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