1package provider
2
3import (
4 "bytes"
5 "context"
6 "encoding/base64"
7 "encoding/json"
8 "fmt"
9 "io"
10 "net/http"
11 "os"
12 "path/filepath"
13 "strings"
14 "time"
15
16 "github.com/google/uuid"
17)
18
19// Google Gemini provider, talking directly to the Generative Language
20// REST API at generativelanguage.googleapis.com. We hand-roll the wire
21// format instead of pulling in @google/genai (the SDK is large and we
22// only need streamGenerateContent + the models list).
23//
24// Auth model: API key only. Google does NOT issue OAuth tokens for
25// consumer Gemini Advanced / Google One AI subscriptions; programmatic
26// access requires either an AI Studio API key (this client) or Vertex
27// AI / GCP service-account credentials (separate provider, not yet
28// implemented in zut).
29
30const geminiDefaultBaseURL = "https://generativelanguage.googleapis.com"
31
32// geminiClient implements Client against the Gemini Generative Language API.
33type geminiClient struct {
34 apiKey string
35 baseURL string
36 http *http.Client
37}
38
39// NewGemini creates a Gemini client using an AI Studio API key.
40// baseURL may be empty; defaults to https://generativelanguage.googleapis.com.
41func NewGemini(apiKey, baseURL string) Client {
42 if baseURL == "" {
43 baseURL = geminiDefaultBaseURL
44 }
45 return &geminiClient{
46 apiKey: apiKey,
47 baseURL: strings.TrimRight(baseURL, "/"),
48 http: &http.Client{Timeout: 0},
49 }
50}
51
52func (c *geminiClient) Name() string { return "google" }
53
54// ---- wire types ----
55//
56// Subset of Gemini's Content / Part / GenerateContentRequest schema.
57// Only the fields zut actually emits or consumes are declared here.
58
59type gemInlineData struct {
60 MimeType string `json:"mimeType"`
61 Data string `json:"data"` // base64
62}
63
64type gemFunctionCall struct {
65 Name string `json:"name"`
66 Args json.RawMessage `json:"args,omitempty"`
67}
68
69type gemFunctionResponse struct {
70 Name string `json:"name"`
71 Response json.RawMessage `json:"response"`
72}
73
74// gemPart is one element of a Content. Exactly one of the optional
75// fields is populated per part. We use pointers so empty values are
76// omitted from the wire, which matters for tool responses (Gemini
77// rejects parts with both `text` and `functionResponse` set).
78type gemPart struct {
79 Text string `json:"text,omitempty"`
80 InlineData *gemInlineData `json:"inlineData,omitempty"`
81 FunctionCall *gemFunctionCall `json:"functionCall,omitempty"`
82 FunctionResponse *gemFunctionResponse `json:"functionResponse,omitempty"`
83 // Thought marks a thought-summary part. ThoughtSignature is opaque
84 // provider state that must be replayed on the same part.
85 Thought bool `json:"thought,omitempty"`
86 ThoughtSignature string `json:"thoughtSignature,omitempty"`
87}
88
89type gemContent struct {
90 Role string `json:"role"` // "user" | "model"
91 Parts []gemPart `json:"parts"`
92}
93
94type gemFunctionDecl struct {
95 Name string `json:"name"`
96 Description string `json:"description,omitempty"`
97 Parameters json.RawMessage `json:"parameters,omitempty"`
98}
99
100type gemTool struct {
101 FunctionDeclarations []gemFunctionDecl `json:"functionDeclarations,omitempty"`
102}
103
104type gemThinkingConfig struct {
105 IncludeThoughts bool `json:"includeThoughts,omitempty"`
106 ThinkingBudget *int `json:"thinkingBudget,omitempty"`
107 ThinkingLevel string `json:"thinkingLevel,omitempty"`
108}
109
110type gemGenerationConfig struct {
111 Temperature *float32 `json:"temperature,omitempty"`
112 MaxOutputTokens *int `json:"maxOutputTokens,omitempty"`
113 ThinkingConfig *gemThinkingConfig `json:"thinkingConfig,omitempty"`
114}
115
116type gemSystemInstruction struct {
117 Parts []gemPart `json:"parts"`
118}
119
120type gemRequest struct {
121 Contents []gemContent `json:"contents"`
122 SystemInstruction *gemSystemInstruction `json:"systemInstruction,omitempty"`
123 Tools []gemTool `json:"tools,omitempty"`
124 GenerationConfig *gemGenerationConfig `json:"generationConfig,omitempty"`
125}
126
127// ---- request building ----
128
129func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
130 m, err := FindModel("google", req.Model)
131 if err != nil {
132 // Not in the catalog — still allow custom ids by falling back
133 // to defaults so users can point at unreleased models or
134 // alternate base URLs.
135 m = Model{
136 Provider: "google",
137 ID: req.Model,
138 ContextWindow: 1_000_000,
139 MaxOutput: 8192,
140 Reasoning: strings.Contains(req.Model, "2.5") || strings.Contains(req.Model, "3"),
141 }
142 }
143
144 reasoning := ClampReasoningForModel(m, req.Reasoning)
145 out := &gemRequest{}
146
147 // System prompt → systemInstruction.parts[0].text.
148 if system := req.SystemPrompt(); strings.TrimSpace(system) != "" {
149 out.SystemInstruction = &gemSystemInstruction{
150 Parts: []gemPart{{Text: system}},
151 }
152 }
153
154 functionsEnabled := geminiSupportsFunctionCalling(m.ID)
155 activeTools := activeToolDefinitions(req.Tools, req.Messages)
156
157 // Convert tool defs. Gemini image-generation models reject function
158 // declarations with "Function calling is not enabled for this model";
159 // for those models, send a direct multimodal prompt instead.
160 if functionsEnabled && len(activeTools) > 0 {
161 decls := make([]gemFunctionDecl, 0, len(activeTools))
162 for _, t := range activeTools {
163 schema := sanitizeGeminiToolSchema(t.Schema)
164 decls = append(decls, gemFunctionDecl{
165 Name: t.Name,
166 Description: t.Description,
167 Parameters: schema,
168 })
169 }
170 out.Tools = []gemTool{{FunctionDeclarations: decls}}
171 }
172
173 // Generation config.
174 maxTok := req.MaxTokens
175 if maxTok <= 0 {
176 maxTok = m.MaxOutput
177 }
178 gc := &gemGenerationConfig{Temperature: req.Temperature}
179 if maxTok > 0 {
180 gc.MaxOutputTokens = &maxTok
181 }
182 if reasoning != "" && m.Reasoning {
183 tc := geminiThinkingConfig(m.ID, reasoning)
184 if tc != nil {
185 gc.ThinkingConfig = tc
186 }
187 }
188 out.GenerationConfig = gc
189
190 // Convert messages. When function calling is disabled for the target
191 // model, also remove historical functionCall/functionResponse parts;
192 // image models should receive only text/image content.
193 msgs := req.Messages
194 if functionsEnabled {
195 msgs = RepairOrphanedToolResults(req.Messages)
196 }
197 for _, msg := range msgs {
198 switch msg.Role {
199 case RoleUser:
200 parts := convertGemUserParts(msg.Content)
201 if len(parts) == 0 {
202 continue
203 }
204 out.Contents = append(out.Contents, gemContent{Role: "user", Parts: parts})
205 case RoleAssistant:
206 parts := convertGemAssistantParts(msg.Content, functionsEnabled)
207 if len(parts) == 0 {
208 continue
209 }
210 out.Contents = append(out.Contents, gemContent{Role: "model", Parts: parts})
211 case RoleTool:
212 if !functionsEnabled {
213 continue
214 }
215 // Each tool_result becomes a user-role message with a
216 // functionResponse part. Gemini's protocol uses
217 // "user" role for tool replies.
218 parts := convertGemToolResultParts(msg.Content)
219 if len(parts) == 0 {
220 continue
221 }
222 out.Contents = append(out.Contents, gemContent{Role: "user", Parts: parts})
223 }
224 }
225
226 return out, m.ID, nil
227}
228
229func sanitizeGeminiToolSchema(schema json.RawMessage) json.RawMessage {
230 if len(schema) == 0 || !json.Valid(schema) {
231 return json.RawMessage(`{"type":"object","properties":{}}`)
232 }
233 var v any
234 if err := json.Unmarshal(schema, &v); err != nil {
235 return json.RawMessage(`{"type":"object","properties":{}}`)
236 }
237 v = stripGeminiUnsupportedSchemaFields(v)
238 out, err := json.Marshal(v)
239 if err != nil || len(out) == 0 || !json.Valid(out) {
240 return json.RawMessage(`{"type":"object","properties":{}}`)
241 }
242 return out
243}
244
245func stripGeminiUnsupportedSchemaFields(v any) any {
246 switch x := v.(type) {
247 case map[string]any:
248 out := make(map[string]any, len(x))
249 for k, val := range x {
250 switch k {
251 case "additionalProperties", "$schema":
252 continue
253 default:
254 out[k] = stripGeminiUnsupportedSchemaFields(val)
255 }
256 }
257 return out
258 case []any:
259 out := make([]any, len(x))
260 for i, val := range x {
261 out[i] = stripGeminiUnsupportedSchemaFields(val)
262 }
263 return out
264 default:
265 return v
266 }
267}
268
269func convertGemUserParts(blocks []Content) []gemPart {
270 var parts []gemPart
271 for _, b := range blocks {
272 switch v := b.(type) {
273 case TextBlock:
274 if v.Text == "" {
275 continue
276 }
277 parts = append(parts, gemPart{Text: v.Text})
278 case ImageBlock:
279 parts = append(parts, gemPart{
280 InlineData: &gemInlineData{
281 MimeType: v.MimeType,
282 Data: base64.StdEncoding.EncodeToString(v.Data),
283 },
284 })
285 }
286 }
287 return parts
288}
289
290func convertGemAssistantParts(blocks []Content, functionsEnabled bool) []gemPart {
291 var parts []gemPart
292 for _, b := range blocks {
293 switch v := b.(type) {
294 case ReasoningBlock:
295 if v.Encrypted != "" || v.Summary != "" {
296 parts = append(parts, gemPart{
297 Text: v.Summary,
298 Thought: true,
299 ThoughtSignature: v.Encrypted,
300 })
301 }
302 case TextBlock:
303 if strings.TrimSpace(v.Text) == "" {
304 continue
305 }
306 parts = append(parts, gemPart{Text: v.Text, ThoughtSignature: v.ThoughtSignature})
307 case ImageBlock:
308 parts = append(parts, gemPart{
309 InlineData: &gemInlineData{
310 MimeType: v.MimeType,
311 Data: base64.StdEncoding.EncodeToString(v.Data),
312 },
313 ThoughtSignature: v.ThoughtSignature,
314 })
315 case ToolCallBlock:
316 if !functionsEnabled {
317 continue
318 }
319 args := v.Arguments
320 if len(args) == 0 || !json.Valid(args) {
321 args = json.RawMessage("{}")
322 }
323 parts = append(parts, gemPart{
324 FunctionCall: &gemFunctionCall{
325 Name: v.Name,
326 Args: args,
327 },
328 ThoughtSignature: v.ThoughtSignature,
329 })
330 }
331 }
332 return parts
333}
334
335func geminiSupportsFunctionCalling(modelID string) bool {
336 id := strings.ToLower(modelID)
337 // Gemini image generation/editing models accept direct multimodal
338 // prompts but reject tools/function declarations.
339 if strings.Contains(id, "flash-image") || strings.Contains(id, "image-generation") {
340 return false
341 }
342 return true
343}
344
345func saveGeminiImageToWorkingDir(mimeType string, data []byte) (string, error) {
346 ext := ".png"
347 switch strings.ToLower(mimeType) {
348 case "image/jpeg", "image/jpg":
349 ext = ".jpg"
350 case "image/webp":
351 ext = ".webp"
352 case "image/gif":
353 ext = ".gif"
354 }
355 name := "zut-gemini-image-" + uuid.NewString() + ext
356 path := filepath.Join(".", name)
357 if err := os.WriteFile(path, data, 0o644); err != nil {
358 return "", err
359 }
360 return path, nil
361}
362
363func convertGemToolResultParts(blocks []Content) []gemPart {
364 var parts []gemPart
365 for _, b := range blocks {
366 tr, ok := b.(ToolResultBlock)
367 if !ok {
368 continue
369 }
370 // Flatten text content. Image content in tool results is dropped
371 // for Gemini < 3 (multimodal function responses are 3+); for the
372 // common path (text output) we wrap as {"output": "..."} or
373 // {"error": "..."} per the SDK's convention.
374 var sb strings.Builder
375 for _, c := range tr.Content {
376 if tb, ok := c.(TextBlock); ok {
377 if sb.Len() > 0 {
378 sb.WriteString("\n")
379 }
380 sb.WriteString(tb.Text)
381 }
382 }
383 key := "output"
384 if tr.IsError {
385 key = "error"
386 }
387 // Gemini wants response to be an object, not a string.
388 respObj := map[string]string{key: sb.String()}
389 respBytes, err := json.Marshal(respObj)
390 if err != nil {
391 respBytes = []byte(`{"output":""}`)
392 }
393 // Tool name is required on functionResponse; the original
394 // ToolCallBlock has it but ToolResultBlock only carries the
395 // call id. We thread the name back via the call id by using
396 // the id as the name fallback — Gemini ignores the name on
397 // the response side as long as it's non-empty.
398 name := tr.CallID
399 parts = append(parts, gemPart{
400 FunctionResponse: &gemFunctionResponse{
401 Name: name,
402 Response: respBytes,
403 },
404 })
405 }
406 return parts
407}
408
409// geminiThinkingConfig maps zut's reasoning level ("low"/"medium"/"high")
410// to Gemini's thinkingConfig. The right knob depends on the model
411// generation: 2.5 family uses thinkingBudget (tokens), 3.x uses
412// thinkingLevel (enum). Returns nil when the level is unrecognised.
413func geminiThinkingConfig(modelID, level string) *gemThinkingConfig {
414 level = NormalizeReasoning(level)
415 id := strings.ToLower(modelID)
416
417 // Gemini 3.x: enum-based thinkingLevel. Pro can't go below LOW.
418 if strings.Contains(id, "gemini-3") {
419 isPro := strings.Contains(id, "-pro")
420 var lvl string
421 switch level {
422 case "minimum":
423 if isPro {
424 lvl = "LOW"
425 } else {
426 lvl = "MINIMAL"
427 }
428 case "low":
429 lvl = "LOW"
430 case "medium":
431 if isPro {
432 lvl = "HIGH"
433 } else {
434 lvl = "MEDIUM"
435 }
436 case "high", "xhigh", "max":
437 lvl = "HIGH"
438 default:
439 return nil
440 }
441 return &gemThinkingConfig{IncludeThoughts: true, ThinkingLevel: lvl}
442 }
443
444 // Gemini 2.5 family: token-budget per-model.
445 budget := ReasoningBudget(level)
446 switch {
447 case strings.Contains(id, "2.5-pro"):
448 if budget > 32768 {
449 budget = 32768
450 }
451 case strings.Contains(id, "2.5-flash-lite"):
452 if budget > 24576 {
453 budget = 24576
454 }
455 case strings.Contains(id, "2.5-flash"):
456 if budget > 24576 {
457 budget = 24576
458 }
459 default:
460 return nil
461 }
462 if budget <= 0 {
463 return nil
464 }
465 return &gemThinkingConfig{IncludeThoughts: true, ThinkingBudget: &budget}
466}
467
468// ---- streaming ----
469
470func (c *geminiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
471 if err := ValidateFastMode(c.Name(), req.FastMode); err != nil {
472 return nil, err
473 }
474 wire, modelID, err := c.buildRequest(req)
475 if err != nil {
476 return nil, err
477 }
478 body, err := json.Marshal(wire)
479 if err != nil {
480 return nil, err
481 }
482
483 // Gemini's streaming endpoint is a POST with ?alt=sse to get
484 // an EventSource-compatible response. Without alt=sse the
485 // server returns a JSON array (one element per chunk), which
486 // we'd need a different parser for.
487 apiPath := fmt.Sprintf("/v1beta/models/%s:streamGenerateContent", modelID)
488 url := c.baseURL + apiPath + "?alt=sse"
489
490 newReq := func() (*http.Request, error) {
491 httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
492 if err != nil {
493 return nil, err
494 }
495 httpReq.Header.Set("content-type", "application/json")
496 httpReq.Header.Set("accept", "text/event-stream")
497 // Two ways to authenticate against the Generative Language
498 // API: x-goog-api-key header or ?key= query param. We use
499 // the header so the key never lands in proxy access logs.
500 httpReq.Header.Set("x-goog-api-key", c.apiKey)
501 return httpReq, nil
502 }
503
504 resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle)
505 if err != nil {
506 return nil, fmt.Errorf("google: %w", err)
507 }
508 if resp.StatusCode != http.StatusOK {
509 b, _ := io.ReadAll(resp.Body)
510 resp.Body.Close()
511 return nil, fmt.Errorf("google: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
512 }
513
514 out := make(chan Event, 16)
515 go c.runStream(ctx, resp, req, out)
516 return out, nil
517}
518
519func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
520 defer close(out)
521 defer resp.Body.Close()
522
523 model, _ := FindModel("google", req.Model)
524 out <- EventStart{Model: req.Model, Provider: "google"}
525
526 raw := make(chan sseEvent, 16)
527 go readSSE(resp.Body, raw)
528
529 // Gemini's SSE stream is a sequence of complete JSON
530 // GenerateContentResponse objects, one per data: line. Each
531 // candidate carries a list of parts (text or functionCall),
532 // possibly accumulating across chunks.
533 type blockEntry struct {
534 kind string // "text" | "image" | "tool_use" | "reasoning"
535 textBuf strings.Builder
536 thoughtSignature string
537 image *ImageBlock
538 imagePath string
539 toolID string
540 toolName string
541 toolArgs strings.Builder
542 }
543 var (
544 blocks []*blockEntry
545 currentText *blockEntry
546 currentReasoning *blockEntry
547 usage Usage
548 stop = StopEnd
549 finalErr error
550 toolCounter int
551 )
552
553 appendPartText := func(kind, delta, signature string) {
554 current := ¤tText
555 if kind == "reasoning" {
556 current = ¤tReasoning
557 }
558 if *current == nil {
559 *current = &blockEntry{kind: kind}
560 blocks = append(blocks, *current)
561 }
562 (*current).textBuf.WriteString(delta)
563 if signature != "" {
564 (*current).thoughtSignature = signature
565 // A signature terminates this wire part. A later delta of the
566 // same kind belongs to a new part and must not be merged into it.
567 *current = nil
568 }
569 }
570
571 appendImage := func(mimeType, dataB64, signature string) {
572 data, err := base64.StdEncoding.DecodeString(dataB64)
573 if err != nil || len(data) == 0 {
574 return
575 }
576 img := ImageBlock{MimeType: mimeType, Data: data, ThoughtSignature: signature}
577 path, _ := saveGeminiImageToWorkingDir(mimeType, data)
578 blocks = append(blocks, &blockEntry{kind: "image", image: &img, imagePath: path})
579 // Non-text parts break both streamed text runs.
580 currentText = nil
581 currentReasoning = nil
582 }
583
584 startTool := func(name string, providedID string, args json.RawMessage) *blockEntry {
585 toolCounter++
586 id := providedID
587 if id == "" {
588 id = fmt.Sprintf("%s_%d_%d", name, time.Now().UnixNano(), toolCounter)
589 }
590 t := &blockEntry{
591 kind: "tool_use",
592 toolID: id,
593 toolName: name,
594 }
595 if len(args) > 0 && json.Valid(args) {
596 t.toolArgs.Write(args)
597 }
598 blocks = append(blocks, t)
599 // New tool blocks break both streamed text runs.
600 currentText = nil
601 currentReasoning = nil
602 return t
603 }
604
605 assembleMsg := func() Message {
606 content := []Content{}
607 for _, b := range blocks {
608 switch b.kind {
609 case "text":
610 if b.textBuf.Len() > 0 {
611 content = append(content, TextBlock{
612 Text: b.textBuf.String(),
613 ThoughtSignature: b.thoughtSignature,
614 })
615 }
616 case "reasoning":
617 if b.textBuf.Len() > 0 || b.thoughtSignature != "" {
618 content = append(content, ReasoningBlock{
619 Summary: b.textBuf.String(),
620 Encrypted: b.thoughtSignature,
621 })
622 }
623 case "image":
624 if b.image != nil && len(b.image.Data) > 0 {
625 content = append(content, *b.image)
626 if b.imagePath != "" {
627 content = append(content, TextBlock{Text: fmt.Sprintf("Saved image: `%s`", b.imagePath)})
628 }
629 }
630 case "tool_use":
631 args := b.toolArgs.String()
632 if args == "" || !json.Valid([]byte(args)) {
633 args = "{}"
634 }
635 content = append(content, ToolCallBlock{
636 ID: b.toolID,
637 Name: b.toolName,
638 Arguments: json.RawMessage(args),
639 ThoughtSignature: b.thoughtSignature,
640 })
641 }
642 }
643 return Message{Role: RoleAssistant, Content: content, Time: time.Now()}
644 }
645
646 sendDone := func() {
647 usage.CostUSD = ComputeCost(model, usage)
648 out <- EventUsage{Usage: usage}
649 out <- EventDone{Stop: stop, Err: finalErr, Message: assembleMsg()}
650 }
651
652 for {
653 select {
654 case <-ctx.Done():
655 stop = StopAborted
656 finalErr = ctx.Err()
657 sendDone()
658 return
659 case ev, ok := <-raw:
660 if !ok {
661 sendDone()
662 return
663 }
664 if ev.Err != nil {
665 stop = StopError
666 finalErr = fmt.Errorf("read SSE: %w", ev.Err)
667 sendDone()
668 return
669 }
670 if strings.TrimSpace(ev.Data) == "" {
671 continue
672 }
673 var chunk struct {
674 Candidates []struct {
675 Content struct {
676 Role string `json:"role"`
677 Parts []struct {
678 Text string `json:"text"`
679 InlineData *gemInlineData `json:"inlineData"`
680 Thought bool `json:"thought"`
681 ThoughtSignature string `json:"thoughtSignature"`
682 FunctionCall *gemFunctionCall `json:"functionCall"`
683 FunctionCallID string `json:"id"`
684 FunctionCallName string `json:"name"`
685 } `json:"parts"`
686 } `json:"content"`
687 FinishReason string `json:"finishReason"`
688 } `json:"candidates"`
689 UsageMetadata *struct {
690 PromptTokenCount int `json:"promptTokenCount"`
691 CandidatesTokenCount int `json:"candidatesTokenCount"`
692 ThoughtsTokenCount *int `json:"thoughtsTokenCount"`
693 CachedContentTokenCount int `json:"cachedContentTokenCount"`
694 TotalTokenCount int `json:"totalTokenCount"`
695 } `json:"usageMetadata"`
696 Error *struct {
697 Code int `json:"code"`
698 Message string `json:"message"`
699 Status string `json:"status"`
700 } `json:"error"`
701 }
702 if err := json.Unmarshal([]byte(ev.Data), &chunk); err != nil {
703 continue
704 }
705 if chunk.Error != nil {
706 stop = StopError
707 finalErr = fmt.Errorf("google: %s", chunk.Error.Message)
708 sendDone()
709 return
710 }
711 for _, cand := range chunk.Candidates {
712 for _, part := range cand.Content.Parts {
713 if part.InlineData != nil {
714 appendImage(part.InlineData.MimeType, part.InlineData.Data, part.ThoughtSignature)
715 continue
716 }
717 if part.FunctionCall != nil {
718 var args json.RawMessage
719 if len(part.FunctionCall.Args) > 0 {
720 args = part.FunctionCall.Args
721 } else {
722 args = json.RawMessage("{}")
723 }
724 t := startTool(part.FunctionCall.Name, part.FunctionCallID, args)
725 t.thoughtSignature = part.ThoughtSignature
726 out <- EventToolStart{ID: t.toolID, Name: t.toolName}
727 out <- EventToolArgs{ID: t.toolID, Delta: t.toolArgs.String()}
728 out <- EventToolEnd{ID: t.toolID}
729 continue
730 }
731 if part.Thought {
732 appendPartText("reasoning", part.Text, part.ThoughtSignature)
733 currentText = nil
734 continue
735 }
736 if part.Text == "" {
737 continue
738 }
739 appendPartText("text", part.Text, part.ThoughtSignature)
740 currentReasoning = nil
741 out <- EventTextDelta{Delta: part.Text}
742 }
743 switch cand.FinishReason {
744 case "STOP", "":
745 // "" arrives on intermediate chunks; only
746 // promote the explicit terminal value.
747 if cand.FinishReason == "STOP" {
748 stop = StopEnd
749 }
750 case "MAX_TOKENS":
751 stop = StopLength
752 case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "IMAGE_SAFETY":
753 stop = StopError
754 finalErr = fmt.Errorf("google: response blocked (%s)", cand.FinishReason)
755 }
756 }
757 if chunk.UsageMetadata != nil {
758 // Gemini reports cumulative totals on every chunk
759 // (or close to it), so assign rather than sum.
760 um := chunk.UsageMetadata
761 input := um.PromptTokenCount - um.CachedContentTokenCount
762 if input < 0 {
763 input = um.PromptTokenCount
764 }
765 usage.InputTokens = input
766 usage.OutputTokens = um.CandidatesTokenCount
767 if um.ThoughtsTokenCount != nil {
768 usage.ReasoningTokens = *um.ThoughtsTokenCount
769 usage.ReasoningTokensKnown = true
770 usage.OutputTokens += *um.ThoughtsTokenCount
771 }
772 usage.CacheReadTokens = um.CachedContentTokenCount
773 }
774 // Promote ToolUse stop when tool calls are present and
775 // the candidate finished cleanly.
776 if stop == StopEnd {
777 for _, b := range blocks {
778 if b.kind == "tool_use" {
779 stop = StopToolUse
780 break
781 }
782 }
783 }
784 }
785 }
786}