v0.14.0 75d4151

gemini.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close