v0.3.11 2c9dbfa

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 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 := &currentText
551		if kind == "reasoning" {
552			current = &currentReasoning
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}

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 477

↑↓ moveenter openesc close