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