2023-12-15 12:32:01 -05:00
# frozen_string_literal: true
2024-01-17 13:08:49 -05:00
require_relative " endpoint_compliance "
2023-12-15 12:32:01 -05:00
2024-01-17 13:08:49 -05:00
class GeminiMock < EndpointMock
2023-12-18 16:06:01 -05:00
def response ( content , tool_call : false )
2023-12-15 12:32:01 -05:00
{
candidates : [
{
content : {
2023-12-18 16:06:01 -05:00
parts : [ ( tool_call ? content : { text : content } ) ] ,
2023-12-15 12:32:01 -05:00
role : " model " ,
} ,
finishReason : " STOP " ,
index : 0 ,
safetyRatings : [
{ category : " HARM_CATEGORY_SEXUALLY_EXPLICIT " , probability : " NEGLIGIBLE " } ,
{ category : " HARM_CATEGORY_HATE_SPEECH " , probability : " NEGLIGIBLE " } ,
{ category : " HARM_CATEGORY_HARASSMENT " , probability : " NEGLIGIBLE " } ,
{ category : " HARM_CATEGORY_DANGEROUS_CONTENT " , probability : " NEGLIGIBLE " } ,
] ,
} ,
] ,
promptFeedback : {
safetyRatings : [
{ category : " HARM_CATEGORY_SEXUALLY_EXPLICIT " , probability : " NEGLIGIBLE " } ,
{ category : " HARM_CATEGORY_HATE_SPEECH " , probability : " NEGLIGIBLE " } ,
{ category : " HARM_CATEGORY_HARASSMENT " , probability : " NEGLIGIBLE " } ,
{ category : " HARM_CATEGORY_DANGEROUS_CONTENT " , probability : " NEGLIGIBLE " } ,
] ,
} ,
}
end
2023-12-18 16:06:01 -05:00
def stub_response ( prompt , response_text , tool_call : false )
2023-12-15 12:32:01 -05:00
WebMock
. stub_request (
:post ,
2024-01-17 13:08:49 -05:00
" https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key= #{ SiteSetting . ai_gemini_api_key } " ,
2023-12-15 12:32:01 -05:00
)
2024-01-17 13:08:49 -05:00
. with ( body : request_body ( prompt , tool_call ) )
2023-12-18 16:06:01 -05:00
. to_return ( status : 200 , body : JSON . dump ( response ( response_text , tool_call : tool_call ) ) )
2023-12-15 12:32:01 -05:00
end
2023-12-18 16:06:01 -05:00
def stream_line ( delta , finish_reason : nil , tool_call : false )
2023-12-15 12:32:01 -05:00
{
candidates : [
{
content : {
2023-12-18 16:06:01 -05:00
parts : [ ( tool_call ? delta : { text : delta } ) ] ,
2023-12-15 12:32:01 -05:00
role : " model " ,
} ,
finishReason : finish_reason ,
index : 0 ,
safetyRatings : [
{ category : " HARM_CATEGORY_SEXUALLY_EXPLICIT " , probability : " NEGLIGIBLE " } ,
{ category : " HARM_CATEGORY_HATE_SPEECH " , probability : " NEGLIGIBLE " } ,
{ category : " HARM_CATEGORY_HARASSMENT " , probability : " NEGLIGIBLE " } ,
{ category : " HARM_CATEGORY_DANGEROUS_CONTENT " , probability : " NEGLIGIBLE " } ,
] ,
} ,
] ,
} . to_json
end
2023-12-18 16:06:01 -05:00
def stub_streamed_response ( prompt , deltas , tool_call : false )
2023-12-15 12:32:01 -05:00
chunks =
deltas . each_with_index . map do | _ , index |
if index == ( deltas . length - 1 )
2023-12-18 16:06:01 -05:00
stream_line ( deltas [ index ] , finish_reason : " STOP " , tool_call : tool_call )
2023-12-15 12:32:01 -05:00
else
2023-12-18 16:06:01 -05:00
stream_line ( deltas [ index ] , tool_call : tool_call )
2023-12-15 12:32:01 -05:00
end
end
2024-01-04 16:15:34 -05:00
chunks = chunks . join ( " \n , \n " ) . prepend ( " [ \n " ) . concat ( " \n ] " ) . split ( " " )
2023-12-15 12:32:01 -05:00
WebMock
. stub_request (
:post ,
2024-01-17 13:08:49 -05:00
" https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:streamGenerateContent?key= #{ SiteSetting . ai_gemini_api_key } " ,
2023-12-15 12:32:01 -05:00
)
2024-01-17 13:08:49 -05:00
. with ( body : request_body ( prompt , tool_call ) )
2023-12-15 12:32:01 -05:00
. to_return ( status : 200 , body : chunks )
end
2024-01-17 13:08:49 -05:00
def tool_payload
{
name : " get_weather " ,
description : " Get the weather in a city " ,
parameters : {
type : " object " ,
required : %w[ location unit ] ,
properties : {
" location " = > {
type : " string " ,
description : " the city name " ,
} ,
" unit " = > {
type : " string " ,
description : " the unit of measurement celcius c or fahrenheit f " ,
enum : %w[ c f ] ,
} ,
} ,
} ,
}
end
def request_body ( prompt , tool_call )
model
. default_options
. merge ( contents : prompt )
. tap { | b | b [ :tools ] = [ { function_declarations : [ tool_payload ] } ] if tool_call }
. to_json
end
def tool_deltas
[
{ " functionCall " = > { name : " get_weather " , args : { } } } ,
{ " functionCall " = > { name : " get_weather " , args : { location : " " } } } ,
{ " functionCall " = > { name : " get_weather " , args : { location : " Sydney " , unit : " c " } } } ,
]
end
def tool_response
{ " functionCall " = > { name : " get_weather " , args : { location : " Sydney " , unit : " c " } } }
end
end
RSpec . describe DiscourseAi :: Completions :: Endpoints :: Gemini do
2024-07-30 12:44:57 -04:00
subject ( :endpoint ) { described_class . new ( model ) }
fab! ( :model ) { Fabricate ( :gemini_model , vision_enabled : true ) }
2024-07-24 15:29:47 -04:00
2024-03-05 10:48:28 -05:00
fab! ( :user )
2024-01-17 13:08:49 -05:00
2024-05-22 02:35:29 -04:00
let ( :image100x100 ) { plugin_file_from_fixtures ( " 100x100.jpg " ) }
let ( :upload100x100 ) do
UploadCreator . new ( image100x100 , " image.jpg " ) . create_for ( Discourse . system_user . id )
end
2024-03-07 14:37:23 -05:00
let ( :gemini_mock ) { GeminiMock . new ( endpoint ) }
2024-01-17 13:08:49 -05:00
let ( :compliance ) do
EndpointsCompliance . new ( self , endpoint , DiscourseAi :: Completions :: Dialects :: Gemini , user )
end
2024-08-12 02:10:16 -04:00
let ( :echo_tool ) do
{
name : " echo " ,
description : " echo something " ,
parameters : [ { name : " text " , type : " string " , description : " text to echo " , required : true } ] ,
}
end
# by default gemini is meant to use AUTO mode, however new experimental models
# appear to require this to be explicitly set
it " Explicitly specifies tool config " do
prompt = DiscourseAi :: Completions :: Prompt . new ( " Hello " , tools : [ echo_tool ] )
response = gemini_mock . response ( " World " ) . to_json
req_body = nil
llm = DiscourseAi :: Completions :: Llm . proxy ( " custom: #{ model . id } " )
url = " #{ model . url } :generateContent?key=123 "
stub_request ( :post , url ) . with (
body :
proc do | _req_body |
req_body = _req_body
true
end ,
) . to_return ( status : 200 , body : response )
response = llm . generate ( prompt , user : user )
expect ( response ) . to eq ( " World " )
parsed = JSON . parse ( req_body , symbolize_names : true )
expect ( parsed [ :tool_config ] ) . to eq ( { function_calling_config : { mode : " AUTO " } } )
end
2024-11-03 18:07:17 -05:00
it " properly encodes tool calls " do
prompt = DiscourseAi :: Completions :: Prompt . new ( " Hello " , tools : [ echo_tool ] )
llm = DiscourseAi :: Completions :: Llm . proxy ( " custom: #{ model . id } " )
url = " #{ model . url } :generateContent?key=123 "
response_json = { " functionCall " = > { name : " echo " , args : { text : " <S>ydney " } } }
response = gemini_mock . response ( response_json , tool_call : true ) . to_json
stub_request ( :post , url ) . to_return ( status : 200 , body : response )
response = llm . generate ( prompt , user : user )
2024-11-11 16:14:30 -05:00
tool =
DiscourseAi :: Completions :: ToolCall . new (
id : " tool_0 " ,
name : " echo " ,
parameters : {
text : " <S>ydney " ,
} ,
)
expect ( response ) . to eq ( tool )
2024-11-03 18:07:17 -05:00
end
2024-05-22 02:35:29 -04:00
it " Supports Vision API " do
prompt =
DiscourseAi :: Completions :: Prompt . new (
" You are image bot " ,
messages : [ type : :user , id : " user1 " , content : " hello " , upload_ids : [ upload100x100 . id ] ] ,
)
2024-01-17 13:08:49 -05:00
2024-05-22 02:35:29 -04:00
encoded = prompt . encoded_uploads ( prompt . messages . last )
2024-01-17 13:08:49 -05:00
2024-05-22 02:35:29 -04:00
response = gemini_mock . response ( " World " ) . to_json
req_body = nil
2024-07-24 15:29:47 -04:00
llm = DiscourseAi :: Completions :: Llm . proxy ( " custom: #{ model . id } " )
2024-07-30 12:44:57 -04:00
url = " #{ model . url } :generateContent?key=123 "
2024-05-22 02:35:29 -04:00
stub_request ( :post , url ) . with (
body :
proc do | _req_body |
req_body = _req_body
true
end ,
) . to_return ( status : 200 , body : response )
response = llm . generate ( prompt , user : user )
expect ( response ) . to eq ( " World " )
expected_prompt = {
" generationConfig " = > {
} ,
2024-06-23 19:59:42 -04:00
" safetySettings " = > [
{ " category " = > " HARM_CATEGORY_HARASSMENT " , " threshold " = > " BLOCK_NONE " } ,
{ " category " = > " HARM_CATEGORY_SEXUALLY_EXPLICIT " , " threshold " = > " BLOCK_NONE " } ,
{ " category " = > " HARM_CATEGORY_HATE_SPEECH " , " threshold " = > " BLOCK_NONE " } ,
{ " category " = > " HARM_CATEGORY_DANGEROUS_CONTENT " , " threshold " = > " BLOCK_NONE " } ,
] ,
2024-05-22 02:35:29 -04:00
" contents " = > [
{
" role " = > " user " ,
" parts " = > [
{ " text " = > " hello " } ,
{ " inlineData " = > { " mimeType " = > " image/jpeg " , " data " = > encoded [ 0 ] [ :base64 ] } } ,
] ,
} ,
] ,
" systemInstruction " = > {
" role " = > " system " ,
" parts " = > [ { " text " = > " You are image bot " } ] ,
} ,
}
expect ( JSON . parse ( req_body ) ) . to eq ( expected_prompt )
end
2024-11-11 16:14:30 -05:00
it " Can stream tool calls correctly " do
rows = [
{
candidates : [
{
content : {
parts : [ { functionCall : { name : " echo " , args : { text : " sam<>wh!s " } } } ] ,
role : " model " ,
} ,
safetyRatings : [
{ category : " HARM_CATEGORY_HATE_SPEECH " , probability : " NEGLIGIBLE " } ,
{ category : " HARM_CATEGORY_DANGEROUS_CONTENT " , probability : " NEGLIGIBLE " } ,
{ category : " HARM_CATEGORY_HARASSMENT " , probability : " NEGLIGIBLE " } ,
{ category : " HARM_CATEGORY_SEXUALLY_EXPLICIT " , probability : " NEGLIGIBLE " } ,
] ,
} ,
] ,
usageMetadata : {
promptTokenCount : 625 ,
totalTokenCount : 625 ,
} ,
modelVersion : " gemini-1.5-pro-002 " ,
} ,
{
candidates : [ { content : { parts : [ { text : " " } ] , role : " model " } , finishReason : " STOP " } ] ,
usageMetadata : {
promptTokenCount : 625 ,
candidatesTokenCount : 4 ,
totalTokenCount : 629 ,
} ,
modelVersion : " gemini-1.5-pro-002 " ,
} ,
]
payload = rows . map { | r | " data: #{ r . to_json } \n \n " } . join
llm = DiscourseAi :: Completions :: Llm . proxy ( " custom: #{ model . id } " )
url = " #{ model . url } :streamGenerateContent?alt=sse&key=123 "
prompt = DiscourseAi :: Completions :: Prompt . new ( " Hello " , tools : [ echo_tool ] )
output = [ ]
stub_request ( :post , url ) . to_return ( status : 200 , body : payload )
llm . generate ( prompt , user : user ) { | partial | output << partial }
tool_call =
DiscourseAi :: Completions :: ToolCall . new (
id : " tool_0 " ,
name : " echo " ,
parameters : {
text : " sam<>wh!s " ,
} ,
)
expect ( output ) . to eq ( [ tool_call ] )
log = AiApiAuditLog . order ( :id ) . last
expect ( log . request_tokens ) . to eq ( 625 )
expect ( log . response_tokens ) . to eq ( 4 )
end
2024-11-18 17:22:39 -05:00
it " Can correctly handle malformed responses " do
response = << ~ TEXT
data : { " candidates " : [ { " content " : { " parts " : [ { " text " : " Certainly " } ] , " role " : " model " } } ] , " usageMetadata " : { " promptTokenCount " : 399 , " totalTokenCount " : 399 } , " modelVersion " : " gemini-1.5-pro-002 " }
data : { " candidates " : [ { " content " : { " parts " : [ { " text " : " ! I'll create a simple \\ " Hello , World ! \ \ " page where each letter " } ] , " role " : " model " } , " safetyRatings " : [ { " category " : " HARM_CATEGORY_HATE_SPEECH " , " probability " : " NEGLIGIBLE " } , { " category " : " HARM_CATEGORY_DANGEROUS_CONTENT " , " probability " : " NEGLIGIBLE " } , { " category " : " HARM_CATEGORY_HARASSMENT " , " probability " : " NEGLIGIBLE " } , { " category " : " HARM_CATEGORY_SEXUALLY_EXPLICIT " , " probability " : " NEGLIGIBLE " } ] } ] , " usageMetadata " : { " promptTokenCount " : 399 , " totalTokenCount " : 399 } , " modelVersion " : " gemini-1.5-pro-002 " }
data : { " candidates " : [ { " content " : { " parts " : [ { " text " : " has a different color using inline styles for simplicity. Each letter will be wrapped " } ] , " role " : " model " } , " safetyRatings " : [ { " category " : " HARM_CATEGORY_HATE_SPEECH " , " probability " : " NEGLIGIBLE " } , { " category " : " HARM_CATEGORY_DANGEROUS_CONTENT " , " probability " : " NEGLIGIBLE " } , { " category " : " HARM_CATEGORY_HARASSMENT " , " probability " : " NEGLIGIBLE " } , { " category " : " HARM_CATEGORY_SEXUALLY_EXPLICIT " , " probability " : " NEGLIGIBLE " } ] } ] , " usageMetadata " : { " promptTokenCount " : 399 , " totalTokenCount " : 399 } , " modelVersion " : " gemini-1.5-pro-002 " }
data : { " candidates " : [ { " content " : { " parts " : [ { " text " : " " } ] , " role " : " model " } , " finishReason " : " STOP " } ] , " usageMetadata " : { " promptTokenCount " : 399 , " candidatesTokenCount " : 191 , " totalTokenCount " : 590 } , " modelVersion " : " gemini-1.5-pro-002 " }
data : { " candidates " : [ { " finishReason " : " MALFORMED_FUNCTION_CALL " } ] , " usageMetadata " : { " promptTokenCount " : 399 , " candidatesTokenCount " : 191 , " totalTokenCount " : 590 } , " modelVersion " : " gemini-1.5-pro-002 " }
TEXT
llm = DiscourseAi :: Completions :: Llm . proxy ( " custom: #{ model . id } " )
url = " #{ model . url } :streamGenerateContent?alt=sse&key=123 "
output = [ ]
stub_request ( :post , url ) . to_return ( status : 200 , body : response )
llm . generate ( " Hello " , user : user ) { | partial | output << partial }
expect ( output ) . to eq (
[
" Certainly " ,
" ! I'll create a simple \" Hello, World! \" page where each letter " ,
" has a different color using inline styles for simplicity. Each letter will be wrapped " ,
] ,
)
end
2024-05-22 02:35:29 -04:00
it " Can correctly handle streamed responses even if they are chunked badly " do
data = + " "
data << " da|ta: | "
data << gemini_mock . response ( " Hello " ) . to_json
data << " \r \n \r \n data: "
data << gemini_mock . response ( " |World " ) . to_json
data << " \r \n \r \n data: "
data << gemini_mock . response ( " Sam " ) . to_json
split = data . split ( " | " )
2024-07-24 15:29:47 -04:00
llm = DiscourseAi :: Completions :: Llm . proxy ( " custom: #{ model . id } " )
2024-07-30 12:44:57 -04:00
url = " #{ model . url } :streamGenerateContent?alt=sse&key=123 "
2024-05-22 02:35:29 -04:00
2024-11-11 16:14:30 -05:00
output = [ ]
2024-05-22 02:35:29 -04:00
gemini_mock . with_chunk_array_support do
stub_request ( :post , url ) . to_return ( status : 200 , body : split )
llm . generate ( " Hello " , user : user ) { | partial | output << partial }
2024-01-17 13:08:49 -05:00
end
2024-05-22 02:35:29 -04:00
2024-11-11 16:14:30 -05:00
expect ( output . join ) . to eq ( " Hello World Sam " )
2024-01-17 13:08:49 -05:00
end
2023-12-15 12:32:01 -05:00
end