diff --git a/google/google.go b/google/google.go index 80c45217f202ebe8b6bbe9bccfaa6c42a824d0ed..e2e1458a897f11da2bcf04d424d407f648455b76 100644 --- a/google/google.go +++ b/google/google.go @@ -1,6 +1,7 @@ package google import ( + "cmp" "context" "encoding/base64" "encoding/json" @@ -12,6 +13,7 @@ import ( "github.com/charmbracelet/fantasy/ai" "github.com/charmbracelet/x/exp/slice" + "github.com/google/uuid" "google.golang.org/genai" ) @@ -403,8 +405,161 @@ func (g *languageModel) Provider() string { } // Stream implements ai.LanguageModel. -func (g *languageModel) Stream(context.Context, ai.Call) (ai.StreamResponse, error) { - return nil, errors.New("unimplemented") +func (g *languageModel) Stream(ctx context.Context, call ai.Call) (ai.StreamResponse, error) { + config, contents, warnings, err := g.prepareParams(call) + if err != nil { + return nil, err + } + + lastMessage, history, ok := slice.Pop(contents) + if !ok { + return nil, errors.New("no messages to send") + } + + chat, err := g.client.Chats.Create(ctx, g.modelID, config, history) + if err != nil { + return nil, err + } + + return func(yield func(ai.StreamPart) bool) { + if len(warnings) > 0 { + if !yield(ai.StreamPart{ + Type: ai.StreamPartTypeWarnings, + Warnings: warnings, + }) { + return + } + } + + var currentContent string + var toolCalls []ai.ToolCallContent + var isActiveText bool + var usage ai.Usage + + // Stream the response + for resp, err := range chat.SendMessageStream(ctx, depointerSlice(lastMessage.Parts)...) { + if err != nil { + yield(ai.StreamPart{ + Type: ai.StreamPartTypeError, + Error: err, + }) + return + } + + if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil { + for _, part := range resp.Candidates[0].Content.Parts { + switch { + case part.Text != "": + delta := part.Text + if delta != "" { + if !isActiveText { + isActiveText = true + if !yield(ai.StreamPart{ + Type: ai.StreamPartTypeTextStart, + ID: "0", + }) { + return + } + } + if !yield(ai.StreamPart{ + Type: ai.StreamPartTypeTextDelta, + ID: "0", + Delta: delta, + }) { + return + } + currentContent += delta + } + case part.FunctionCall != nil: + if isActiveText { + isActiveText = false + if !yield(ai.StreamPart{ + Type: ai.StreamPartTypeTextEnd, + ID: "0", + }) { + return + } + } + + toolCallID := cmp.Or(part.FunctionCall.ID, part.FunctionCall.Name, uuid.NewString()) + + args, err := json.Marshal(part.FunctionCall.Args) + if err != nil { + yield(ai.StreamPart{ + Type: ai.StreamPartTypeError, + Error: err, + }) + return + } + + if !yield(ai.StreamPart{ + Type: ai.StreamPartTypeToolInputStart, + ID: toolCallID, + ToolCallName: part.FunctionCall.Name, + }) { + return + } + + if !yield(ai.StreamPart{ + Type: ai.StreamPartTypeToolInputDelta, + ID: toolCallID, + Delta: string(args), + }) { + return + } + + if !yield(ai.StreamPart{ + Type: ai.StreamPartTypeToolInputEnd, + ID: toolCallID, + }) { + return + } + + if !yield(ai.StreamPart{ + Type: ai.StreamPartTypeToolCall, + ID: toolCallID, + ToolCallName: part.FunctionCall.Name, + ToolCallInput: string(args), + ProviderExecuted: false, + }) { + return + } + + toolCalls = append(toolCalls, ai.ToolCallContent{ + ToolCallID: toolCallID, + ToolName: part.FunctionCall.Name, + Input: string(args), + ProviderExecuted: false, + }) + } + } + } + + if resp.UsageMetadata != nil { + usage = mapUsage(resp.UsageMetadata) + } + } + + if isActiveText { + if !yield(ai.StreamPart{ + Type: ai.StreamPartTypeTextEnd, + ID: "0", + }) { + return + } + } + + finishReason := ai.FinishReasonStop + if len(toolCalls) > 0 { + finishReason = ai.FinishReasonToolCalls + } + + yield(ai.StreamPart{ + Type: ai.StreamPartTypeFinish, + Usage: usage, + FinishReason: finishReason, + }) + }, nil } func toGoogleTools(tools []ai.Tool, toolChoice *ai.ToolChoice) (googleTools []*genai.FunctionDeclaration, googleToolChoice *genai.ToolConfig, warnings []ai.CallWarning) { diff --git a/providertests/testdata/TestStream/google-gemini-2.5-flash.yaml b/providertests/testdata/TestStream/google-gemini-2.5-flash.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a387948b1675c09b7ff26ae42a53affe3cc5c7bd --- /dev/null +++ b/providertests/testdata/TestStream/google-gemini-2.5-flash.yaml @@ -0,0 +1,33 @@ +--- +version: 2 +interactions: +- id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 188 + host: generativelanguage.googleapis.com + body: "{\"contents\":[{\"parts\":[{\"text\":\"Count from 1 to 3 in Spanish\"}],\"role\":\"user\"}],\"generationConfig\":{},\"systemInstruction\":{\"parts\":[{\"text\":\"You are a helpful assistant\"}],\"role\":\"user\"}}\n" + form: + alt: + - sse + headers: + Content-Type: + - application/json + User-Agent: + - google-genai-sdk/1.23.0 gl-go/go1.24.5 + url: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + body: "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"Here you go:\\n\\n1. **Uno**\\n2. **Dos**\\n3. **Tres**\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 16,\"candidatesTokenCount\": 25,\"totalTokenCount\": 74,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 16}],\"thoughtsTokenCount\": 33},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"jl3AaOWjGefyqtsPi_C6sAM\"}\r\n\r\n" + headers: + Content-Type: + - text/event-stream + status: 200 OK + code: 200 + duration: 1.178272625s diff --git a/providertests/testdata/TestStream/google-gemini-2.5-pro.yaml b/providertests/testdata/TestStream/google-gemini-2.5-pro.yaml new file mode 100644 index 0000000000000000000000000000000000000000..1e5db68a70ba3ec7672a047db128cb8f4e9a3df1 --- /dev/null +++ b/providertests/testdata/TestStream/google-gemini-2.5-pro.yaml @@ -0,0 +1,33 @@ +--- +version: 2 +interactions: +- id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 188 + host: generativelanguage.googleapis.com + body: "{\"contents\":[{\"parts\":[{\"text\":\"Count from 1 to 3 in Spanish\"}],\"role\":\"user\"}],\"generationConfig\":{},\"systemInstruction\":{\"parts\":[{\"text\":\"You are a helpful assistant\"}],\"role\":\"user\"}}\n" + form: + alt: + - sse + headers: + Content-Type: + - application/json + User-Agent: + - google-genai-sdk/1.23.0 gl-go/go1.24.5 + url: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + body: "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"Claro:\\n\\n1. **Uno**\\n2. **Dos**\\n3. **Tres**\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 16,\"candidatesTokenCount\": 24,\"totalTokenCount\": 67,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 16}],\"thoughtsTokenCount\": 27},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"j13AaJWDJfeHqtsPv4Tk0QY\"}\r\n\r\n" + headers: + Content-Type: + - text/event-stream + status: 200 OK + code: 200 + duration: 1.10918025s diff --git a/providertests/testdata/TestStreamWithTools/google-gemini-2.5-flash.yaml b/providertests/testdata/TestStreamWithTools/google-gemini-2.5-flash.yaml new file mode 100644 index 0000000000000000000000000000000000000000..7d6b4d785c1953f8d6efb18f00a391cc37455ca5 --- /dev/null +++ b/providertests/testdata/TestStreamWithTools/google-gemini-2.5-flash.yaml @@ -0,0 +1,63 @@ +--- +version: 2 +interactions: +- id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 530 + host: generativelanguage.googleapis.com + body: "{\"contents\":[{\"parts\":[{\"text\":\"What is 15 + 27?\"}],\"role\":\"user\"}],\"generationConfig\":{},\"systemInstruction\":{\"parts\":[{\"text\":\"You are a helpful assistant. Use the add tool to perform calculations.\"}],\"role\":\"user\"},\"toolConfig\":{\"functionCallingConfig\":{\"mode\":\"AUTO\"}},\"tools\":[{\"functionDeclarations\":[{\"description\":\"Add two numbers\",\"name\":\"add\",\"parameters\":{\"properties\":{\"a\":{\"description\":\"first number\",\"type\":\"INTEGER\"},\"b\":{\"description\":\"second number\",\"type\":\"INTEGER\"}},\"required\":[\"a\",\"b\"],\"type\":\"OBJECT\"}}]}]}\n" + form: + alt: + - sse + headers: + Content-Type: + - application/json + User-Agent: + - google-genai-sdk/1.23.0 gl-go/go1.24.5 + url: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + body: "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": {\"name\": \"add\",\"args\": {\"b\": 27,\"a\": 15}},\"thoughtSignature\": \"CikB0e2Kb8U5/pBCljebIcg2A6YS6hrbbpSZxkBqwLEtMeXSp3VgqzxFfwpSAdHtim/J3oxEa23pl45S6ILKbIk8mrvafueiUni658C3m0AydKXx96v4XZMyCKZXtlRlKF2b5cIVRXnd5FU269+k3aYUodfw4KEyM1/6k2JtywrUAQHR7YpvMSRAu6uOizYwxCivae6QRftgUArCIIrKg4//i8/rqvyXf+H6lchZbWgHqsWqUqeP83Xbr/Wn9ycSg9E3ql5I+fg02wzdoi8l1rizh0G9TB2Y6L9u7LCmNCHljEqtbyEeK6z/E0DGUOPxzg6a4F+DZgy4+CgJcv78/YDXz89snc3BfWqrtfjdOWRkF7aBzRgrjTPYPy9PlRuN0IpCcRuEVj2Z545JFd86y3fEAlNyPquEdYTZPNCRNyvfzfxe/akFsKVwoxT9n3bn042w7b9FCioB0e2Kb7hPtS/ZpcnnCkWr8yuufP007PLPpxftDTxftQLQ+8stieRLhPk=\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 72,\"candidatesTokenCount\": 20,\"totalTokenCount\": 168,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 72}],\"thoughtsTokenCount\": 76},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"kF3AaKvWLqqvqtsPqK2DkAM\"}\r\n\r\n" + headers: + Content-Type: + - text/event-stream + status: 200 OK + code: 200 + duration: 1.43092075s +- id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 723 + host: generativelanguage.googleapis.com + body: "{\"contents\":[{\"parts\":[{\"text\":\"What is 15 + 27?\"}],\"role\":\"user\"},{\"parts\":[{\"functionCall\":{\"args\":{\"a\":15,\"b\":27},\"id\":\"add\",\"name\":\"add\"}}],\"role\":\"model\"},{\"parts\":[{\"functionResponse\":{\"id\":\"add\",\"name\":\"add\",\"response\":{\"result\":\"42\"}}}],\"role\":\"user\"}],\"generationConfig\":{},\"systemInstruction\":{\"parts\":[{\"text\":\"You are a helpful assistant. Use the add tool to perform calculations.\"}],\"role\":\"user\"},\"toolConfig\":{\"functionCallingConfig\":{\"mode\":\"AUTO\"}},\"tools\":[{\"functionDeclarations\":[{\"description\":\"Add two numbers\",\"name\":\"add\",\"parameters\":{\"properties\":{\"a\":{\"description\":\"first number\",\"type\":\"INTEGER\"},\"b\":{\"description\":\"second number\",\"type\":\"INTEGER\"}},\"required\":[\"a\",\"b\"],\"type\":\"OBJECT\"}}]}]}\n" + form: + alt: + - sse + headers: + Content-Type: + - application/json + User-Agent: + - google-genai-sdk/1.23.0 gl-go/go1.24.5 + url: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + body: "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"1\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 106,\"candidatesTokenCount\": 1,\"totalTokenCount\": 107,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 106}]},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"kV3AaLLYMq-9qtsPp7ytgAM\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"5 + 27 = 42.\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 106,\"candidatesTokenCount\": 11,\"totalTokenCount\": 117,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 106}]},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"kV3AaLLYMq-9qtsPp7ytgAM\"}\r\n\r\n" + headers: + Content-Type: + - text/event-stream + status: 200 OK + code: 200 + duration: 509.578042ms diff --git a/providertests/testdata/TestStreamWithTools/google-gemini-2.5-pro.yaml b/providertests/testdata/TestStreamWithTools/google-gemini-2.5-pro.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f75e4dc979b82ec0ce6912c41e54451afa171e44 --- /dev/null +++ b/providertests/testdata/TestStreamWithTools/google-gemini-2.5-pro.yaml @@ -0,0 +1,63 @@ +--- +version: 2 +interactions: +- id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 530 + host: generativelanguage.googleapis.com + body: "{\"contents\":[{\"parts\":[{\"text\":\"What is 15 + 27?\"}],\"role\":\"user\"}],\"generationConfig\":{},\"systemInstruction\":{\"parts\":[{\"text\":\"You are a helpful assistant. Use the add tool to perform calculations.\"}],\"role\":\"user\"},\"toolConfig\":{\"functionCallingConfig\":{\"mode\":\"AUTO\"}},\"tools\":[{\"functionDeclarations\":[{\"description\":\"Add two numbers\",\"name\":\"add\",\"parameters\":{\"properties\":{\"a\":{\"description\":\"first number\",\"type\":\"INTEGER\"},\"b\":{\"description\":\"second number\",\"type\":\"INTEGER\"}},\"required\":[\"a\",\"b\"],\"type\":\"OBJECT\"}}]}]}\n" + form: + alt: + - sse + headers: + Content-Type: + - application/json + User-Agent: + - google-genai-sdk/1.23.0 gl-go/go1.24.5 + url: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + body: "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": {\"name\": \"add\",\"args\": {\"b\": 27,\"a\": 15}},\"thoughtSignature\": \"CiQB0e2Kb2msJaUJQD/ryh2cHxovqVsdNAi70C56akmtxGkHt6gKTgHR7Ypv5epJU/RMVeBdHVar6Hdi2Vu9XsjQXYE5ARa7Oa0KJJ3T05dS9wFHmvtNunRmMMFDd66JrL1YdDm8jb+/UtYT8lKvI3Edn2zgXQroAQHR7YpvVJArRl+sKCbpq+eYmufcPWNqP8yj7ur46fqm4fyQqs7cRtB9PoydN2i54A8Hp5T/xVZWHdb2XSVSzlR8huRTgyoF7/1xtCKLnHdx+J4TtzayU/Niwngm7cvV2Qxy5G4KyBDrWk4zG/7laQ6iNLjvieMuMyfXvZ+7QBCW4Yqnnp4EVtuvnHK97JYVGJ3D+gzUcCgZB0RlYaXjkMHRBRJmA4iu2YZUXsYoghGLsK6Gipy0ImnNnbCHDghyS2aYBdiuMnIXA4V08ique5D81pdRkNKXUSIrfIsIpaiOrLfwcyLdqKoKMwHR7YpvFbVQa/Sg4nXKQ3YLBd3WcT0SGEiCdhPS9r0l5i3jmnwtRBeHlQUmYW3J0TfABg==\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 72,\"candidatesTokenCount\": 20,\"totalTokenCount\": 165,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 72}],\"thoughtsTokenCount\": 73},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"kl3AaMrLIMiBmtkPlZiKgAM\"}\r\n\r\n" + headers: + Content-Type: + - text/event-stream + status: 200 OK + code: 200 + duration: 1.165652542s +- id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 723 + host: generativelanguage.googleapis.com + body: "{\"contents\":[{\"parts\":[{\"text\":\"What is 15 + 27?\"}],\"role\":\"user\"},{\"parts\":[{\"functionCall\":{\"args\":{\"a\":15,\"b\":27},\"id\":\"add\",\"name\":\"add\"}}],\"role\":\"model\"},{\"parts\":[{\"functionResponse\":{\"id\":\"add\",\"name\":\"add\",\"response\":{\"result\":\"42\"}}}],\"role\":\"user\"}],\"generationConfig\":{},\"systemInstruction\":{\"parts\":[{\"text\":\"You are a helpful assistant. Use the add tool to perform calculations.\"}],\"role\":\"user\"},\"toolConfig\":{\"functionCallingConfig\":{\"mode\":\"AUTO\"}},\"tools\":[{\"functionDeclarations\":[{\"description\":\"Add two numbers\",\"name\":\"add\",\"parameters\":{\"properties\":{\"a\":{\"description\":\"first number\",\"type\":\"INTEGER\"},\"b\":{\"description\":\"second number\",\"type\":\"INTEGER\"}},\"required\":[\"a\",\"b\"],\"type\":\"OBJECT\"}}]}]}\n" + form: + alt: + - sse + headers: + Content-Type: + - application/json + User-Agent: + - google-genai-sdk/1.23.0 gl-go/go1.24.5 + url: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + body: "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"The sum of \"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 106,\"candidatesTokenCount\": 3,\"totalTokenCount\": 109,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 106}]},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"k13AaK7JJcTMqtsP5IG68QQ\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"15 and 27 is 42.\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 106,\"candidatesTokenCount\": 14,\"totalTokenCount\": 120,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 106}]},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"k13AaK7JJcTMqtsP5IG68QQ\"}\r\n\r\n" + headers: + Content-Type: + - text/event-stream + status: 200 OK + code: 200 + duration: 612.576833ms