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