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