v0.38.0 58133ea

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	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 := &currentText
563		if kind == "reasoning" {
564			current = &currentReasoning
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				args := b.toolArgs.String()
640				if args == "" || !json.Valid([]byte(args)) {
641					args = "{}"
642				}
643				content = append(content, ToolCallBlock{
644					ID:               b.toolID,
645					Name:             b.toolName,
646					Arguments:        json.RawMessage(args),
647					ThoughtSignature: b.thoughtSignature,
648				})
649			}
650		}
651		return Message{Role: RoleAssistant, Content: content, Time: time.Now()}
652	}
653
654	sendDone := func() {
655		usage.CostUSD = ComputeCost(model, usage)
656		out <- EventUsage{Usage: usage}
657		out <- EventDone{Stop: stop, Err: finalErr, Message: assembleMsg()}
658	}
659
660	for {
661		select {
662		case <-ctx.Done():
663			stop = StopAborted
664			finalErr = ctx.Err()
665			sendDone()
666			return
667		case ev, ok := <-raw:
668			if !ok {
669				sendDone()
670				return
671			}
672			if ev.Err != nil {
673				stop = StopError
674				finalErr = fmt.Errorf("read SSE: %w", ev.Err)
675				sendDone()
676				return
677			}
678			if strings.TrimSpace(ev.Data) == "" {
679				continue
680			}
681			var chunk struct {
682				Candidates []struct {
683					Content struct {
684						Role  string `json:"role"`
685						Parts []struct {
686							Text             string           `json:"text"`
687							InlineData       *gemInlineData   `json:"inlineData"`
688							Thought          bool             `json:"thought"`
689							ThoughtSignature string           `json:"thoughtSignature"`
690							FunctionCall     *gemFunctionCall `json:"functionCall"`
691							FunctionCallID   string           `json:"id"`
692							FunctionCallName string           `json:"name"`
693						} `json:"parts"`
694					} `json:"content"`
695					FinishReason string `json:"finishReason"`
696				} `json:"candidates"`
697				UsageMetadata *struct {
698					PromptTokenCount        int  `json:"promptTokenCount"`
699					CandidatesTokenCount    int  `json:"candidatesTokenCount"`
700					ThoughtsTokenCount      *int `json:"thoughtsTokenCount"`
701					CachedContentTokenCount int  `json:"cachedContentTokenCount"`
702					TotalTokenCount         int  `json:"totalTokenCount"`
703				} `json:"usageMetadata"`
704				Error *struct {
705					Code    int    `json:"code"`
706					Message string `json:"message"`
707					Status  string `json:"status"`
708				} `json:"error"`
709			}
710			if err := json.Unmarshal([]byte(ev.Data), &chunk); err != nil {
711				continue
712			}
713			if chunk.Error != nil {
714				stop = StopError
715				finalErr = fmt.Errorf("google: %s", chunk.Error.Message)
716				sendDone()
717				return
718			}
719			for _, cand := range chunk.Candidates {
720				for _, part := range cand.Content.Parts {
721					if part.InlineData != nil {
722						appendImage(part.InlineData.MimeType, part.InlineData.Data, part.ThoughtSignature)
723						continue
724					}
725					if part.FunctionCall != nil {
726						var args json.RawMessage
727						if len(part.FunctionCall.Args) > 0 {
728							args = part.FunctionCall.Args
729						} else {
730							args = json.RawMessage("{}")
731						}
732						t := startTool(part.FunctionCall.Name, part.FunctionCallID, args)
733						t.thoughtSignature = part.ThoughtSignature
734						out <- EventToolStart{ID: t.toolID, Name: t.toolName}
735						out <- EventToolArgs{ID: t.toolID, Delta: t.toolArgs.String()}
736						out <- EventToolEnd{ID: t.toolID}
737						continue
738					}
739					if part.Thought {
740						appendPartText("reasoning", part.Text, part.ThoughtSignature)
741						currentText = nil
742						continue
743					}
744					if part.Text == "" {
745						continue
746					}
747					appendPartText("text", part.Text, part.ThoughtSignature)
748					currentReasoning = nil
749					out <- EventTextDelta{Delta: part.Text}
750				}
751				switch cand.FinishReason {
752				case "STOP", "":
753					// "" arrives on intermediate chunks; only
754					// promote the explicit terminal value.
755					if cand.FinishReason == "STOP" {
756						stop = StopEnd
757					}
758				case "MAX_TOKENS":
759					stop = StopLength
760				case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "IMAGE_SAFETY":
761					stop = StopError
762					finalErr = fmt.Errorf("google: response blocked (%s)", cand.FinishReason)
763				}
764			}
765			if chunk.UsageMetadata != nil {
766				// Gemini reports cumulative totals on every chunk
767				// (or close to it), so assign rather than sum.
768				um := chunk.UsageMetadata
769				input := um.PromptTokenCount - um.CachedContentTokenCount
770				if input < 0 {
771					input = um.PromptTokenCount
772				}
773				usage.InputTokens = input
774				usage.OutputTokens = um.CandidatesTokenCount
775				if um.ThoughtsTokenCount != nil {
776					usage.ReasoningTokens = *um.ThoughtsTokenCount
777					usage.ReasoningTokensKnown = true
778					usage.OutputTokens += *um.ThoughtsTokenCount
779				}
780				usage.CacheReadTokens = um.CachedContentTokenCount
781			}
782			// Promote ToolUse stop when tool calls are present and
783			// the candidate finished cleanly.
784			if stop == StopEnd {
785				for _, b := range blocks {
786					if b.kind == "tool_use" {
787						stop = StopToolUse
788						break
789					}
790				}
791			}
792		}
793	}
794}

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