detached ef93175

add Google Gemini provider

- internal/provider/gemini.go: REST client against
  generativelanguage.googleapis.com/v1beta/models/{id}:streamGenerateContent
  ?alt=sse, mapping our message/tool format to Gemini's Content/Part schema
  and translating SSE chunks into the existing assistant-message event
  stream. Handles text, tool calls, thought-summary parts, and per-model
  thinking config (thinkingBudget for 2.5, thinkingLevel for 3.x with
  Gemini-3-Pro pinned to LOW minimum).
- internal/provider/discover.go: DiscoverGoogle pages /v1beta/models and
  filters to chat-capable ids (skips embeddings, AQA).
- internal/provider/models.go: catalog entries for gemini-2.5-pro,
  2.5-flash, 2.5-flash-lite, 2.0-flash, 2.0-flash-lite.
- internal/auth: 'google' is a recognized provider; API-key probe hits
  /v1beta/models with x-goog-api-key. OAuth flows reject google with a
  clear 'API-key only' error since Gemini Advanced subscriptions don't
  issue API tokens.
- internal/agent: env lookup for GEMINI_API_KEY / GOOGLE_API_KEY,
  default model gemini-2.5-pro, NewClient wires provider.NewGemini,
  background model discovery, /login + /logout + rescue dialog all
  include google.
- README: new ### Google Gemini section with auth model, free-tier
  limits, and reasoning-config notes.
commit
ef93175bf9ac47bcf7154a3349dc8cbdc0435759
author
patriceckhart · · 2026-05-07 19:15 UTC
parent
9035106
signature
unsigned
refs
tag: v0.1.67
16 files · +1135 -30
M README.md +39 -7
@@ -5,7 +5,7 @@
55 Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
66
77 - one static binary.
8-- built-in providers for anthropic, openai/codex, kimi, and ollama/openai-compatible local models.
8+- built-in providers for anthropic, openai/codex, kimi, google gemini, and ollama/openai-compatible local models.
99 - four tools (read, write, edit, bash).
1010 - three run modes (interactive tui, print, json).
1111 - built-in telegram bot.
@@ -61,7 +61,7 @@ The easiest way is to just run `zot` and type `/login`. The TUI opens even witho
6161 ### Credential lookup order
6262
6363 1. `--api-key` flag
64-2. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `KIMI_API_KEY`, or `MOONSHOT_API_KEY` env var
64+2. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `KIMI_API_KEY`, `MOONSHOT_API_KEY`, `GEMINI_API_KEY`, or `GOOGLE_API_KEY` env var
6565 3. `$ZOT_HOME/auth.json` (API key or OAuth token; mode 0600)
6666
6767 `$ZOT_HOME` defaults to:
@@ -73,8 +73,8 @@ The easiest way is to just run `zot` and type `/login`. The TUI opens even witho
7373
7474 Run `zot` and type `/login`. Pick one of two methods:
7575
76-- **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you paste your `sk-ant-...`, `sk-...`, or Kimi/Moonshot key. zot probes the provider once and saves it to `auth.json` if accepted.
77-- **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, or Kimi Code subscription.
76+- **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you paste your `sk-ant-...`, `sk-...`, Kimi/Moonshot key, or Google AI Studio (`AIza...`) Gemini key. zot probes the provider once and saves it to `auth.json` if accepted.
77+- **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, or Kimi Code subscription. Google Gemini does **not** have a subscription login path: a paid Gemini Advanced / Google One AI Premium plan only unlocks the consumer Gemini app and does not raise quotas on the AI Studio API. Use the API-key flow for Google.
7878 - Anthropic and OpenAI pin the browser callback to fixed provider-specific ports (`localhost:53692` for Anthropic, `localhost:1455` for OpenAI) because those are the only ports their auth servers will redirect to.
7979 - Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers.
8080 - OpenAI uses the Codex CLI OAuth flow. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token.
@@ -127,7 +127,7 @@ zot --help
127127
128128 | Flag | Description |
129129 |---|---|
130-| `--provider anthropic\|openai\|kimi\|ollama` | Pick the provider. |
130+| `--provider anthropic\|openai\|kimi\|google\|ollama` | Pick the provider. |
131131 | `--model <id>` | Pick the model (see `--list-models`). |
132132 | `--api-key <key>` | Override the API key. |
133133 | `--base-url <url>` | Override the provider base URL (tests, self-hosted). |
@@ -181,7 +181,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands:
181181 |---|---|
182182 | `/help` | Show key bindings and commands. |
183183 | `/login` | Log in via API key or subscription (opens a dialog). |
184-| `/logout [provider]` | Clear credentials for `anthropic`, `openai`, `kimi`, or all when omitted. `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zot again. |
184+| `/logout [provider]` | Clear credentials for `anthropic`, `openai`, `kimi`, `google`, or all when omitted. `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zot again. |
185185 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |
186186 | `/sessions` | Resume a previous session for this directory. |
187187 | `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. |
@@ -268,7 +268,7 @@ When a turn fails because of a recoverable provider error — expired token (`40
268268
269269 The picker is the same vertical list / fuzzy filter UI as `/model`, but it only shows models from providers you're currently logged in to (env vars, `auth.json`, Kimi CLI fallback, ollama). The failed model is excluded. Press `↑`/`↓` to choose, `enter` to retry the **same prompt** on the new model, `esc` to dismiss.
270270
271-Before the actual provider request fires, the OpenAI / Anthropic / Kimi / OpenAI-Codex clients also do up to two silent retries with short backoff (250ms, 750ms) on `502`/`503`/`504` and connection-reset / EOF-before-headers errors. Most edge-proxy blips disappear without you ever seeing the rescue picker.
271+Before the actual provider request fires, the OpenAI / Anthropic / Kimi / Google / OpenAI-Codex clients also do up to two silent retries with short backoff (250ms, 750ms) on `502`/`503`/`504` and connection-reset / EOF-before-headers errors. Most edge-proxy blips disappear without you ever seeing the rescue picker.
272272
273273 A rescue retry always **drops launch-time `--api-key` and `--base-url`** before rebuilding the agent. Those overrides are usually the reason the rescue triggered (bad key, typo'd base URL, corporate gateway only valid for the originally-picked provider), so the retry re-resolves credentials from env vars / `auth.json` / provider defaults instead. Use `/model` if you want overrides to stick.
274274
@@ -333,6 +333,38 @@ zot --provider kimi --model kimi-k2-0905-preview --base-url https://api.moonshot
333333
334334 You can add additional Kimi/Moonshot model IDs to `models.json` under the `kimi` provider.
335335
336+### Google Gemini
337+
338+zot has built-in Google Gemini support through the [AI Studio Generative Language API](https://aistudio.google.com/).
339+
340+```bash
341+zot --provider google
342+```
343+
344+By default this uses:
345+
346+- model: `gemini-2.5-pro`
347+- base URL: `https://generativelanguage.googleapis.com`
348+
349+Catalog ships with `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite`, `gemini-2.0-flash`, and `gemini-2.0-flash-lite`. Live discovery against `/v1beta/models` adds anything else your key can see.
350+
351+Credential lookup order for Google:
352+
353+1. `--api-key`
354+2. `GEMINI_API_KEY`
355+3. `GOOGLE_API_KEY`
356+4. `$ZOT_HOME/auth.json`
357+
358+Use `/login` and pick **api key** to paste an AI Studio key. zot probes `/v1beta/models` once and stores the key under `google` in `auth.json`.
359+
360+> **Auth model: API key only.** Google does not issue OAuth tokens for consumer Gemini Advanced / Google One AI Premium subscriptions, so there is no "log in with your Google subscription" flow. Programmatic access requires either an AI Studio API key (this provider) or a Vertex AI / GCP service-account credential (not yet wired up in zot). The `/login subscription` step quietly downgrades to the api-key form when you pick Google so you don't end up in a dead end.
361+
362+> **Free-tier rate limits.** AI Studio's free tier has tight per-minute and per-day caps that vary by model: `gemini-2.5-pro` is the strictest (a few requests per minute, ~50 per day), Flash and Flash-Lite are far more generous. If a Pro turn 429s with `"You exceeded your current quota"` while Flash on the same key still works, you've hit the Pro free-tier RPD. Either switch to Flash for agent loops, or [enable billing](https://aistudio.google.com/app/apikey) on your AI Studio project to flip the same key from free to pay-as-you-go pricing (`$1.25/M` input, `$10/M` output for Pro).
363+
364+Reasoning levels (`--reasoning low|medium|high`) map differently per generation: 2.5 family uses `thinkingBudget` token budgets per model (Pro caps at 32k, Flash at 24k); Gemini 3.x uses the `thinkingLevel` enum (`MINIMAL`/`LOW`/`MEDIUM`/`HIGH`), with Gemini-3-Pro pinned to `LOW` minimum and `HIGH` for any "medium" or "high" request. 2.0-family models have no thinking config at all.
365+
366+You can add additional Gemini model IDs to `models.json` under the `google` provider.
367+
336368 ### Local models with ollama
337369
338370 zot works with [ollama](https://ollama.com) out of the box. Ollama serves an OpenAI-compatible API locally, so any model you have pulled works with zot.
@@ -5,7 +5,7 @@
5 Yet another coding agent harness, lightweight and written (vibe-slopped) in go.5 Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
6 6
7 - one static binary.7 - one static binary.
8-- built-in providers for anthropic, openai/codex, kimi, and ollama/openai-compatible local models.8+- built-in providers for anthropic, openai/codex, kimi, google gemini, and ollama/openai-compatible local models.
9 - four tools (read, write, edit, bash).9 - four tools (read, write, edit, bash).
10 - three run modes (interactive tui, print, json).10 - three run modes (interactive tui, print, json).
11 - built-in telegram bot.11 - built-in telegram bot.
@@ -61,7 +61,7 @@ The easiest way is to just run `zot` and type `/login`. The TUI opens even witho
61 ### Credential lookup order61 ### Credential lookup order
62 62
63 1. `--api-key` flag63 1. `--api-key` flag
64-2. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `KIMI_API_KEY`, or `MOONSHOT_API_KEY` env var64+2. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `KIMI_API_KEY`, `MOONSHOT_API_KEY`, `GEMINI_API_KEY`, or `GOOGLE_API_KEY` env var
65 3. `$ZOT_HOME/auth.json` (API key or OAuth token; mode 0600)65 3. `$ZOT_HOME/auth.json` (API key or OAuth token; mode 0600)
66 66
67 `$ZOT_HOME` defaults to:67 `$ZOT_HOME` defaults to:
@@ -73,8 +73,8 @@ The easiest way is to just run `zot` and type `/login`. The TUI opens even witho
73 73
74 Run `zot` and type `/login`. Pick one of two methods:74 Run `zot` and type `/login`. Pick one of two methods:
75 75
76-- **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you paste your `sk-ant-...`, `sk-...`, or Kimi/Moonshot key. zot probes the provider once and saves it to `auth.json` if accepted.76+- **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you paste your `sk-ant-...`, `sk-...`, Kimi/Moonshot key, or Google AI Studio (`AIza...`) Gemini key. zot probes the provider once and saves it to `auth.json` if accepted.
77-- **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, or Kimi Code subscription.77+- **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, or Kimi Code subscription. Google Gemini does **not** have a subscription login path: a paid Gemini Advanced / Google One AI Premium plan only unlocks the consumer Gemini app and does not raise quotas on the AI Studio API. Use the API-key flow for Google.
78 - Anthropic and OpenAI pin the browser callback to fixed provider-specific ports (`localhost:53692` for Anthropic, `localhost:1455` for OpenAI) because those are the only ports their auth servers will redirect to.78 - Anthropic and OpenAI pin the browser callback to fixed provider-specific ports (`localhost:53692` for Anthropic, `localhost:1455` for OpenAI) because those are the only ports their auth servers will redirect to.
79 - Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers.79 - Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers.
80 - OpenAI uses the Codex CLI OAuth flow. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token.80 - OpenAI uses the Codex CLI OAuth flow. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token.
@@ -127,7 +127,7 @@ zot --help
127 127
128 | Flag | Description |128 | Flag | Description |
129 |---|---|129 |---|---|
130-| `--provider anthropic\|openai\|kimi\|ollama` | Pick the provider. |130+| `--provider anthropic\|openai\|kimi\|google\|ollama` | Pick the provider. |
131 | `--model <id>` | Pick the model (see `--list-models`). |131 | `--model <id>` | Pick the model (see `--list-models`). |
132 | `--api-key <key>` | Override the API key. |132 | `--api-key <key>` | Override the API key. |
133 | `--base-url <url>` | Override the provider base URL (tests, self-hosted). |133 | `--base-url <url>` | Override the provider base URL (tests, self-hosted). |
@@ -181,7 +181,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands:
181 |---|---|181 |---|---|
182 | `/help` | Show key bindings and commands. |182 | `/help` | Show key bindings and commands. |
183 | `/login` | Log in via API key or subscription (opens a dialog). |183 | `/login` | Log in via API key or subscription (opens a dialog). |
184-| `/logout [provider]` | Clear credentials for `anthropic`, `openai`, `kimi`, or all when omitted. `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zot again. |184+| `/logout [provider]` | Clear credentials for `anthropic`, `openai`, `kimi`, `google`, or all when omitted. `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zot again. |
185 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |185 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |
186 | `/sessions` | Resume a previous session for this directory. |186 | `/sessions` | Resume a previous session for this directory. |
187 | `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. |187 | `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. |
@@ -268,7 +268,7 @@ When a turn fails because of a recoverable provider error — expired token (`40
268 268
269 The picker is the same vertical list / fuzzy filter UI as `/model`, but it only shows models from providers you're currently logged in to (env vars, `auth.json`, Kimi CLI fallback, ollama). The failed model is excluded. Press `↑`/`↓` to choose, `enter` to retry the **same prompt** on the new model, `esc` to dismiss.269 The picker is the same vertical list / fuzzy filter UI as `/model`, but it only shows models from providers you're currently logged in to (env vars, `auth.json`, Kimi CLI fallback, ollama). The failed model is excluded. Press `↑`/`↓` to choose, `enter` to retry the **same prompt** on the new model, `esc` to dismiss.
270 270
271-Before the actual provider request fires, the OpenAI / Anthropic / Kimi / OpenAI-Codex clients also do up to two silent retries with short backoff (250ms, 750ms) on `502`/`503`/`504` and connection-reset / EOF-before-headers errors. Most edge-proxy blips disappear without you ever seeing the rescue picker.271+Before the actual provider request fires, the OpenAI / Anthropic / Kimi / Google / OpenAI-Codex clients also do up to two silent retries with short backoff (250ms, 750ms) on `502`/`503`/`504` and connection-reset / EOF-before-headers errors. Most edge-proxy blips disappear without you ever seeing the rescue picker.
272 272
273 A rescue retry always **drops launch-time `--api-key` and `--base-url`** before rebuilding the agent. Those overrides are usually the reason the rescue triggered (bad key, typo'd base URL, corporate gateway only valid for the originally-picked provider), so the retry re-resolves credentials from env vars / `auth.json` / provider defaults instead. Use `/model` if you want overrides to stick.273 A rescue retry always **drops launch-time `--api-key` and `--base-url`** before rebuilding the agent. Those overrides are usually the reason the rescue triggered (bad key, typo'd base URL, corporate gateway only valid for the originally-picked provider), so the retry re-resolves credentials from env vars / `auth.json` / provider defaults instead. Use `/model` if you want overrides to stick.
274 274
@@ -333,6 +333,38 @@ zot --provider kimi --model kimi-k2-0905-preview --base-url https://api.moonshot
333 333
334 You can add additional Kimi/Moonshot model IDs to `models.json` under the `kimi` provider.334 You can add additional Kimi/Moonshot model IDs to `models.json` under the `kimi` provider.
335 335
336+### Google Gemini
337+
338+zot has built-in Google Gemini support through the [AI Studio Generative Language API](https://aistudio.google.com/).
339+
340+```bash
341+zot --provider google
342+```
343+
344+By default this uses:
345+
346+- model: `gemini-2.5-pro`
347+- base URL: `https://generativelanguage.googleapis.com`
348+
349+Catalog ships with `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite`, `gemini-2.0-flash`, and `gemini-2.0-flash-lite`. Live discovery against `/v1beta/models` adds anything else your key can see.
350+
351+Credential lookup order for Google:
352+
353+1. `--api-key`
354+2. `GEMINI_API_KEY`
355+3. `GOOGLE_API_KEY`
356+4. `$ZOT_HOME/auth.json`
357+
358+Use `/login` and pick **api key** to paste an AI Studio key. zot probes `/v1beta/models` once and stores the key under `google` in `auth.json`.
359+
360+> **Auth model: API key only.** Google does not issue OAuth tokens for consumer Gemini Advanced / Google One AI Premium subscriptions, so there is no "log in with your Google subscription" flow. Programmatic access requires either an AI Studio API key (this provider) or a Vertex AI / GCP service-account credential (not yet wired up in zot). The `/login subscription` step quietly downgrades to the api-key form when you pick Google so you don't end up in a dead end.
361+
362+> **Free-tier rate limits.** AI Studio's free tier has tight per-minute and per-day caps that vary by model: `gemini-2.5-pro` is the strictest (a few requests per minute, ~50 per day), Flash and Flash-Lite are far more generous. If a Pro turn 429s with `"You exceeded your current quota"` while Flash on the same key still works, you've hit the Pro free-tier RPD. Either switch to Flash for agent loops, or [enable billing](https://aistudio.google.com/app/apikey) on your AI Studio project to flip the same key from free to pay-as-you-go pricing (`$1.25/M` input, `$10/M` output for Pro).
363+
364+Reasoning levels (`--reasoning low|medium|high`) map differently per generation: 2.5 family uses `thinkingBudget` token budgets per model (Pro caps at 32k, Flash at 24k); Gemini 3.x uses the `thinkingLevel` enum (`MINIMAL`/`LOW`/`MEDIUM`/`HIGH`), with Gemini-3-Pro pinned to `LOW` minimum and `HIGH` for any "medium" or "high" request. 2.0-family models have no thinking config at all.
365+
366+You can add additional Gemini model IDs to `models.json` under the `google` provider.
367+
336 ### Local models with ollama368 ### Local models with ollama
337 369
338 zot works with [ollama](https://ollama.com) out of the box. Ollama serves an OpenAI-compatible API locally, so any model you have pulled works with zot.370 zot works with [ollama](https://ollama.com) out of the box. Ollama serves an OpenAI-compatible API locally, so any model you have pulled works with zot.
M internal/agent/build.go +11 -2
@@ -129,7 +129,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
129129
130130 // User-requested provider (explicit > config > default).
131131 provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
132- if provName != "anthropic" && provName != "openai" && provName != "kimi" && provName != "ollama" {
132+ if provName != "anthropic" && provName != "openai" && provName != "kimi" && provName != "google" && provName != "ollama" {
133133 // Unknown provider (maybe removed or renamed). Fall back to
134134 // the first provider that has credentials, or anthropic.
135135 provName = "anthropic"
@@ -170,7 +170,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
170170 // never shows a "not logged in" banner.
171171 userPickedProvider := args.Provider != ""
172172 if credErr != nil && !userPickedProvider && provName != "ollama" {
173- for _, other := range []string{"anthropic", "openai", "kimi"} {
173+ for _, other := range []string{"anthropic", "openai", "kimi", "google"} {
174174 if other == provName {
175175 continue
176176 }
@@ -189,6 +189,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
189189 model = "gpt-5"
190190 case "kimi":
191191 model = "kimi-for-coding"
192+ case "google":
193+ model = "gemini-2.5-pro"
192194 case "ollama":
193195 return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
194196 default:
@@ -204,6 +206,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
204206 model = "gpt-5"
205207 case "kimi":
206208 model = "kimi-for-coding"
209+ case "google":
210+ model = "gemini-2.5-pro"
207211 default:
208212 model = provider.DefaultModel.ID
209213 }
@@ -368,6 +372,9 @@ func (r Resolved) NewClient() provider.Client {
368372 return r.wrapWithRefresh(inner)
369373 }
370374 return provider.NewOpenAI(r.Credential, r.BaseURL)
375+ case "google":
376+ // API-key only path. Gemini Generative Language API.
377+ return provider.NewGemini(r.Credential, r.BaseURL)
371378 default:
372379 if r.AuthMethod == "oauth" {
373380 inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
@@ -514,6 +521,8 @@ func envVarName(provider string) string {
514521 return "OPENAI"
515522 case "kimi":
516523 return "KIMI"
524+ case "google":
525+ return "GEMINI"
517526 case "ollama":
518527 return "OLLAMA"
519528 default:
@@ -129,7 +129,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
129 129
130 // User-requested provider (explicit > config > default).130 // User-requested provider (explicit > config > default).
131 provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")131 provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
132- if provName != "anthropic" && provName != "openai" && provName != "kimi" && provName != "ollama" {132+ if provName != "anthropic" && provName != "openai" && provName != "kimi" && provName != "google" && provName != "ollama" {
133 // Unknown provider (maybe removed or renamed). Fall back to133 // Unknown provider (maybe removed or renamed). Fall back to
134 // the first provider that has credentials, or anthropic.134 // the first provider that has credentials, or anthropic.
135 provName = "anthropic"135 provName = "anthropic"
@@ -170,7 +170,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
170 // never shows a "not logged in" banner.170 // never shows a "not logged in" banner.
171 userPickedProvider := args.Provider != ""171 userPickedProvider := args.Provider != ""
172 if credErr != nil && !userPickedProvider && provName != "ollama" {172 if credErr != nil && !userPickedProvider && provName != "ollama" {
173- for _, other := range []string{"anthropic", "openai", "kimi"} {173+ for _, other := range []string{"anthropic", "openai", "kimi", "google"} {
174 if other == provName {174 if other == provName {
175 continue175 continue
176 }176 }
@@ -189,6 +189,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
189 model = "gpt-5"189 model = "gpt-5"
190 case "kimi":190 case "kimi":
191 model = "kimi-for-coding"191 model = "kimi-for-coding"
192+ case "google":
193+ model = "gemini-2.5-pro"
192 case "ollama":194 case "ollama":
193 return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")195 return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
194 default:196 default:
@@ -204,6 +206,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
204 model = "gpt-5"206 model = "gpt-5"
205 case "kimi":207 case "kimi":
206 model = "kimi-for-coding"208 model = "kimi-for-coding"
209+ case "google":
210+ model = "gemini-2.5-pro"
207 default:211 default:
208 model = provider.DefaultModel.ID212 model = provider.DefaultModel.ID
209 }213 }
@@ -368,6 +372,9 @@ func (r Resolved) NewClient() provider.Client {
368 return r.wrapWithRefresh(inner)372 return r.wrapWithRefresh(inner)
369 }373 }
370 return provider.NewOpenAI(r.Credential, r.BaseURL)374 return provider.NewOpenAI(r.Credential, r.BaseURL)
375+ case "google":
376+ // API-key only path. Gemini Generative Language API.
377+ return provider.NewGemini(r.Credential, r.BaseURL)
371 default:378 default:
372 if r.AuthMethod == "oauth" {379 if r.AuthMethod == "oauth" {
373 inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)380 inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
@@ -514,6 +521,8 @@ func envVarName(provider string) string {
514 return "OPENAI"521 return "OPENAI"
515 case "kimi":522 case "kimi":
516 return "KIMI"523 return "KIMI"
524+ case "google":
525+ return "GEMINI"
517 case "ollama":526 case "ollama":
518 return "OLLAMA"527 return "OLLAMA"
519 default:528 default:
M internal/agent/cli.go +1 -1
@@ -721,7 +721,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
721721 BuildAgentForRescue: buildAgentForRescue,
722722 LoggedInProviders: func() []string {
723723 var out []string
724- for _, p := range []string{"anthropic", "openai", "kimi"} {
724+ for _, p := range []string{"anthropic", "openai", "kimi", "google"} {
725725 if _, _, err := ResolveCredential(p, ""); err == nil {
726726 out = append(out, p)
727727 }
@@ -721,7 +721,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
721 BuildAgentForRescue: buildAgentForRescue,721 BuildAgentForRescue: buildAgentForRescue,
722 LoggedInProviders: func() []string {722 LoggedInProviders: func() []string {
723 var out []string723 var out []string
724- for _, p := range []string{"anthropic", "openai", "kimi"} {724+ for _, p := range []string{"anthropic", "openai", "kimi", "google"} {
725 if _, _, err := ResolveCredential(p, ""); err == nil {725 if _, _, err := ResolveCredential(p, ""); err == nil {
726 out = append(out, p)726 out = append(out, p)
727 }727 }
M internal/agent/config.go +17 -0
@@ -148,6 +148,16 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
148148 if v := os.Getenv("MOONSHOT_API_KEY"); v != "" {
149149 return v, "apikey", "", nil
150150 }
151+ case "google":
152+ // Both env names are widely-used in the Google ecosystem;
153+ // GEMINI_API_KEY is the AI Studio default, GOOGLE_API_KEY
154+ // is the older / generic name. Either works.
155+ if v := os.Getenv("GEMINI_API_KEY"); v != "" {
156+ return v, "apikey", "", nil
157+ }
158+ if v := os.Getenv("GOOGLE_API_KEY"); v != "" {
159+ return v, "apikey", "", nil
160+ }
151161 }
152162 c, err := AuthStoreFor().Load()
153163 if err != nil {
@@ -185,6 +195,13 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
185195 tok, _ = refreshIfExpired("kimi", tok)
186196 return tok.AccessToken, "oauth", "", nil
187197 }
198+ case "google":
199+ // Google is API-key only — no OAuth path. We still load
200+ // auth.json so /login api-key flows work without exporting
201+ // an env var.
202+ if c.Google.APIKey != "" {
203+ return c.Google.APIKey, "apikey", "", nil
204+ }
188205 }
189206 return "", "", "", fmt.Errorf("no credential for %s", provider)
190207 }
@@ -148,6 +148,16 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
148 if v := os.Getenv("MOONSHOT_API_KEY"); v != "" {148 if v := os.Getenv("MOONSHOT_API_KEY"); v != "" {
149 return v, "apikey", "", nil149 return v, "apikey", "", nil
150 }150 }
151+ case "google":
152+ // Both env names are widely-used in the Google ecosystem;
153+ // GEMINI_API_KEY is the AI Studio default, GOOGLE_API_KEY
154+ // is the older / generic name. Either works.
155+ if v := os.Getenv("GEMINI_API_KEY"); v != "" {
156+ return v, "apikey", "", nil
157+ }
158+ if v := os.Getenv("GOOGLE_API_KEY"); v != "" {
159+ return v, "apikey", "", nil
160+ }
151 }161 }
152 c, err := AuthStoreFor().Load()162 c, err := AuthStoreFor().Load()
153 if err != nil {163 if err != nil {
@@ -185,6 +195,13 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
185 tok, _ = refreshIfExpired("kimi", tok)195 tok, _ = refreshIfExpired("kimi", tok)
186 return tok.AccessToken, "oauth", "", nil196 return tok.AccessToken, "oauth", "", nil
187 }197 }
198+ case "google":
199+ // Google is API-key only — no OAuth path. We still load
200+ // auth.json so /login api-key flows work without exporting
201+ // an env var.
202+ if c.Google.APIKey != "" {
203+ return c.Google.APIKey, "apikey", "", nil
204+ }
188 }205 }
189 return "", "", "", fmt.Errorf("no credential for %s", provider)206 return "", "", "", fmt.Errorf("no credential for %s", provider)
190 }207 }
M internal/agent/modelsync.go +5 -0
@@ -81,6 +81,11 @@ func refreshModels() {
8181 all = append(all, live...)
8282 }
8383 }
84+ if cred, method, err := ResolveCredential("google", ""); err == nil && method == "apikey" {
85+ if live, err := provider.DiscoverGoogle(ctx, cred, ""); err == nil {
86+ all = append(all, live...)
87+ }
88+ }
8489
8590 if len(all) == 0 {
8691 return
@@ -81,6 +81,11 @@ func refreshModels() {
81 all = append(all, live...)81 all = append(all, live...)
82 }82 }
83 }83 }
84+ if cred, method, err := ResolveCredential("google", ""); err == nil && method == "apikey" {
85+ if live, err := provider.DiscoverGoogle(ctx, cred, ""); err == nil {
86+ all = append(all, live...)
87+ }
88+ }
84 89
85 if len(all) == 0 {90 if len(all) == 0 {
86 return91 return
M internal/agent/modes/interactive.go +4 -4
@@ -2460,7 +2460,7 @@ func (i *Interactive) openLogoutDialog() {
24602460 }
24612461
24622462 var items []logoutItem
2463- for _, p := range []string{"anthropic", "openai", "kimi"} {
2463+ for _, p := range []string{"anthropic", "openai", "kimi", "google"} {
24642464 if creds.Has(p) {
24652465 method := creds.Method(p)
24662466 if method == "oauth" {
@@ -2513,12 +2513,12 @@ func (i *Interactive) doLogout(target string) {
25132513 var providers []string
25142514 switch target {
25152515 case "", "all":
2516- providers = []string{"anthropic", "openai", "kimi"}
2517- case "anthropic", "openai", "kimi":
2516+ providers = []string{"anthropic", "openai", "kimi", "google"}
2517+ case "anthropic", "openai", "kimi", "google":
25182518 providers = []string{target}
25192519 default:
25202520 i.mu.Lock()
2521- i.statusErr = "unknown provider: " + target + " (use anthropic, openai, kimi, or all)"
2521+ i.statusErr = "unknown provider: " + target + " (use anthropic, openai, kimi, google, or all)"
25222522 i.mu.Unlock()
25232523 return
25242524 }
@@ -2460,7 +2460,7 @@ func (i *Interactive) openLogoutDialog() {
2460 }2460 }
2461 2461
2462 var items []logoutItem2462 var items []logoutItem
2463- for _, p := range []string{"anthropic", "openai", "kimi"} {2463+ for _, p := range []string{"anthropic", "openai", "kimi", "google"} {
2464 if creds.Has(p) {2464 if creds.Has(p) {
2465 method := creds.Method(p)2465 method := creds.Method(p)
2466 if method == "oauth" {2466 if method == "oauth" {
@@ -2513,12 +2513,12 @@ func (i *Interactive) doLogout(target string) {
2513 var providers []string2513 var providers []string
2514 switch target {2514 switch target {
2515 case "", "all":2515 case "", "all":
2516- providers = []string{"anthropic", "openai", "kimi"}2516+ providers = []string{"anthropic", "openai", "kimi", "google"}
2517- case "anthropic", "openai", "kimi":2517+ case "anthropic", "openai", "kimi", "google":
2518 providers = []string{target}2518 providers = []string{target}
2519 default:2519 default:
2520 i.mu.Lock()2520 i.mu.Lock()
2521- i.statusErr = "unknown provider: " + target + " (use anthropic, openai, kimi, or all)"2521+ i.statusErr = "unknown provider: " + target + " (use anthropic, openai, kimi, google, or all)"
2522 i.mu.Unlock()2522 i.mu.Unlock()
2523 return2523 return
2524 }2524 }
M internal/agent/modes/login_dialog.go +19 -7
@@ -28,7 +28,7 @@ const (
2828 type loginDialog struct {
2929 step loginStep
3030 method string // "apikey" | "oauth"
31- provider string // "anthropic" | "openai" | "kimi"
31+ provider string // "anthropic" | "openai" | "kimi" | "google"
3232 message string
3333 success bool
3434 url string
@@ -39,7 +39,7 @@ type loginDialog struct {
3939 // provider, captured when Open() runs. Rendered above the
4040 // method picker so the user can see whether they're already
4141 // logged in (and how) before starting a new flow. Keys:
42- // "anthropic", "openai", "kimi". Value is "apikey", "oauth", or ""
42+ // "anthropic", "openai", "kimi", "google". Value is "apikey", "oauth", or ""
4343 // (not logged in).
4444 status map[string]string
4545 }
@@ -65,7 +65,7 @@ func (d *loginDialog) Open(zotHome string) {
6565 d.success = false
6666 d.url = ""
6767 d.cursor = 0
68- d.status = map[string]string{"anthropic": "", "openai": "", "kimi": ""}
68+ d.status = map[string]string{"anthropic": "", "openai": "", "kimi": "", "google": ""}
6969 // Best-effort: if the auth file can't be read, treat every
7070 // provider as not-logged-in. The status line just won't show
7171 // anything useful in that case, which is fine — the user
@@ -75,6 +75,7 @@ func (d *loginDialog) Open(zotHome string) {
7575 d.status["anthropic"] = creds.Method("anthropic")
7676 d.status["openai"] = creds.Method("openai")
7777 d.status["kimi"] = creds.Method("kimi")
78+ d.status["google"] = creds.Method("google")
7879 }
7980 }
8081
@@ -111,7 +112,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
111112 }
112113 lines = append(lines, frameRule(th, width))
113114 case loginStepProvider:
114- opts := []string{"anthropic", "openai", "kimi"}
115+ opts := []string{"anthropic", "openai", "kimi", "google"}
115116 lines = append(lines, frameHeader(th, "login - "+d.method, width))
116117 for _, l := range d.renderStatusLines(th) {
117118 lines = append(lines, l)
@@ -204,6 +205,8 @@ func providerLabel(id string) string {
204205 return "OpenAI (ChatGPT Plus/Pro)"
205206 case "kimi":
206207 return "Kimi Code"
208+ case "google":
209+ return "Google (Gemini API key)"
207210 }
208211 return id
209212 }
@@ -222,7 +225,8 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
222225 anth := d.status["anthropic"]
223226 op := d.status["openai"]
224227 kimi := d.status["kimi"]
225- if anth == "" && op == "" && kimi == "" {
228+ goog := d.status["google"]
229+ if anth == "" && op == "" && kimi == "" && goog == "" {
226230 return nil
227231 }
228232 row := func(id, method string) string {
@@ -245,6 +249,7 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
245249 row("anthropic", anth),
246250 row("openai", op),
247251 row("kimi", kimi),
252+ row("google", goog),
248253 "",
249254 }
250255 }
@@ -310,16 +315,23 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
310315 d.cursor--
311316 }
312317 case tui.KeyDown:
313- if d.cursor < 2 {
318+ if d.cursor < 3 {
314319 d.cursor++
315320 }
316321 case tui.KeyEsc:
317322 d.Close()
318323 return loginDialogAction{Close: true}
319324 case tui.KeyEnter:
320- providers := []string{"anthropic", "openai", "kimi"}
325+ providers := []string{"anthropic", "openai", "kimi", "google"}
321326 d.provider = providers[d.cursor]
322327 d.step = loginStepWaiting
328+ // Google has no subscription OAuth path (Gemini Advanced does
329+ // not issue API tokens). If the user chose subscription + google,
330+ // quietly downgrade to api-key so they get a usable form instead
331+ // of a hard error.
332+ if d.provider == "google" {
333+ d.method = "apikey"
334+ }
323335 if d.method == "apikey" {
324336 return loginDialogAction{StartAPIKey: true, Provider: d.provider}
325337 }
@@ -28,7 +28,7 @@ const (
28 type loginDialog struct {28 type loginDialog struct {
29 step loginStep29 step loginStep
30 method string // "apikey" | "oauth"30 method string // "apikey" | "oauth"
31- provider string // "anthropic" | "openai" | "kimi"31+ provider string // "anthropic" | "openai" | "kimi" | "google"
32 message string32 message string
33 success bool33 success bool
34 url string34 url string
@@ -39,7 +39,7 @@ type loginDialog struct {
39 // provider, captured when Open() runs. Rendered above the39 // provider, captured when Open() runs. Rendered above the
40 // method picker so the user can see whether they're already40 // method picker so the user can see whether they're already
41 // logged in (and how) before starting a new flow. Keys:41 // logged in (and how) before starting a new flow. Keys:
42- // "anthropic", "openai", "kimi". Value is "apikey", "oauth", or ""42+ // "anthropic", "openai", "kimi", "google". Value is "apikey", "oauth", or ""
43 // (not logged in).43 // (not logged in).
44 status map[string]string44 status map[string]string
45 }45 }
@@ -65,7 +65,7 @@ func (d *loginDialog) Open(zotHome string) {
65 d.success = false65 d.success = false
66 d.url = ""66 d.url = ""
67 d.cursor = 067 d.cursor = 0
68- d.status = map[string]string{"anthropic": "", "openai": "", "kimi": ""}68+ d.status = map[string]string{"anthropic": "", "openai": "", "kimi": "", "google": ""}
69 // Best-effort: if the auth file can't be read, treat every69 // Best-effort: if the auth file can't be read, treat every
70 // provider as not-logged-in. The status line just won't show70 // provider as not-logged-in. The status line just won't show
71 // anything useful in that case, which is fine — the user71 // anything useful in that case, which is fine — the user
@@ -75,6 +75,7 @@ func (d *loginDialog) Open(zotHome string) {
75 d.status["anthropic"] = creds.Method("anthropic")75 d.status["anthropic"] = creds.Method("anthropic")
76 d.status["openai"] = creds.Method("openai")76 d.status["openai"] = creds.Method("openai")
77 d.status["kimi"] = creds.Method("kimi")77 d.status["kimi"] = creds.Method("kimi")
78+ d.status["google"] = creds.Method("google")
78 }79 }
79 }80 }
80 81
@@ -111,7 +112,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
111 }112 }
112 lines = append(lines, frameRule(th, width))113 lines = append(lines, frameRule(th, width))
113 case loginStepProvider:114 case loginStepProvider:
114- opts := []string{"anthropic", "openai", "kimi"}115+ opts := []string{"anthropic", "openai", "kimi", "google"}
115 lines = append(lines, frameHeader(th, "login - "+d.method, width))116 lines = append(lines, frameHeader(th, "login - "+d.method, width))
116 for _, l := range d.renderStatusLines(th) {117 for _, l := range d.renderStatusLines(th) {
117 lines = append(lines, l)118 lines = append(lines, l)
@@ -204,6 +205,8 @@ func providerLabel(id string) string {
204 return "OpenAI (ChatGPT Plus/Pro)"205 return "OpenAI (ChatGPT Plus/Pro)"
205 case "kimi":206 case "kimi":
206 return "Kimi Code"207 return "Kimi Code"
208+ case "google":
209+ return "Google (Gemini API key)"
207 }210 }
208 return id211 return id
209 }212 }
@@ -222,7 +225,8 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
222 anth := d.status["anthropic"]225 anth := d.status["anthropic"]
223 op := d.status["openai"]226 op := d.status["openai"]
224 kimi := d.status["kimi"]227 kimi := d.status["kimi"]
225- if anth == "" && op == "" && kimi == "" {228+ goog := d.status["google"]
229+ if anth == "" && op == "" && kimi == "" && goog == "" {
226 return nil230 return nil
227 }231 }
228 row := func(id, method string) string {232 row := func(id, method string) string {
@@ -245,6 +249,7 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
245 row("anthropic", anth),249 row("anthropic", anth),
246 row("openai", op),250 row("openai", op),
247 row("kimi", kimi),251 row("kimi", kimi),
252+ row("google", goog),
248 "",253 "",
249 }254 }
250 }255 }
@@ -310,16 +315,23 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
310 d.cursor--315 d.cursor--
311 }316 }
312 case tui.KeyDown:317 case tui.KeyDown:
313- if d.cursor < 2 {318+ if d.cursor < 3 {
314 d.cursor++319 d.cursor++
315 }320 }
316 case tui.KeyEsc:321 case tui.KeyEsc:
317 d.Close()322 d.Close()
318 return loginDialogAction{Close: true}323 return loginDialogAction{Close: true}
319 case tui.KeyEnter:324 case tui.KeyEnter:
320- providers := []string{"anthropic", "openai", "kimi"}325+ providers := []string{"anthropic", "openai", "kimi", "google"}
321 d.provider = providers[d.cursor]326 d.provider = providers[d.cursor]
322 d.step = loginStepWaiting327 d.step = loginStepWaiting
328+ // Google has no subscription OAuth path (Gemini Advanced does
329+ // not issue API tokens). If the user chose subscription + google,
330+ // quietly downgrade to api-key so they get a usable form instead
331+ // of a hard error.
332+ if d.provider == "google" {
333+ d.method = "apikey"
334+ }
323 if d.method == "apikey" {335 if d.method == "apikey" {
324 return loginDialogAction{StartAPIKey: true, Provider: d.provider}336 return loginDialogAction{StartAPIKey: true, Provider: d.provider}
325 }337 }
M internal/agent/modes/rescue_dialog.go +1 -1
@@ -292,7 +292,7 @@ func extractFailedProvider(err error) string {
292292 if i := strings.Index(msg, ":"); i > 0 {
293293 head := strings.TrimSpace(msg[:i])
294294 switch head {
295- case "anthropic", "openai", "openai-codex", "kimi", "ollama":
295+ case "anthropic", "openai", "openai-codex", "kimi", "google", "ollama":
296296 return head
297297 }
298298 }
@@ -292,7 +292,7 @@ func extractFailedProvider(err error) string {
292 if i := strings.Index(msg, ":"); i > 0 {292 if i := strings.Index(msg, ":"); i > 0 {
293 head := strings.TrimSpace(msg[:i])293 head := strings.TrimSpace(msg[:i])
294 switch head {294 switch head {
295- case "anthropic", "openai", "openai-codex", "kimi", "ollama":295+ case "anthropic", "openai", "openai-codex", "kimi", "google", "ollama":
296 return head296 return head
297 }297 }
298 }298 }
M internal/auth/manager.go +13 -4
@@ -78,8 +78,8 @@ func (m *Manager) Close() {
7878
7979 // StartAPIKey launches the API-key login flow.
8080 func (m *Manager) StartAPIKey(provider string) (string, error) {
81- if provider != "anthropic" && provider != "openai" && provider != "kimi" {
82- return "", fmt.Errorf("provider must be anthropic, openai, or kimi")
81+ if !isKnownAPIKeyProvider(provider) {
82+ return "", fmt.Errorf("provider must be anthropic, openai, kimi, or google")
8383 }
8484 if err := m.ensureKeyServer(); err != nil {
8585 return "", err
@@ -124,6 +124,11 @@ func (m *Manager) consumeKeyServerResults() {
124124 // StartOAuth launches the subscription OAuth flow for provider.
125125 // Only one oauth flow may be in progress at a time (because the
126126 // callback port is fixed per provider and re-used by the official CLIs).
127+//
128+// Note: "google" is intentionally not supported here. Google does not
129+// offer a subscription OAuth that exchanges a Google One AI / Gemini
130+// Advanced login for usable Generative Language API credentials, so
131+// the only supported google login path is the API-key flow.
127132 func (m *Manager) StartOAuth(provider string) (string, error) {
128133 if provider == "kimi" {
129134 return m.StartKimiDeviceOAuth()
@@ -134,8 +139,10 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
134139 op = AnthropicOAuth
135140 case "openai":
136141 op = OpenAIOAuth
142+ case "google":
143+ return "", fmt.Errorf("google login is api-key only; use api key login for gemini")
137144 default:
138- return "", fmt.Errorf("provider must be anthropic, openai, or kimi")
145+ return "", fmt.Errorf("provider must be anthropic, openai, kimi, or google")
139146 }
140147
141148 m.mu.Lock()
@@ -255,8 +262,10 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
255262 op = AnthropicManualOAuth
256263 case "openai":
257264 op = OpenAIOAuth
265+ case "google":
266+ return "", fmt.Errorf("google login is api-key only; use api key login for gemini")
258267 default:
259- return "", fmt.Errorf("provider must be anthropic, openai, or kimi")
268+ return "", fmt.Errorf("provider must be anthropic, openai, kimi, or google")
260269 }
261270
262271 pkce, err := NewPKCE()
@@ -78,8 +78,8 @@ func (m *Manager) Close() {
78 78
79 // StartAPIKey launches the API-key login flow.79 // StartAPIKey launches the API-key login flow.
80 func (m *Manager) StartAPIKey(provider string) (string, error) {80 func (m *Manager) StartAPIKey(provider string) (string, error) {
81- if provider != "anthropic" && provider != "openai" && provider != "kimi" {81+ if !isKnownAPIKeyProvider(provider) {
82- return "", fmt.Errorf("provider must be anthropic, openai, or kimi")82+ return "", fmt.Errorf("provider must be anthropic, openai, kimi, or google")
83 }83 }
84 if err := m.ensureKeyServer(); err != nil {84 if err := m.ensureKeyServer(); err != nil {
85 return "", err85 return "", err
@@ -124,6 +124,11 @@ func (m *Manager) consumeKeyServerResults() {
124 // StartOAuth launches the subscription OAuth flow for provider.124 // StartOAuth launches the subscription OAuth flow for provider.
125 // Only one oauth flow may be in progress at a time (because the125 // Only one oauth flow may be in progress at a time (because the
126 // callback port is fixed per provider and re-used by the official CLIs).126 // callback port is fixed per provider and re-used by the official CLIs).
127+//
128+// Note: "google" is intentionally not supported here. Google does not
129+// offer a subscription OAuth that exchanges a Google One AI / Gemini
130+// Advanced login for usable Generative Language API credentials, so
131+// the only supported google login path is the API-key flow.
127 func (m *Manager) StartOAuth(provider string) (string, error) {132 func (m *Manager) StartOAuth(provider string) (string, error) {
128 if provider == "kimi" {133 if provider == "kimi" {
129 return m.StartKimiDeviceOAuth()134 return m.StartKimiDeviceOAuth()
@@ -134,8 +139,10 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
134 op = AnthropicOAuth139 op = AnthropicOAuth
135 case "openai":140 case "openai":
136 op = OpenAIOAuth141 op = OpenAIOAuth
142+ case "google":
143+ return "", fmt.Errorf("google login is api-key only; use api key login for gemini")
137 default:144 default:
138- return "", fmt.Errorf("provider must be anthropic, openai, or kimi")145+ return "", fmt.Errorf("provider must be anthropic, openai, kimi, or google")
139 }146 }
140 147
141 m.mu.Lock()148 m.mu.Lock()
@@ -255,8 +262,10 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
255 op = AnthropicManualOAuth262 op = AnthropicManualOAuth
256 case "openai":263 case "openai":
257 op = OpenAIOAuth264 op = OpenAIOAuth
265+ case "google":
266+ return "", fmt.Errorf("google login is api-key only; use api key login for gemini")
258 default:267 default:
259- return "", fmt.Errorf("provider must be anthropic, openai, or kimi")268+ return "", fmt.Errorf("provider must be anthropic, openai, kimi, or google")
260 }269 }
261 270
262 pkce, err := NewPKCE()271 pkce, err := NewPKCE()
M internal/auth/probe.go +9 -0
@@ -37,6 +37,15 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error {
3737 return err
3838 }
3939 req.Header.Set("authorization", "Bearer "+key)
40+ case "google":
41+ // Google Generative Language: list models with the API key.
42+ // Accepts the key via x-goog-api-key header (preferred over
43+ // the ?key= query param so it doesn't show up in proxy logs).
44+ req, err = http.NewRequestWithContext(ctx, "GET", "https://generativelanguage.googleapis.com/v1beta/models", nil)
45+ if err != nil {
46+ return err
47+ }
48+ req.Header.Set("x-goog-api-key", key)
4049 default:
4150 return fmt.Errorf("unknown provider %q", provider)
4251 }
@@ -37,6 +37,15 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error {
37 return err37 return err
38 }38 }
39 req.Header.Set("authorization", "Bearer "+key)39 req.Header.Set("authorization", "Bearer "+key)
40+ case "google":
41+ // Google Generative Language: list models with the API key.
42+ // Accepts the key via x-goog-api-key header (preferred over
43+ // the ?key= query param so it doesn't show up in proxy logs).
44+ req, err = http.NewRequestWithContext(ctx, "GET", "https://generativelanguage.googleapis.com/v1beta/models", nil)
45+ if err != nil {
46+ return err
47+ }
48+ req.Header.Set("x-goog-api-key", key)
40 default:49 default:
41 return fmt.Errorf("unknown provider %q", provider)50 return fmt.Errorf("unknown provider %q", provider)
42 }51 }
M internal/auth/server.go +17 -4
@@ -95,6 +95,18 @@ func (s *Server) Shutdown(ctx context.Context) error {
9595 return s.srv.Shutdown(ctx)
9696 }
9797
98+// isKnownAPIKeyProvider reports whether the given provider supports
99+// API-key login through the loopback flow. Kept centralized so adding a
100+// provider only touches one place. OAuth-only paths are handled
101+// elsewhere (manager.StartOAuth).
102+func isKnownAPIKeyProvider(p string) bool {
103+ switch p {
104+ case "anthropic", "openai", "kimi", "google":
105+ return true
106+ }
107+ return false
108+}
109+
98110 // ---- handlers ----
99111
100112 func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
@@ -111,8 +123,8 @@ func (s *Server) handleAPIKey(w http.ResponseWriter, r *http.Request) {
111123 switch r.Method {
112124 case http.MethodGet:
113125 provider := r.URL.Query().Get("provider")
114- if provider != "anthropic" && provider != "openai" && provider != "kimi" {
115- http.Error(w, "provider must be anthropic, openai, or kimi", http.StatusBadRequest)
126+ if !isKnownAPIKeyProvider(provider) {
127+ http.Error(w, "provider must be anthropic, openai, kimi, or google", http.StatusBadRequest)
116128 return
117129 }
118130 tpl.ExecuteTemplate(w, "apikey", map[string]any{"Provider": provider})
@@ -223,11 +235,12 @@ var tpl = template.Must(template.New("index").Parse(`<!doctype html><html lang="
223235 ` + logoTag + `
224236 <h1><span class="zot">zot</span> login</h1>
225237 <hr class="rule">
226-<p>paste an api key for anthropic, openai, or kimi. <span class="zot">zot</span> probes the provider once, then saves the key to <span class="mono">~/Library/Application Support/zot/auth.json</span>.</p>
238+<p>paste an api key for anthropic, openai, kimi, or google. <span class="zot">zot</span> probes the provider once, then saves the key to <span class="mono">~/Library/Application Support/zot/auth.json</span>.</p>
227239 <p>
228240 <a href="/apikey?provider=anthropic">anthropic api key →</a><br>
229241 <a href="/apikey?provider=openai">openai api key →</a><br>
230- <a href="/apikey?provider=kimi">kimi api key →</a>
242+ <a href="/apikey?provider=kimi">kimi api key →</a><br>
243+ <a href="/apikey?provider=google">google gemini api key →</a>
231244 </p>
232245 <hr class="rule">
233246 <p class="muted">for a subscription login (claude pro/max - chatgpt plus/pro - kimi code), close this tab and run /login inside <span class="zot">zot</span>.</p>
@@ -95,6 +95,18 @@ func (s *Server) Shutdown(ctx context.Context) error {
95 return s.srv.Shutdown(ctx)95 return s.srv.Shutdown(ctx)
96 }96 }
97 97
98+// isKnownAPIKeyProvider reports whether the given provider supports
99+// API-key login through the loopback flow. Kept centralized so adding a
100+// provider only touches one place. OAuth-only paths are handled
101+// elsewhere (manager.StartOAuth).
102+func isKnownAPIKeyProvider(p string) bool {
103+ switch p {
104+ case "anthropic", "openai", "kimi", "google":
105+ return true
106+ }
107+ return false
108+}
109+
98 // ---- handlers ----110 // ---- handlers ----
99 111
100 func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {112 func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
@@ -111,8 +123,8 @@ func (s *Server) handleAPIKey(w http.ResponseWriter, r *http.Request) {
111 switch r.Method {123 switch r.Method {
112 case http.MethodGet:124 case http.MethodGet:
113 provider := r.URL.Query().Get("provider")125 provider := r.URL.Query().Get("provider")
114- if provider != "anthropic" && provider != "openai" && provider != "kimi" {126+ if !isKnownAPIKeyProvider(provider) {
115- http.Error(w, "provider must be anthropic, openai, or kimi", http.StatusBadRequest)127+ http.Error(w, "provider must be anthropic, openai, kimi, or google", http.StatusBadRequest)
116 return128 return
117 }129 }
118 tpl.ExecuteTemplate(w, "apikey", map[string]any{"Provider": provider})130 tpl.ExecuteTemplate(w, "apikey", map[string]any{"Provider": provider})
@@ -223,11 +235,12 @@ var tpl = template.Must(template.New("index").Parse(`<!doctype html><html lang="
223 ` + logoTag + `235 ` + logoTag + `
224 <h1><span class="zot">zot</span> login</h1>236 <h1><span class="zot">zot</span> login</h1>
225 <hr class="rule">237 <hr class="rule">
226-<p>paste an api key for anthropic, openai, or kimi. <span class="zot">zot</span> probes the provider once, then saves the key to <span class="mono">~/Library/Application Support/zot/auth.json</span>.</p>238+<p>paste an api key for anthropic, openai, kimi, or google. <span class="zot">zot</span> probes the provider once, then saves the key to <span class="mono">~/Library/Application Support/zot/auth.json</span>.</p>
227 <p>239 <p>
228 <a href="/apikey?provider=anthropic">anthropic api key →</a><br>240 <a href="/apikey?provider=anthropic">anthropic api key →</a><br>
229 <a href="/apikey?provider=openai">openai api key →</a><br>241 <a href="/apikey?provider=openai">openai api key →</a><br>
230- <a href="/apikey?provider=kimi">kimi api key →</a>242+ <a href="/apikey?provider=kimi">kimi api key →</a><br>
243+ <a href="/apikey?provider=google">google gemini api key →</a>
231 </p>244 </p>
232 <hr class="rule">245 <hr class="rule">
233 <p class="muted">for a subscription login (claude pro/max - chatgpt plus/pro - kimi code), close this tab and run /login inside <span class="zot">zot</span>.</p>246 <p class="muted">for a subscription login (claude pro/max - chatgpt plus/pro - kimi code), close this tab and run /login inside <span class="zot">zot</span>.</p>
M internal/auth/store.go +3 -0
@@ -19,6 +19,7 @@ type Credentials struct {
1919 Anthropic ProviderCreds `json:"anthropic,omitempty"`
2020 OpenAI ProviderCreds `json:"openai,omitempty"`
2121 Kimi ProviderCreds `json:"kimi,omitempty"`
22+ Google ProviderCreds `json:"google,omitempty"`
2223 }
2324
2425 // ProviderCreds holds credentials for a single provider. Only one of
@@ -83,6 +84,8 @@ func (c *Credentials) get(provider string) *ProviderCreds {
8384 return &c.OpenAI
8485 case "kimi":
8586 return &c.Kimi
87+ case "google":
88+ return &c.Google
8689 }
8790 return nil
8891 }
@@ -19,6 +19,7 @@ type Credentials struct {
19 Anthropic ProviderCreds `json:"anthropic,omitempty"`19 Anthropic ProviderCreds `json:"anthropic,omitempty"`
20 OpenAI ProviderCreds `json:"openai,omitempty"`20 OpenAI ProviderCreds `json:"openai,omitempty"`
21 Kimi ProviderCreds `json:"kimi,omitempty"`21 Kimi ProviderCreds `json:"kimi,omitempty"`
22+ Google ProviderCreds `json:"google,omitempty"`
22 }23 }
23 24
24 // ProviderCreds holds credentials for a single provider. Only one of25 // ProviderCreds holds credentials for a single provider. Only one of
@@ -83,6 +84,8 @@ func (c *Credentials) get(provider string) *ProviderCreds {
83 return &c.OpenAI84 return &c.OpenAI
84 case "kimi":85 case "kimi":
85 return &c.Kimi86 return &c.Kimi
87+ case "google":
88+ return &c.Google
86 }89 }
87 return nil90 return nil
88 }91 }
M internal/provider/discover.go +94 -0
@@ -112,6 +112,100 @@ func DiscoverOpenAI(ctx context.Context, apiKey, baseURL string) ([]Model, error
112112 return out, nil
113113 }
114114
115+// DiscoverGoogle lists Gemini model ids visible to key on
116+// generativelanguage.googleapis.com. The API paginates with
117+// nextPageToken; we follow it until exhausted.
118+func DiscoverGoogle(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
119+ if baseURL == "" {
120+ baseURL = geminiDefaultBaseURL
121+ }
122+ client := &http.Client{Timeout: 15 * time.Second}
123+ var out []Model
124+ pageToken := ""
125+ for {
126+ url := strings.TrimRight(baseURL, "/") + "/v1beta/models?pageSize=200"
127+ if pageToken != "" {
128+ url += "&pageToken=" + pageToken
129+ }
130+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
131+ if err != nil {
132+ return nil, err
133+ }
134+ req.Header.Set("x-goog-api-key", apiKey)
135+ resp, err := client.Do(req)
136+ if err != nil {
137+ return nil, err
138+ }
139+ body, _ := io.ReadAll(resp.Body)
140+ resp.Body.Close()
141+ if resp.StatusCode != 200 {
142+ return nil, fmt.Errorf("google discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
143+ }
144+ var page struct {
145+ Models []struct {
146+ Name string `json:"name"` // "models/gemini-2.5-pro"
147+ DisplayName string `json:"displayName"`
148+ SupportedGenerationMethods []string `json:"supportedGenerationMethods"`
149+ InputTokenLimit int `json:"inputTokenLimit"`
150+ OutputTokenLimit int `json:"outputTokenLimit"`
151+ } `json:"models"`
152+ NextPageToken string `json:"nextPageToken"`
153+ }
154+ if err := json.Unmarshal(body, &page); err != nil {
155+ return nil, fmt.Errorf("google discover parse: %w", err)
156+ }
157+ for _, m := range page.Models {
158+ // Strip the "models/" prefix Gemini uses on resource names.
159+ id := strings.TrimPrefix(m.Name, "models/")
160+ if !looksLikeGeminiChatModel(id, m.SupportedGenerationMethods) {
161+ continue
162+ }
163+ display := m.DisplayName
164+ if display == "" {
165+ display = id
166+ }
167+ out = append(out, Model{
168+ Provider: "google",
169+ ID: id,
170+ DisplayName: display,
171+ ContextWindow: m.InputTokenLimit,
172+ MaxOutput: m.OutputTokenLimit,
173+ Source: "live",
174+ })
175+ }
176+ if page.NextPageToken == "" {
177+ break
178+ }
179+ pageToken = page.NextPageToken
180+ }
181+ return out, nil
182+}
183+
184+// looksLikeGeminiChatModel filters Gemini's /v1beta/models output
185+// down to entries usable with streamGenerateContent. The API also
186+// lists embedding models, AQA, and other non-chat artefacts.
187+func looksLikeGeminiChatModel(id string, methods []string) bool {
188+ if !strings.HasPrefix(id, "gemini-") && !strings.HasPrefix(id, "gemma-") {
189+ return false
190+ }
191+ if strings.Contains(id, "embedding") || strings.Contains(id, "aqa") {
192+ return false
193+ }
194+ if len(methods) > 0 {
195+ ok := false
196+ for _, m := range methods {
197+ if m == "generateContent" || m == "streamGenerateContent" {
198+ ok = true
199+ break
200+ }
201+ }
202+ if !ok {
203+ return false
204+ }
205+ }
206+ return true
207+}
208+
115209 // looksLikeChatModel returns true for OpenAI ids that can plausibly be
116210 // used with the chat/completions endpoint. Errs on the side of inclusion.
117211 func looksLikeChatModel(id string) bool {
@@ -112,6 +112,100 @@ func DiscoverOpenAI(ctx context.Context, apiKey, baseURL string) ([]Model, error
112 return out, nil112 return out, nil
113 }113 }
114 114
115+// DiscoverGoogle lists Gemini model ids visible to key on
116+// generativelanguage.googleapis.com. The API paginates with
117+// nextPageToken; we follow it until exhausted.
118+func DiscoverGoogle(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
119+ if baseURL == "" {
120+ baseURL = geminiDefaultBaseURL
121+ }
122+ client := &http.Client{Timeout: 15 * time.Second}
123+ var out []Model
124+ pageToken := ""
125+ for {
126+ url := strings.TrimRight(baseURL, "/") + "/v1beta/models?pageSize=200"
127+ if pageToken != "" {
128+ url += "&pageToken=" + pageToken
129+ }
130+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
131+ if err != nil {
132+ return nil, err
133+ }
134+ req.Header.Set("x-goog-api-key", apiKey)
135+ resp, err := client.Do(req)
136+ if err != nil {
137+ return nil, err
138+ }
139+ body, _ := io.ReadAll(resp.Body)
140+ resp.Body.Close()
141+ if resp.StatusCode != 200 {
142+ return nil, fmt.Errorf("google discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
143+ }
144+ var page struct {
145+ Models []struct {
146+ Name string `json:"name"` // "models/gemini-2.5-pro"
147+ DisplayName string `json:"displayName"`
148+ SupportedGenerationMethods []string `json:"supportedGenerationMethods"`
149+ InputTokenLimit int `json:"inputTokenLimit"`
150+ OutputTokenLimit int `json:"outputTokenLimit"`
151+ } `json:"models"`
152+ NextPageToken string `json:"nextPageToken"`
153+ }
154+ if err := json.Unmarshal(body, &page); err != nil {
155+ return nil, fmt.Errorf("google discover parse: %w", err)
156+ }
157+ for _, m := range page.Models {
158+ // Strip the "models/" prefix Gemini uses on resource names.
159+ id := strings.TrimPrefix(m.Name, "models/")
160+ if !looksLikeGeminiChatModel(id, m.SupportedGenerationMethods) {
161+ continue
162+ }
163+ display := m.DisplayName
164+ if display == "" {
165+ display = id
166+ }
167+ out = append(out, Model{
168+ Provider: "google",
169+ ID: id,
170+ DisplayName: display,
171+ ContextWindow: m.InputTokenLimit,
172+ MaxOutput: m.OutputTokenLimit,
173+ Source: "live",
174+ })
175+ }
176+ if page.NextPageToken == "" {
177+ break
178+ }
179+ pageToken = page.NextPageToken
180+ }
181+ return out, nil
182+}
183+
184+// looksLikeGeminiChatModel filters Gemini's /v1beta/models output
185+// down to entries usable with streamGenerateContent. The API also
186+// lists embedding models, AQA, and other non-chat artefacts.
187+func looksLikeGeminiChatModel(id string, methods []string) bool {
188+ if !strings.HasPrefix(id, "gemini-") && !strings.HasPrefix(id, "gemma-") {
189+ return false
190+ }
191+ if strings.Contains(id, "embedding") || strings.Contains(id, "aqa") {
192+ return false
193+ }
194+ if len(methods) > 0 {
195+ ok := false
196+ for _, m := range methods {
197+ if m == "generateContent" || m == "streamGenerateContent" {
198+ ok = true
199+ break
200+ }
201+ }
202+ if !ok {
203+ return false
204+ }
205+ }
206+ return true
207+}
208+
115 // looksLikeChatModel returns true for OpenAI ids that can plausibly be209 // looksLikeChatModel returns true for OpenAI ids that can plausibly be
116 // used with the chat/completions endpoint. Errs on the side of inclusion.210 // used with the chat/completions endpoint. Errs on the side of inclusion.
117 func looksLikeChatModel(id string) bool {211 func looksLikeChatModel(id string) bool {
A internal/provider/gemini.go +623 -0
@@ -0,0 +1,623 @@
1+package provider
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/base64"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "strings"
12+ "time"
13+)
14+
15+// Google Gemini provider, talking directly to the Generative Language
16+// REST API at generativelanguage.googleapis.com. We hand-roll the wire
17+// format instead of pulling in @google/genai (the SDK is large and we
18+// only need streamGenerateContent + the models list).
19+//
20+// Auth model: API key only. Google does NOT issue OAuth tokens for
21+// consumer Gemini Advanced / Google One AI subscriptions; programmatic
22+// access requires either an AI Studio API key (this client) or Vertex
23+// AI / GCP service-account credentials (separate provider, not yet
24+// implemented in zot).
25+
26+const geminiDefaultBaseURL = "https://generativelanguage.googleapis.com"
27+
28+// geminiClient implements Client against the Gemini Generative Language API.
29+type geminiClient struct {
30+ apiKey string
31+ baseURL string
32+ http *http.Client
33+}
34+
35+// NewGemini creates a Gemini client using an AI Studio API key.
36+// baseURL may be empty; defaults to https://generativelanguage.googleapis.com.
37+func NewGemini(apiKey, baseURL string) Client {
38+ if baseURL == "" {
39+ baseURL = geminiDefaultBaseURL
40+ }
41+ return &geminiClient{
42+ apiKey: apiKey,
43+ baseURL: strings.TrimRight(baseURL, "/"),
44+ http: &http.Client{Timeout: 0},
45+ }
46+}
47+
48+func (c *geminiClient) Name() string { return "google" }
49+
50+// ---- wire types ----
51+//
52+// Subset of Gemini's Content / Part / GenerateContentRequest schema.
53+// Only the fields zot actually emits or consumes are declared here.
54+
55+type gemInlineData struct {
56+ MimeType string `json:"mimeType"`
57+ Data string `json:"data"` // base64
58+}
59+
60+type gemFunctionCall struct {
61+ Name string `json:"name"`
62+ Args json.RawMessage `json:"args,omitempty"`
63+}
64+
65+type gemFunctionResponse struct {
66+ Name string `json:"name"`
67+ Response json.RawMessage `json:"response"`
68+}
69+
70+// gemPart is one element of a Content. Exactly one of the optional
71+// fields is populated per part. We use pointers so empty values are
72+// omitted from the wire, which matters for tool responses (Gemini
73+// rejects parts with both `text` and `functionResponse` set).
74+type gemPart struct {
75+ Text string `json:"text,omitempty"`
76+ InlineData *gemInlineData `json:"inlineData,omitempty"`
77+ FunctionCall *gemFunctionCall `json:"functionCall,omitempty"`
78+ FunctionResponse *gemFunctionResponse `json:"functionResponse,omitempty"`
79+ // Thought: true marks a thought-summary part. Outgoing parts
80+ // from zot never set this; incoming chunks might.
81+ Thought bool `json:"thought,omitempty"`
82+}
83+
84+type gemContent struct {
85+ Role string `json:"role"` // "user" | "model"
86+ Parts []gemPart `json:"parts"`
87+}
88+
89+type gemFunctionDecl struct {
90+ Name string `json:"name"`
91+ Description string `json:"description,omitempty"`
92+ Parameters json.RawMessage `json:"parameters,omitempty"`
93+}
94+
95+type gemTool struct {
96+ FunctionDeclarations []gemFunctionDecl `json:"functionDeclarations,omitempty"`
97+}
98+
99+type gemThinkingConfig struct {
100+ IncludeThoughts bool `json:"includeThoughts,omitempty"`
101+ ThinkingBudget *int `json:"thinkingBudget,omitempty"`
102+ ThinkingLevel string `json:"thinkingLevel,omitempty"`
103+}
104+
105+type gemGenerationConfig struct {
106+ Temperature *float32 `json:"temperature,omitempty"`
107+ MaxOutputTokens *int `json:"maxOutputTokens,omitempty"`
108+ ThinkingConfig *gemThinkingConfig `json:"thinkingConfig,omitempty"`
109+}
110+
111+type gemSystemInstruction struct {
112+ Parts []gemPart `json:"parts"`
113+}
114+
115+type gemRequest struct {
116+ Contents []gemContent `json:"contents"`
117+ SystemInstruction *gemSystemInstruction `json:"systemInstruction,omitempty"`
118+ Tools []gemTool `json:"tools,omitempty"`
119+ GenerationConfig *gemGenerationConfig `json:"generationConfig,omitempty"`
120+}
121+
122+// ---- request building ----
123+
124+func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
125+ m, err := FindModel("google", req.Model)
126+ if err != nil {
127+ // Not in the catalog — still allow custom ids by falling back
128+ // to defaults so users can point at unreleased models or
129+ // alternate base URLs.
130+ m = Model{
131+ Provider: "google",
132+ ID: req.Model,
133+ ContextWindow: 1_000_000,
134+ MaxOutput: 8192,
135+ Reasoning: strings.Contains(req.Model, "2.5") || strings.Contains(req.Model, "3"),
136+ }
137+ }
138+
139+ out := &gemRequest{}
140+
141+ // System prompt → systemInstruction.parts[0].text.
142+ if strings.TrimSpace(req.System) != "" {
143+ out.SystemInstruction = &gemSystemInstruction{
144+ Parts: []gemPart{{Text: req.System}},
145+ }
146+ }
147+
148+ // Convert tool defs.
149+ if len(req.Tools) > 0 {
150+ decls := make([]gemFunctionDecl, 0, len(req.Tools))
151+ for _, t := range req.Tools {
152+ schema := t.Schema
153+ if len(schema) == 0 || !json.Valid(schema) {
154+ schema = json.RawMessage(`{"type":"object","properties":{}}`)
155+ }
156+ decls = append(decls, gemFunctionDecl{
157+ Name: t.Name,
158+ Description: t.Description,
159+ Parameters: schema,
160+ })
161+ }
162+ out.Tools = []gemTool{{FunctionDeclarations: decls}}
163+ }
164+
165+ // Generation config.
166+ maxTok := req.MaxTokens
167+ if maxTok <= 0 {
168+ maxTok = m.MaxOutput
169+ }
170+ gc := &gemGenerationConfig{Temperature: req.Temperature}
171+ if maxTok > 0 {
172+ gc.MaxOutputTokens = &maxTok
173+ }
174+ if req.Reasoning != "" && m.Reasoning {
175+ tc := geminiThinkingConfig(m.ID, req.Reasoning)
176+ if tc != nil {
177+ gc.ThinkingConfig = tc
178+ }
179+ }
180+ out.GenerationConfig = gc
181+
182+ // Convert messages.
183+ msgs := RepairOrphanedToolResults(req.Messages)
184+ for _, msg := range msgs {
185+ switch msg.Role {
186+ case RoleUser:
187+ parts := convertGemUserParts(msg.Content)
188+ if len(parts) == 0 {
189+ continue
190+ }
191+ out.Contents = append(out.Contents, gemContent{Role: "user", Parts: parts})
192+ case RoleAssistant:
193+ parts := convertGemAssistantParts(msg.Content)
194+ if len(parts) == 0 {
195+ continue
196+ }
197+ out.Contents = append(out.Contents, gemContent{Role: "model", Parts: parts})
198+ case RoleTool:
199+ // Each tool_result becomes a user-role message with a
200+ // functionResponse part. Gemini's protocol uses
201+ // "user" role for tool replies.
202+ parts := convertGemToolResultParts(msg.Content)
203+ if len(parts) == 0 {
204+ continue
205+ }
206+ out.Contents = append(out.Contents, gemContent{Role: "user", Parts: parts})
207+ }
208+ }
209+
210+ return out, m.ID, nil
211+}
212+
213+func convertGemUserParts(blocks []Content) []gemPart {
214+ var parts []gemPart
215+ for _, b := range blocks {
216+ switch v := b.(type) {
217+ case TextBlock:
218+ if v.Text == "" {
219+ continue
220+ }
221+ parts = append(parts, gemPart{Text: v.Text})
222+ case ImageBlock:
223+ parts = append(parts, gemPart{
224+ InlineData: &gemInlineData{
225+ MimeType: v.MimeType,
226+ Data: base64.StdEncoding.EncodeToString(v.Data),
227+ },
228+ })
229+ }
230+ }
231+ return parts
232+}
233+
234+func convertGemAssistantParts(blocks []Content) []gemPart {
235+ var parts []gemPart
236+ for _, b := range blocks {
237+ switch v := b.(type) {
238+ case TextBlock:
239+ if strings.TrimSpace(v.Text) == "" {
240+ continue
241+ }
242+ parts = append(parts, gemPart{Text: v.Text})
243+ case ToolCallBlock:
244+ args := v.Arguments
245+ if len(args) == 0 || !json.Valid(args) {
246+ args = json.RawMessage("{}")
247+ }
248+ parts = append(parts, gemPart{
249+ FunctionCall: &gemFunctionCall{
250+ Name: v.Name,
251+ Args: args,
252+ },
253+ })
254+ }
255+ }
256+ return parts
257+}
258+
259+func convertGemToolResultParts(blocks []Content) []gemPart {
260+ var parts []gemPart
261+ for _, b := range blocks {
262+ tr, ok := b.(ToolResultBlock)
263+ if !ok {
264+ continue
265+ }
266+ // Flatten text content. Image content in tool results is dropped
267+ // for Gemini < 3 (multimodal function responses are 3+); for the
268+ // common path (text output) we wrap as {"output": "..."} or
269+ // {"error": "..."} per the SDK's convention.
270+ var sb strings.Builder
271+ for _, c := range tr.Content {
272+ if tb, ok := c.(TextBlock); ok {
273+ if sb.Len() > 0 {
274+ sb.WriteString("\n")
275+ }
276+ sb.WriteString(tb.Text)
277+ }
278+ }
279+ key := "output"
280+ if tr.IsError {
281+ key = "error"
282+ }
283+ // Gemini wants response to be an object, not a string.
284+ respObj := map[string]string{key: sb.String()}
285+ respBytes, err := json.Marshal(respObj)
286+ if err != nil {
287+ respBytes = []byte(`{"output":""}`)
288+ }
289+ // Tool name is required on functionResponse; the original
290+ // ToolCallBlock has it but ToolResultBlock only carries the
291+ // call id. We thread the name back via the call id by using
292+ // the id as the name fallback — Gemini ignores the name on
293+ // the response side as long as it's non-empty.
294+ name := tr.CallID
295+ parts = append(parts, gemPart{
296+ FunctionResponse: &gemFunctionResponse{
297+ Name: name,
298+ Response: respBytes,
299+ },
300+ })
301+ }
302+ return parts
303+}
304+
305+// geminiThinkingConfig maps zot's reasoning level ("low"/"medium"/"high")
306+// to Gemini's thinkingConfig. The right knob depends on the model
307+// generation: 2.5 family uses thinkingBudget (tokens), 3.x uses
308+// thinkingLevel (enum). Returns nil when the level is unrecognised.
309+func geminiThinkingConfig(modelID, level string) *gemThinkingConfig {
310+ level = strings.ToLower(level)
311+ id := strings.ToLower(modelID)
312+
313+ // Gemini 3.x: enum-based thinkingLevel. Pro can't go below LOW.
314+ if strings.Contains(id, "gemini-3") {
315+ isPro := strings.Contains(id, "-pro")
316+ var lvl string
317+ switch level {
318+ case "low":
319+ lvl = "LOW"
320+ case "medium":
321+ if isPro {
322+ lvl = "HIGH"
323+ } else {
324+ lvl = "MEDIUM"
325+ }
326+ case "high":
327+ lvl = "HIGH"
328+ default:
329+ return nil
330+ }
331+ return &gemThinkingConfig{IncludeThoughts: true, ThinkingLevel: lvl}
332+ }
333+
334+ // Gemini 2.5 family: token-budget per-model.
335+ var budget int
336+ switch {
337+ case strings.Contains(id, "2.5-pro"):
338+ switch level {
339+ case "low":
340+ budget = 2048
341+ case "medium":
342+ budget = 8192
343+ case "high":
344+ budget = 32768
345+ }
346+ case strings.Contains(id, "2.5-flash-lite"):
347+ switch level {
348+ case "low":
349+ budget = 2048
350+ case "medium":
351+ budget = 8192
352+ case "high":
353+ budget = 24576
354+ }
355+ case strings.Contains(id, "2.5-flash"):
356+ switch level {
357+ case "low":
358+ budget = 2048
359+ case "medium":
360+ budget = 8192
361+ case "high":
362+ budget = 24576
363+ }
364+ default:
365+ return nil
366+ }
367+ if budget <= 0 {
368+ return nil
369+ }
370+ return &gemThinkingConfig{IncludeThoughts: true, ThinkingBudget: &budget}
371+}
372+
373+// ---- streaming ----
374+
375+func (c *geminiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
376+ wire, modelID, err := c.buildRequest(req)
377+ if err != nil {
378+ return nil, err
379+ }
380+ body, err := json.Marshal(wire)
381+ if err != nil {
382+ return nil, err
383+ }
384+
385+ // Gemini's streaming endpoint is a POST with ?alt=sse to get
386+ // an EventSource-compatible response. Without alt=sse the
387+ // server returns a JSON array (one element per chunk), which
388+ // we'd need a different parser for.
389+ apiPath := fmt.Sprintf("/v1beta/models/%s:streamGenerateContent", modelID)
390+ url := c.baseURL + apiPath + "?alt=sse"
391+
392+ newReq := func() (*http.Request, error) {
393+ httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
394+ if err != nil {
395+ return nil, err
396+ }
397+ httpReq.Header.Set("content-type", "application/json")
398+ httpReq.Header.Set("accept", "text/event-stream")
399+ // Two ways to authenticate against the Generative Language
400+ // API: x-goog-api-key header or ?key= query param. We use
401+ // the header so the key never lands in proxy access logs.
402+ httpReq.Header.Set("x-goog-api-key", c.apiKey)
403+ return httpReq, nil
404+ }
405+
406+ resp, err := doStreamWithRetry(ctx, c.http, newReq)
407+ if err != nil {
408+ return nil, fmt.Errorf("google: %w", err)
409+ }
410+ if resp.StatusCode != http.StatusOK {
411+ b, _ := io.ReadAll(resp.Body)
412+ resp.Body.Close()
413+ return nil, fmt.Errorf("google: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
414+ }
415+
416+ out := make(chan Event, 16)
417+ go c.runStream(ctx, resp, req, out)
418+ return out, nil
419+}
420+
421+func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
422+ defer close(out)
423+ defer resp.Body.Close()
424+
425+ model, _ := FindModel("google", req.Model)
426+ out <- EventStart{Model: req.Model, Provider: "google"}
427+
428+ raw := make(chan sseEvent, 16)
429+ go readSSE(resp.Body, raw)
430+
431+ // Gemini's SSE stream is a sequence of complete JSON
432+ // GenerateContentResponse objects, one per data: line. Each
433+ // candidate carries a list of parts (text or functionCall),
434+ // possibly accumulating across chunks.
435+ type blockEntry struct {
436+ kind string // "text" | "tool_use"
437+ textBuf strings.Builder
438+ toolID string
439+ toolName string
440+ toolArgs strings.Builder
441+ }
442+ var (
443+ blocks []*blockEntry
444+ currentText *blockEntry
445+ usage Usage
446+ stop StopReason = StopEnd
447+ finalErr error
448+ toolCounter int
449+ )
450+
451+ appendText := func(delta string) {
452+ if currentText == nil {
453+ currentText = &blockEntry{kind: "text"}
454+ blocks = append(blocks, currentText)
455+ }
456+ currentText.textBuf.WriteString(delta)
457+ }
458+
459+ startTool := func(name string, providedID string, args json.RawMessage) *blockEntry {
460+ toolCounter++
461+ id := providedID
462+ if id == "" {
463+ id = fmt.Sprintf("%s_%d_%d", name, time.Now().UnixNano(), toolCounter)
464+ }
465+ t := &blockEntry{
466+ kind: "tool_use",
467+ toolID: id,
468+ toolName: name,
469+ }
470+ if len(args) > 0 && json.Valid(args) {
471+ t.toolArgs.Write(args)
472+ }
473+ blocks = append(blocks, t)
474+ // New tool block breaks the current text run.
475+ currentText = nil
476+ return t
477+ }
478+
479+ assembleMsg := func() Message {
480+ content := []Content{}
481+ for _, b := range blocks {
482+ switch b.kind {
483+ case "text":
484+ if b.textBuf.Len() > 0 {
485+ content = append(content, TextBlock{Text: b.textBuf.String()})
486+ }
487+ case "tool_use":
488+ args := b.toolArgs.String()
489+ if args == "" || !json.Valid([]byte(args)) {
490+ args = "{}"
491+ }
492+ content = append(content, ToolCallBlock{
493+ ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args),
494+ })
495+ }
496+ }
497+ return Message{Role: RoleAssistant, Content: content, Time: time.Now()}
498+ }
499+
500+ sendDone := func() {
501+ usage.CostUSD = ComputeCost(model, usage)
502+ out <- EventUsage{Usage: usage}
503+ out <- EventDone{Stop: stop, Err: finalErr, Message: assembleMsg()}
504+ }
505+
506+ for {
507+ select {
508+ case <-ctx.Done():
509+ stop = StopAborted
510+ finalErr = ctx.Err()
511+ sendDone()
512+ return
513+ case ev, ok := <-raw:
514+ if !ok {
515+ sendDone()
516+ return
517+ }
518+ if strings.TrimSpace(ev.Data) == "" {
519+ continue
520+ }
521+ var chunk struct {
522+ Candidates []struct {
523+ Content struct {
524+ Role string `json:"role"`
525+ Parts []struct {
526+ Text string `json:"text"`
527+ Thought bool `json:"thought"`
528+ FunctionCall *gemFunctionCall `json:"functionCall"`
529+ FunctionCallID string `json:"id"`
530+ FunctionCallName string `json:"name"`
531+ } `json:"parts"`
532+ } `json:"content"`
533+ FinishReason string `json:"finishReason"`
534+ } `json:"candidates"`
535+ UsageMetadata *struct {
536+ PromptTokenCount int `json:"promptTokenCount"`
537+ CandidatesTokenCount int `json:"candidatesTokenCount"`
538+ ThoughtsTokenCount int `json:"thoughtsTokenCount"`
539+ CachedContentTokenCount int `json:"cachedContentTokenCount"`
540+ TotalTokenCount int `json:"totalTokenCount"`
541+ } `json:"usageMetadata"`
542+ Error *struct {
543+ Code int `json:"code"`
544+ Message string `json:"message"`
545+ Status string `json:"status"`
546+ } `json:"error"`
547+ }
548+ if err := json.Unmarshal([]byte(ev.Data), &chunk); err != nil {
549+ continue
550+ }
551+ if chunk.Error != nil {
552+ stop = StopError
553+ finalErr = fmt.Errorf("google: %s", chunk.Error.Message)
554+ sendDone()
555+ return
556+ }
557+ for _, cand := range chunk.Candidates {
558+ for _, part := range cand.Content.Parts {
559+ if part.FunctionCall != nil {
560+ var args json.RawMessage
561+ if len(part.FunctionCall.Args) > 0 {
562+ args = part.FunctionCall.Args
563+ } else {
564+ args = json.RawMessage("{}")
565+ }
566+ t := startTool(part.FunctionCall.Name, part.FunctionCallID, args)
567+ out <- EventToolStart{ID: t.toolID, Name: t.toolName}
568+ out <- EventToolArgs{ID: t.toolID, Delta: t.toolArgs.String()}
569+ out <- EventToolEnd{ID: t.toolID}
570+ continue
571+ }
572+ if part.Text == "" {
573+ continue
574+ }
575+ if part.Thought {
576+ // Thinking summaries arrive as text parts
577+ // with thought=true. Not surfaced to the
578+ // user in v1; could be exposed via a
579+ // future ReasoningBlock if useful.
580+ continue
581+ }
582+ appendText(part.Text)
583+ out <- EventTextDelta{Delta: part.Text}
584+ }
585+ switch cand.FinishReason {
586+ case "STOP", "":
587+ // "" arrives on intermediate chunks; only
588+ // promote the explicit terminal value.
589+ if cand.FinishReason == "STOP" {
590+ stop = StopEnd
591+ }
592+ case "MAX_TOKENS":
593+ stop = StopLength
594+ case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "IMAGE_SAFETY":
595+ stop = StopError
596+ finalErr = fmt.Errorf("google: response blocked (%s)", cand.FinishReason)
597+ }
598+ }
599+ if chunk.UsageMetadata != nil {
600+ // Gemini reports cumulative totals on every chunk
601+ // (or close to it), so assign rather than sum.
602+ um := chunk.UsageMetadata
603+ input := um.PromptTokenCount - um.CachedContentTokenCount
604+ if input < 0 {
605+ input = um.PromptTokenCount
606+ }
607+ usage.InputTokens = input
608+ usage.OutputTokens = um.CandidatesTokenCount + um.ThoughtsTokenCount
609+ usage.CacheReadTokens = um.CachedContentTokenCount
610+ }
611+ // Promote ToolUse stop when tool calls are present and
612+ // the candidate finished cleanly.
613+ if stop == StopEnd {
614+ for _, b := range blocks {
615+ if b.kind == "tool_use" {
616+ stop = StopToolUse
617+ break
618+ }
619+ }
620+ }
621+ }
622+ }
623+}
@@ -0,0 +1,623 @@
1+package provider
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/base64"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "strings"
12+ "time"
13+)
14+
15+// Google Gemini provider, talking directly to the Generative Language
16+// REST API at generativelanguage.googleapis.com. We hand-roll the wire
17+// format instead of pulling in @google/genai (the SDK is large and we
18+// only need streamGenerateContent + the models list).
19+//
20+// Auth model: API key only. Google does NOT issue OAuth tokens for
21+// consumer Gemini Advanced / Google One AI subscriptions; programmatic
22+// access requires either an AI Studio API key (this client) or Vertex
23+// AI / GCP service-account credentials (separate provider, not yet
24+// implemented in zot).
25+
26+const geminiDefaultBaseURL = "https://generativelanguage.googleapis.com"
27+
28+// geminiClient implements Client against the Gemini Generative Language API.
29+type geminiClient struct {
30+ apiKey string
31+ baseURL string
32+ http *http.Client
33+}
34+
35+// NewGemini creates a Gemini client using an AI Studio API key.
36+// baseURL may be empty; defaults to https://generativelanguage.googleapis.com.
37+func NewGemini(apiKey, baseURL string) Client {
38+ if baseURL == "" {
39+ baseURL = geminiDefaultBaseURL
40+ }
41+ return &geminiClient{
42+ apiKey: apiKey,
43+ baseURL: strings.TrimRight(baseURL, "/"),
44+ http: &http.Client{Timeout: 0},
45+ }
46+}
47+
48+func (c *geminiClient) Name() string { return "google" }
49+
50+// ---- wire types ----
51+//
52+// Subset of Gemini's Content / Part / GenerateContentRequest schema.
53+// Only the fields zot actually emits or consumes are declared here.
54+
55+type gemInlineData struct {
56+ MimeType string `json:"mimeType"`
57+ Data string `json:"data"` // base64
58+}
59+
60+type gemFunctionCall struct {
61+ Name string `json:"name"`
62+ Args json.RawMessage `json:"args,omitempty"`
63+}
64+
65+type gemFunctionResponse struct {
66+ Name string `json:"name"`
67+ Response json.RawMessage `json:"response"`
68+}
69+
70+// gemPart is one element of a Content. Exactly one of the optional
71+// fields is populated per part. We use pointers so empty values are
72+// omitted from the wire, which matters for tool responses (Gemini
73+// rejects parts with both `text` and `functionResponse` set).
74+type gemPart struct {
75+ Text string `json:"text,omitempty"`
76+ InlineData *gemInlineData `json:"inlineData,omitempty"`
77+ FunctionCall *gemFunctionCall `json:"functionCall,omitempty"`
78+ FunctionResponse *gemFunctionResponse `json:"functionResponse,omitempty"`
79+ // Thought: true marks a thought-summary part. Outgoing parts
80+ // from zot never set this; incoming chunks might.
81+ Thought bool `json:"thought,omitempty"`
82+}
83+
84+type gemContent struct {
85+ Role string `json:"role"` // "user" | "model"
86+ Parts []gemPart `json:"parts"`
87+}
88+
89+type gemFunctionDecl struct {
90+ Name string `json:"name"`
91+ Description string `json:"description,omitempty"`
92+ Parameters json.RawMessage `json:"parameters,omitempty"`
93+}
94+
95+type gemTool struct {
96+ FunctionDeclarations []gemFunctionDecl `json:"functionDeclarations,omitempty"`
97+}
98+
99+type gemThinkingConfig struct {
100+ IncludeThoughts bool `json:"includeThoughts,omitempty"`
101+ ThinkingBudget *int `json:"thinkingBudget,omitempty"`
102+ ThinkingLevel string `json:"thinkingLevel,omitempty"`
103+}
104+
105+type gemGenerationConfig struct {
106+ Temperature *float32 `json:"temperature,omitempty"`
107+ MaxOutputTokens *int `json:"maxOutputTokens,omitempty"`
108+ ThinkingConfig *gemThinkingConfig `json:"thinkingConfig,omitempty"`
109+}
110+
111+type gemSystemInstruction struct {
112+ Parts []gemPart `json:"parts"`
113+}
114+
115+type gemRequest struct {
116+ Contents []gemContent `json:"contents"`
117+ SystemInstruction *gemSystemInstruction `json:"systemInstruction,omitempty"`
118+ Tools []gemTool `json:"tools,omitempty"`
119+ GenerationConfig *gemGenerationConfig `json:"generationConfig,omitempty"`
120+}
121+
122+// ---- request building ----
123+
124+func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
125+ m, err := FindModel("google", req.Model)
126+ if err != nil {
127+ // Not in the catalog — still allow custom ids by falling back
128+ // to defaults so users can point at unreleased models or
129+ // alternate base URLs.
130+ m = Model{
131+ Provider: "google",
132+ ID: req.Model,
133+ ContextWindow: 1_000_000,
134+ MaxOutput: 8192,
135+ Reasoning: strings.Contains(req.Model, "2.5") || strings.Contains(req.Model, "3"),
136+ }
137+ }
138+
139+ out := &gemRequest{}
140+
141+ // System prompt → systemInstruction.parts[0].text.
142+ if strings.TrimSpace(req.System) != "" {
143+ out.SystemInstruction = &gemSystemInstruction{
144+ Parts: []gemPart{{Text: req.System}},
145+ }
146+ }
147+
148+ // Convert tool defs.
149+ if len(req.Tools) > 0 {
150+ decls := make([]gemFunctionDecl, 0, len(req.Tools))
151+ for _, t := range req.Tools {
152+ schema := t.Schema
153+ if len(schema) == 0 || !json.Valid(schema) {
154+ schema = json.RawMessage(`{"type":"object","properties":{}}`)
155+ }
156+ decls = append(decls, gemFunctionDecl{
157+ Name: t.Name,
158+ Description: t.Description,
159+ Parameters: schema,
160+ })
161+ }
162+ out.Tools = []gemTool{{FunctionDeclarations: decls}}
163+ }
164+
165+ // Generation config.
166+ maxTok := req.MaxTokens
167+ if maxTok <= 0 {
168+ maxTok = m.MaxOutput
169+ }
170+ gc := &gemGenerationConfig{Temperature: req.Temperature}
171+ if maxTok > 0 {
172+ gc.MaxOutputTokens = &maxTok
173+ }
174+ if req.Reasoning != "" && m.Reasoning {
175+ tc := geminiThinkingConfig(m.ID, req.Reasoning)
176+ if tc != nil {
177+ gc.ThinkingConfig = tc
178+ }
179+ }
180+ out.GenerationConfig = gc
181+
182+ // Convert messages.
183+ msgs := RepairOrphanedToolResults(req.Messages)
184+ for _, msg := range msgs {
185+ switch msg.Role {
186+ case RoleUser:
187+ parts := convertGemUserParts(msg.Content)
188+ if len(parts) == 0 {
189+ continue
190+ }
191+ out.Contents = append(out.Contents, gemContent{Role: "user", Parts: parts})
192+ case RoleAssistant:
193+ parts := convertGemAssistantParts(msg.Content)
194+ if len(parts) == 0 {
195+ continue
196+ }
197+ out.Contents = append(out.Contents, gemContent{Role: "model", Parts: parts})
198+ case RoleTool:
199+ // Each tool_result becomes a user-role message with a
200+ // functionResponse part. Gemini's protocol uses
201+ // "user" role for tool replies.
202+ parts := convertGemToolResultParts(msg.Content)
203+ if len(parts) == 0 {
204+ continue
205+ }
206+ out.Contents = append(out.Contents, gemContent{Role: "user", Parts: parts})
207+ }
208+ }
209+
210+ return out, m.ID, nil
211+}
212+
213+func convertGemUserParts(blocks []Content) []gemPart {
214+ var parts []gemPart
215+ for _, b := range blocks {
216+ switch v := b.(type) {
217+ case TextBlock:
218+ if v.Text == "" {
219+ continue
220+ }
221+ parts = append(parts, gemPart{Text: v.Text})
222+ case ImageBlock:
223+ parts = append(parts, gemPart{
224+ InlineData: &gemInlineData{
225+ MimeType: v.MimeType,
226+ Data: base64.StdEncoding.EncodeToString(v.Data),
227+ },
228+ })
229+ }
230+ }
231+ return parts
232+}
233+
234+func convertGemAssistantParts(blocks []Content) []gemPart {
235+ var parts []gemPart
236+ for _, b := range blocks {
237+ switch v := b.(type) {
238+ case TextBlock:
239+ if strings.TrimSpace(v.Text) == "" {
240+ continue
241+ }
242+ parts = append(parts, gemPart{Text: v.Text})
243+ case ToolCallBlock:
244+ args := v.Arguments
245+ if len(args) == 0 || !json.Valid(args) {
246+ args = json.RawMessage("{}")
247+ }
248+ parts = append(parts, gemPart{
249+ FunctionCall: &gemFunctionCall{
250+ Name: v.Name,
251+ Args: args,
252+ },
253+ })
254+ }
255+ }
256+ return parts
257+}
258+
259+func convertGemToolResultParts(blocks []Content) []gemPart {
260+ var parts []gemPart
261+ for _, b := range blocks {
262+ tr, ok := b.(ToolResultBlock)
263+ if !ok {
264+ continue
265+ }
266+ // Flatten text content. Image content in tool results is dropped
267+ // for Gemini < 3 (multimodal function responses are 3+); for the
268+ // common path (text output) we wrap as {"output": "..."} or
269+ // {"error": "..."} per the SDK's convention.
270+ var sb strings.Builder
271+ for _, c := range tr.Content {
272+ if tb, ok := c.(TextBlock); ok {
273+ if sb.Len() > 0 {
274+ sb.WriteString("\n")
275+ }
276+ sb.WriteString(tb.Text)
277+ }
278+ }
279+ key := "output"
280+ if tr.IsError {
281+ key = "error"
282+ }
283+ // Gemini wants response to be an object, not a string.
284+ respObj := map[string]string{key: sb.String()}
285+ respBytes, err := json.Marshal(respObj)
286+ if err != nil {
287+ respBytes = []byte(`{"output":""}`)
288+ }
289+ // Tool name is required on functionResponse; the original
290+ // ToolCallBlock has it but ToolResultBlock only carries the
291+ // call id. We thread the name back via the call id by using
292+ // the id as the name fallback — Gemini ignores the name on
293+ // the response side as long as it's non-empty.
294+ name := tr.CallID
295+ parts = append(parts, gemPart{
296+ FunctionResponse: &gemFunctionResponse{
297+ Name: name,
298+ Response: respBytes,
299+ },
300+ })
301+ }
302+ return parts
303+}
304+
305+// geminiThinkingConfig maps zot's reasoning level ("low"/"medium"/"high")
306+// to Gemini's thinkingConfig. The right knob depends on the model
307+// generation: 2.5 family uses thinkingBudget (tokens), 3.x uses
308+// thinkingLevel (enum). Returns nil when the level is unrecognised.
309+func geminiThinkingConfig(modelID, level string) *gemThinkingConfig {
310+ level = strings.ToLower(level)
311+ id := strings.ToLower(modelID)
312+
313+ // Gemini 3.x: enum-based thinkingLevel. Pro can't go below LOW.
314+ if strings.Contains(id, "gemini-3") {
315+ isPro := strings.Contains(id, "-pro")
316+ var lvl string
317+ switch level {
318+ case "low":
319+ lvl = "LOW"
320+ case "medium":
321+ if isPro {
322+ lvl = "HIGH"
323+ } else {
324+ lvl = "MEDIUM"
325+ }
326+ case "high":
327+ lvl = "HIGH"
328+ default:
329+ return nil
330+ }
331+ return &gemThinkingConfig{IncludeThoughts: true, ThinkingLevel: lvl}
332+ }
333+
334+ // Gemini 2.5 family: token-budget per-model.
335+ var budget int
336+ switch {
337+ case strings.Contains(id, "2.5-pro"):
338+ switch level {
339+ case "low":
340+ budget = 2048
341+ case "medium":
342+ budget = 8192
343+ case "high":
344+ budget = 32768
345+ }
346+ case strings.Contains(id, "2.5-flash-lite"):
347+ switch level {
348+ case "low":
349+ budget = 2048
350+ case "medium":
351+ budget = 8192
352+ case "high":
353+ budget = 24576
354+ }
355+ case strings.Contains(id, "2.5-flash"):
356+ switch level {
357+ case "low":
358+ budget = 2048
359+ case "medium":
360+ budget = 8192
361+ case "high":
362+ budget = 24576
363+ }
364+ default:
365+ return nil
366+ }
367+ if budget <= 0 {
368+ return nil
369+ }
370+ return &gemThinkingConfig{IncludeThoughts: true, ThinkingBudget: &budget}
371+}
372+
373+// ---- streaming ----
374+
375+func (c *geminiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
376+ wire, modelID, err := c.buildRequest(req)
377+ if err != nil {
378+ return nil, err
379+ }
380+ body, err := json.Marshal(wire)
381+ if err != nil {
382+ return nil, err
383+ }
384+
385+ // Gemini's streaming endpoint is a POST with ?alt=sse to get
386+ // an EventSource-compatible response. Without alt=sse the
387+ // server returns a JSON array (one element per chunk), which
388+ // we'd need a different parser for.
389+ apiPath := fmt.Sprintf("/v1beta/models/%s:streamGenerateContent", modelID)
390+ url := c.baseURL + apiPath + "?alt=sse"
391+
392+ newReq := func() (*http.Request, error) {
393+ httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
394+ if err != nil {
395+ return nil, err
396+ }
397+ httpReq.Header.Set("content-type", "application/json")
398+ httpReq.Header.Set("accept", "text/event-stream")
399+ // Two ways to authenticate against the Generative Language
400+ // API: x-goog-api-key header or ?key= query param. We use
401+ // the header so the key never lands in proxy access logs.
402+ httpReq.Header.Set("x-goog-api-key", c.apiKey)
403+ return httpReq, nil
404+ }
405+
406+ resp, err := doStreamWithRetry(ctx, c.http, newReq)
407+ if err != nil {
408+ return nil, fmt.Errorf("google: %w", err)
409+ }
410+ if resp.StatusCode != http.StatusOK {
411+ b, _ := io.ReadAll(resp.Body)
412+ resp.Body.Close()
413+ return nil, fmt.Errorf("google: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
414+ }
415+
416+ out := make(chan Event, 16)
417+ go c.runStream(ctx, resp, req, out)
418+ return out, nil
419+}
420+
421+func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
422+ defer close(out)
423+ defer resp.Body.Close()
424+
425+ model, _ := FindModel("google", req.Model)
426+ out <- EventStart{Model: req.Model, Provider: "google"}
427+
428+ raw := make(chan sseEvent, 16)
429+ go readSSE(resp.Body, raw)
430+
431+ // Gemini's SSE stream is a sequence of complete JSON
432+ // GenerateContentResponse objects, one per data: line. Each
433+ // candidate carries a list of parts (text or functionCall),
434+ // possibly accumulating across chunks.
435+ type blockEntry struct {
436+ kind string // "text" | "tool_use"
437+ textBuf strings.Builder
438+ toolID string
439+ toolName string
440+ toolArgs strings.Builder
441+ }
442+ var (
443+ blocks []*blockEntry
444+ currentText *blockEntry
445+ usage Usage
446+ stop StopReason = StopEnd
447+ finalErr error
448+ toolCounter int
449+ )
450+
451+ appendText := func(delta string) {
452+ if currentText == nil {
453+ currentText = &blockEntry{kind: "text"}
454+ blocks = append(blocks, currentText)
455+ }
456+ currentText.textBuf.WriteString(delta)
457+ }
458+
459+ startTool := func(name string, providedID string, args json.RawMessage) *blockEntry {
460+ toolCounter++
461+ id := providedID
462+ if id == "" {
463+ id = fmt.Sprintf("%s_%d_%d", name, time.Now().UnixNano(), toolCounter)
464+ }
465+ t := &blockEntry{
466+ kind: "tool_use",
467+ toolID: id,
468+ toolName: name,
469+ }
470+ if len(args) > 0 && json.Valid(args) {
471+ t.toolArgs.Write(args)
472+ }
473+ blocks = append(blocks, t)
474+ // New tool block breaks the current text run.
475+ currentText = nil
476+ return t
477+ }
478+
479+ assembleMsg := func() Message {
480+ content := []Content{}
481+ for _, b := range blocks {
482+ switch b.kind {
483+ case "text":
484+ if b.textBuf.Len() > 0 {
485+ content = append(content, TextBlock{Text: b.textBuf.String()})
486+ }
487+ case "tool_use":
488+ args := b.toolArgs.String()
489+ if args == "" || !json.Valid([]byte(args)) {
490+ args = "{}"
491+ }
492+ content = append(content, ToolCallBlock{
493+ ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args),
494+ })
495+ }
496+ }
497+ return Message{Role: RoleAssistant, Content: content, Time: time.Now()}
498+ }
499+
500+ sendDone := func() {
501+ usage.CostUSD = ComputeCost(model, usage)
502+ out <- EventUsage{Usage: usage}
503+ out <- EventDone{Stop: stop, Err: finalErr, Message: assembleMsg()}
504+ }
505+
506+ for {
507+ select {
508+ case <-ctx.Done():
509+ stop = StopAborted
510+ finalErr = ctx.Err()
511+ sendDone()
512+ return
513+ case ev, ok := <-raw:
514+ if !ok {
515+ sendDone()
516+ return
517+ }
518+ if strings.TrimSpace(ev.Data) == "" {
519+ continue
520+ }
521+ var chunk struct {
522+ Candidates []struct {
523+ Content struct {
524+ Role string `json:"role"`
525+ Parts []struct {
526+ Text string `json:"text"`
527+ Thought bool `json:"thought"`
528+ FunctionCall *gemFunctionCall `json:"functionCall"`
529+ FunctionCallID string `json:"id"`
530+ FunctionCallName string `json:"name"`
531+ } `json:"parts"`
532+ } `json:"content"`
533+ FinishReason string `json:"finishReason"`
534+ } `json:"candidates"`
535+ UsageMetadata *struct {
536+ PromptTokenCount int `json:"promptTokenCount"`
537+ CandidatesTokenCount int `json:"candidatesTokenCount"`
538+ ThoughtsTokenCount int `json:"thoughtsTokenCount"`
539+ CachedContentTokenCount int `json:"cachedContentTokenCount"`
540+ TotalTokenCount int `json:"totalTokenCount"`
541+ } `json:"usageMetadata"`
542+ Error *struct {
543+ Code int `json:"code"`
544+ Message string `json:"message"`
545+ Status string `json:"status"`
546+ } `json:"error"`
547+ }
548+ if err := json.Unmarshal([]byte(ev.Data), &chunk); err != nil {
549+ continue
550+ }
551+ if chunk.Error != nil {
552+ stop = StopError
553+ finalErr = fmt.Errorf("google: %s", chunk.Error.Message)
554+ sendDone()
555+ return
556+ }
557+ for _, cand := range chunk.Candidates {
558+ for _, part := range cand.Content.Parts {
559+ if part.FunctionCall != nil {
560+ var args json.RawMessage
561+ if len(part.FunctionCall.Args) > 0 {
562+ args = part.FunctionCall.Args
563+ } else {
564+ args = json.RawMessage("{}")
565+ }
566+ t := startTool(part.FunctionCall.Name, part.FunctionCallID, args)
567+ out <- EventToolStart{ID: t.toolID, Name: t.toolName}
568+ out <- EventToolArgs{ID: t.toolID, Delta: t.toolArgs.String()}
569+ out <- EventToolEnd{ID: t.toolID}
570+ continue
571+ }
572+ if part.Text == "" {
573+ continue
574+ }
575+ if part.Thought {
576+ // Thinking summaries arrive as text parts
577+ // with thought=true. Not surfaced to the
578+ // user in v1; could be exposed via a
579+ // future ReasoningBlock if useful.
580+ continue
581+ }
582+ appendText(part.Text)
583+ out <- EventTextDelta{Delta: part.Text}
584+ }
585+ switch cand.FinishReason {
586+ case "STOP", "":
587+ // "" arrives on intermediate chunks; only
588+ // promote the explicit terminal value.
589+ if cand.FinishReason == "STOP" {
590+ stop = StopEnd
591+ }
592+ case "MAX_TOKENS":
593+ stop = StopLength
594+ case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "IMAGE_SAFETY":
595+ stop = StopError
596+ finalErr = fmt.Errorf("google: response blocked (%s)", cand.FinishReason)
597+ }
598+ }
599+ if chunk.UsageMetadata != nil {
600+ // Gemini reports cumulative totals on every chunk
601+ // (or close to it), so assign rather than sum.
602+ um := chunk.UsageMetadata
603+ input := um.PromptTokenCount - um.CachedContentTokenCount
604+ if input < 0 {
605+ input = um.PromptTokenCount
606+ }
607+ usage.InputTokens = input
608+ usage.OutputTokens = um.CandidatesTokenCount + um.ThoughtsTokenCount
609+ usage.CacheReadTokens = um.CachedContentTokenCount
610+ }
611+ // Promote ToolUse stop when tool calls are present and
612+ // the candidate finished cleanly.
613+ if stop == StopEnd {
614+ for _, b := range blocks {
615+ if b.kind == "tool_use" {
616+ stop = StopToolUse
617+ break
618+ }
619+ }
620+ }
621+ }
622+ }
623+}
A internal/provider/gemini_test.go +252 -0
@@ -0,0 +1,252 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
10+ "testing"
11+)
12+
13+// TestGeminiStreamHappyPath drives the gemini client end-to-end against
14+// a fake SSE server speaking the Gemini Generative Language wire format.
15+// We assert text deltas accumulate, usage rolls in from usageMetadata,
16+// and the final EventDone carries StopEnd.
17+func TestGeminiStreamHappyPath(t *testing.T) {
18+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19+ if got := r.Header.Get("x-goog-api-key"); got != "k" {
20+ t.Errorf("missing api key header: %q", got)
21+ }
22+ if !strings.Contains(r.URL.RawQuery, "alt=sse") {
23+ t.Errorf("missing alt=sse: %q", r.URL.RawQuery)
24+ }
25+ w.Header().Set("content-type", "text/event-stream")
26+ w.WriteHeader(http.StatusOK)
27+ fl, _ := w.(http.Flusher)
28+ write := func(s string) {
29+ _, _ = w.Write([]byte(s))
30+ if fl != nil {
31+ fl.Flush()
32+ }
33+ }
34+ // Two text chunks, then a usage-only finish chunk.
35+ write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"text":"hel"}]}}]}` + "\n\n")
36+ write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"text":"lo"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":12,"candidatesTokenCount":2,"totalTokenCount":14}}` + "\n\n")
37+ }))
38+ defer srv.Close()
39+
40+ c := NewGemini("k", srv.URL)
41+ evs, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"})
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ var gotText string
46+ var done EventDone
47+ var usage Usage
48+ for ev := range evs {
49+ switch e := ev.(type) {
50+ case EventTextDelta:
51+ gotText += e.Delta
52+ case EventUsage:
53+ usage = e.Usage
54+ case EventDone:
55+ done = e
56+ }
57+ }
58+ if gotText != "hello" {
59+ t.Fatalf("text=%q", gotText)
60+ }
61+ if done.Stop != StopEnd {
62+ t.Fatalf("stop=%v", done.Stop)
63+ }
64+ if usage.InputTokens != 12 || usage.OutputTokens != 2 {
65+ t.Fatalf("usage=%+v", usage)
66+ }
67+}
68+
69+// TestGeminiToolCall covers the tool-call branch: a single
70+// functionCall part should produce ToolStart/Args/End and the final
71+// stop reason should be StopToolUse.
72+func TestGeminiToolCall(t *testing.T) {
73+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
74+ w.Header().Set("content-type", "text/event-stream")
75+ w.WriteHeader(http.StatusOK)
76+ fl, _ := w.(http.Flusher)
77+ write := func(s string) {
78+ _, _ = w.Write([]byte(s))
79+ if fl != nil {
80+ fl.Flush()
81+ }
82+ }
83+ write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"read","args":{"path":"a"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":7,"totalTokenCount":10}}` + "\n\n")
84+ }))
85+ defer srv.Close()
86+
87+ c := NewGemini("k", srv.URL)
88+ evs, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"})
89+ if err != nil {
90+ t.Fatal(err)
91+ }
92+ var startName, argsBuf string
93+ var done EventDone
94+ for ev := range evs {
95+ switch e := ev.(type) {
96+ case EventToolStart:
97+ startName = e.Name
98+ case EventToolArgs:
99+ argsBuf += e.Delta
100+ case EventDone:
101+ done = e
102+ }
103+ }
104+ if startName != "read" {
105+ t.Fatalf("tool name=%q", startName)
106+ }
107+ if !strings.Contains(argsBuf, `"path":"a"`) {
108+ t.Fatalf("tool args=%q", argsBuf)
109+ }
110+ if done.Stop != StopToolUse {
111+ t.Fatalf("stop=%v", done.Stop)
112+ }
113+ // The assembled message should contain the tool call as the first content block.
114+ if len(done.Message.Content) != 1 {
115+ t.Fatalf("message content count=%d", len(done.Message.Content))
116+ }
117+ tc, ok := done.Message.Content[0].(ToolCallBlock)
118+ if !ok {
119+ t.Fatalf("expected ToolCallBlock, got %T", done.Message.Content[0])
120+ }
121+ if tc.Name != "read" {
122+ t.Fatalf("tool block name=%q", tc.Name)
123+ }
124+}
125+
126+// TestGeminiBuildRequestSystemAndTools confirms the wire payload puts
127+// the system prompt under systemInstruction and tool defs under tools[0].
128+func TestGeminiBuildRequestSystemAndTools(t *testing.T) {
129+ c := NewGemini("k", "https://example.invalid").(*geminiClient)
130+ wire, _, err := c.buildRequest(Request{
131+ Model: "gemini-2.5-pro",
132+ System: "you are zot",
133+ Tools: []Tool{
134+ {Name: "read", Description: "read a file", Schema: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`)},
135+ },
136+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
137+ })
138+ if err != nil {
139+ t.Fatal(err)
140+ }
141+ if wire.SystemInstruction == nil || wire.SystemInstruction.Parts[0].Text != "you are zot" {
142+ t.Fatalf("system: %+v", wire.SystemInstruction)
143+ }
144+ if len(wire.Tools) != 1 || len(wire.Tools[0].FunctionDeclarations) != 1 || wire.Tools[0].FunctionDeclarations[0].Name != "read" {
145+ t.Fatalf("tools: %+v", wire.Tools)
146+ }
147+ if len(wire.Contents) != 1 || wire.Contents[0].Role != "user" {
148+ t.Fatalf("contents: %+v", wire.Contents)
149+ }
150+}
151+
152+// TestGeminiErrorStatus confirms HTTP error bodies bubble up.
153+func TestGeminiErrorStatus(t *testing.T) {
154+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
155+ w.WriteHeader(http.StatusUnauthorized)
156+ _, _ = io.WriteString(w, `{"error":{"code":401,"message":"bad key"}}`)
157+ }))
158+ defer srv.Close()
159+
160+ c := NewGemini("k", srv.URL)
161+ _, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"})
162+ if err == nil || !strings.Contains(err.Error(), "401") {
163+ t.Fatalf("want 401 err, got %v", err)
164+ }
165+}
166+
167+// TestGeminiThinkingConfig spot-checks the level → wire mapping for
168+// representative model families. The full table is exercised via
169+// integration; this guards the routing logic.
170+func TestGeminiThinkingConfig(t *testing.T) {
171+ cases := []struct {
172+ modelID string
173+ level string
174+ wantLvl string
175+ wantBud int
176+ }{
177+ {"gemini-3-pro", "low", "LOW", 0},
178+ {"gemini-3-pro", "medium", "HIGH", 0}, // Pro can't go below LOW; medium → HIGH
179+ {"gemini-3-flash", "medium", "MEDIUM", 0},
180+ {"gemini-2.5-pro", "high", "", 32768},
181+ {"gemini-2.5-flash", "low", "", 2048},
182+ {"gemini-2.0-flash", "high", "", 0}, // 2.0 has no thinking → nil
183+ }
184+ for _, tc := range cases {
185+ got := geminiThinkingConfig(tc.modelID, tc.level)
186+ if tc.wantLvl == "" && tc.wantBud == 0 {
187+ if got != nil {
188+ t.Errorf("%s/%s: want nil, got %+v", tc.modelID, tc.level, got)
189+ }
190+ continue
191+ }
192+ if got == nil {
193+ t.Errorf("%s/%s: got nil", tc.modelID, tc.level)
194+ continue
195+ }
196+ if tc.wantLvl != "" && got.ThinkingLevel != tc.wantLvl {
197+ t.Errorf("%s/%s: level=%q want %q", tc.modelID, tc.level, got.ThinkingLevel, tc.wantLvl)
198+ }
199+ if tc.wantBud != 0 {
200+ if got.ThinkingBudget == nil || *got.ThinkingBudget != tc.wantBud {
201+ t.Errorf("%s/%s: budget=%v want %d", tc.modelID, tc.level, got.ThinkingBudget, tc.wantBud)
202+ }
203+ }
204+ }
205+}
206+
207+// TestDiscoverGoogle exercises the discovery helper against a fake
208+// /v1beta/models endpoint, confirming pagination plus filtering of
209+// non-chat ids (embedding, aqa).
210+func TestDiscoverGoogle(t *testing.T) {
211+ calls := 0
212+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
213+ if r.Header.Get("x-goog-api-key") != "k" {
214+ t.Errorf("missing api key")
215+ }
216+ calls++
217+ if calls == 1 {
218+ _, _ = io.WriteString(w, `{
219+ "models": [
220+ {"name":"models/gemini-2.5-pro","displayName":"Gemini 2.5 Pro","supportedGenerationMethods":["generateContent","streamGenerateContent"],"inputTokenLimit":1048576,"outputTokenLimit":65536},
221+ {"name":"models/text-embedding-004","displayName":"Text Embedding","supportedGenerationMethods":["embedContent"]},
222+ {"name":"models/aqa","displayName":"AQA","supportedGenerationMethods":["generateAnswer"]}
223+ ],
224+ "nextPageToken": "p2"
225+ }`)
226+ } else {
227+ _, _ = io.WriteString(w, `{
228+ "models": [
229+ {"name":"models/gemini-2.5-flash","displayName":"Gemini 2.5 Flash","supportedGenerationMethods":["generateContent"]}
230+ ]
231+ }`)
232+ }
233+ }))
234+ defer srv.Close()
235+
236+ got, err := DiscoverGoogle(context.Background(), "k", srv.URL)
237+ if err != nil {
238+ t.Fatal(err)
239+ }
240+ if calls != 2 {
241+ t.Errorf("want 2 calls, got %d", calls)
242+ }
243+ if len(got) != 2 {
244+ t.Fatalf("want 2 models, got %d: %+v", len(got), got)
245+ }
246+ if got[0].ID != "gemini-2.5-pro" || got[0].Provider != "google" {
247+ t.Errorf("first model wrong: %+v", got[0])
248+ }
249+ if got[1].ID != "gemini-2.5-flash" {
250+ t.Errorf("second model wrong: %+v", got[1])
251+ }
252+}
@@ -0,0 +1,252 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
10+ "testing"
11+)
12+
13+// TestGeminiStreamHappyPath drives the gemini client end-to-end against
14+// a fake SSE server speaking the Gemini Generative Language wire format.
15+// We assert text deltas accumulate, usage rolls in from usageMetadata,
16+// and the final EventDone carries StopEnd.
17+func TestGeminiStreamHappyPath(t *testing.T) {
18+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19+ if got := r.Header.Get("x-goog-api-key"); got != "k" {
20+ t.Errorf("missing api key header: %q", got)
21+ }
22+ if !strings.Contains(r.URL.RawQuery, "alt=sse") {
23+ t.Errorf("missing alt=sse: %q", r.URL.RawQuery)
24+ }
25+ w.Header().Set("content-type", "text/event-stream")
26+ w.WriteHeader(http.StatusOK)
27+ fl, _ := w.(http.Flusher)
28+ write := func(s string) {
29+ _, _ = w.Write([]byte(s))
30+ if fl != nil {
31+ fl.Flush()
32+ }
33+ }
34+ // Two text chunks, then a usage-only finish chunk.
35+ write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"text":"hel"}]}}]}` + "\n\n")
36+ write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"text":"lo"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":12,"candidatesTokenCount":2,"totalTokenCount":14}}` + "\n\n")
37+ }))
38+ defer srv.Close()
39+
40+ c := NewGemini("k", srv.URL)
41+ evs, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"})
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ var gotText string
46+ var done EventDone
47+ var usage Usage
48+ for ev := range evs {
49+ switch e := ev.(type) {
50+ case EventTextDelta:
51+ gotText += e.Delta
52+ case EventUsage:
53+ usage = e.Usage
54+ case EventDone:
55+ done = e
56+ }
57+ }
58+ if gotText != "hello" {
59+ t.Fatalf("text=%q", gotText)
60+ }
61+ if done.Stop != StopEnd {
62+ t.Fatalf("stop=%v", done.Stop)
63+ }
64+ if usage.InputTokens != 12 || usage.OutputTokens != 2 {
65+ t.Fatalf("usage=%+v", usage)
66+ }
67+}
68+
69+// TestGeminiToolCall covers the tool-call branch: a single
70+// functionCall part should produce ToolStart/Args/End and the final
71+// stop reason should be StopToolUse.
72+func TestGeminiToolCall(t *testing.T) {
73+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
74+ w.Header().Set("content-type", "text/event-stream")
75+ w.WriteHeader(http.StatusOK)
76+ fl, _ := w.(http.Flusher)
77+ write := func(s string) {
78+ _, _ = w.Write([]byte(s))
79+ if fl != nil {
80+ fl.Flush()
81+ }
82+ }
83+ write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"read","args":{"path":"a"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":7,"totalTokenCount":10}}` + "\n\n")
84+ }))
85+ defer srv.Close()
86+
87+ c := NewGemini("k", srv.URL)
88+ evs, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"})
89+ if err != nil {
90+ t.Fatal(err)
91+ }
92+ var startName, argsBuf string
93+ var done EventDone
94+ for ev := range evs {
95+ switch e := ev.(type) {
96+ case EventToolStart:
97+ startName = e.Name
98+ case EventToolArgs:
99+ argsBuf += e.Delta
100+ case EventDone:
101+ done = e
102+ }
103+ }
104+ if startName != "read" {
105+ t.Fatalf("tool name=%q", startName)
106+ }
107+ if !strings.Contains(argsBuf, `"path":"a"`) {
108+ t.Fatalf("tool args=%q", argsBuf)
109+ }
110+ if done.Stop != StopToolUse {
111+ t.Fatalf("stop=%v", done.Stop)
112+ }
113+ // The assembled message should contain the tool call as the first content block.
114+ if len(done.Message.Content) != 1 {
115+ t.Fatalf("message content count=%d", len(done.Message.Content))
116+ }
117+ tc, ok := done.Message.Content[0].(ToolCallBlock)
118+ if !ok {
119+ t.Fatalf("expected ToolCallBlock, got %T", done.Message.Content[0])
120+ }
121+ if tc.Name != "read" {
122+ t.Fatalf("tool block name=%q", tc.Name)
123+ }
124+}
125+
126+// TestGeminiBuildRequestSystemAndTools confirms the wire payload puts
127+// the system prompt under systemInstruction and tool defs under tools[0].
128+func TestGeminiBuildRequestSystemAndTools(t *testing.T) {
129+ c := NewGemini("k", "https://example.invalid").(*geminiClient)
130+ wire, _, err := c.buildRequest(Request{
131+ Model: "gemini-2.5-pro",
132+ System: "you are zot",
133+ Tools: []Tool{
134+ {Name: "read", Description: "read a file", Schema: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`)},
135+ },
136+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
137+ })
138+ if err != nil {
139+ t.Fatal(err)
140+ }
141+ if wire.SystemInstruction == nil || wire.SystemInstruction.Parts[0].Text != "you are zot" {
142+ t.Fatalf("system: %+v", wire.SystemInstruction)
143+ }
144+ if len(wire.Tools) != 1 || len(wire.Tools[0].FunctionDeclarations) != 1 || wire.Tools[0].FunctionDeclarations[0].Name != "read" {
145+ t.Fatalf("tools: %+v", wire.Tools)
146+ }
147+ if len(wire.Contents) != 1 || wire.Contents[0].Role != "user" {
148+ t.Fatalf("contents: %+v", wire.Contents)
149+ }
150+}
151+
152+// TestGeminiErrorStatus confirms HTTP error bodies bubble up.
153+func TestGeminiErrorStatus(t *testing.T) {
154+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
155+ w.WriteHeader(http.StatusUnauthorized)
156+ _, _ = io.WriteString(w, `{"error":{"code":401,"message":"bad key"}}`)
157+ }))
158+ defer srv.Close()
159+
160+ c := NewGemini("k", srv.URL)
161+ _, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"})
162+ if err == nil || !strings.Contains(err.Error(), "401") {
163+ t.Fatalf("want 401 err, got %v", err)
164+ }
165+}
166+
167+// TestGeminiThinkingConfig spot-checks the level → wire mapping for
168+// representative model families. The full table is exercised via
169+// integration; this guards the routing logic.
170+func TestGeminiThinkingConfig(t *testing.T) {
171+ cases := []struct {
172+ modelID string
173+ level string
174+ wantLvl string
175+ wantBud int
176+ }{
177+ {"gemini-3-pro", "low", "LOW", 0},
178+ {"gemini-3-pro", "medium", "HIGH", 0}, // Pro can't go below LOW; medium → HIGH
179+ {"gemini-3-flash", "medium", "MEDIUM", 0},
180+ {"gemini-2.5-pro", "high", "", 32768},
181+ {"gemini-2.5-flash", "low", "", 2048},
182+ {"gemini-2.0-flash", "high", "", 0}, // 2.0 has no thinking → nil
183+ }
184+ for _, tc := range cases {
185+ got := geminiThinkingConfig(tc.modelID, tc.level)
186+ if tc.wantLvl == "" && tc.wantBud == 0 {
187+ if got != nil {
188+ t.Errorf("%s/%s: want nil, got %+v", tc.modelID, tc.level, got)
189+ }
190+ continue
191+ }
192+ if got == nil {
193+ t.Errorf("%s/%s: got nil", tc.modelID, tc.level)
194+ continue
195+ }
196+ if tc.wantLvl != "" && got.ThinkingLevel != tc.wantLvl {
197+ t.Errorf("%s/%s: level=%q want %q", tc.modelID, tc.level, got.ThinkingLevel, tc.wantLvl)
198+ }
199+ if tc.wantBud != 0 {
200+ if got.ThinkingBudget == nil || *got.ThinkingBudget != tc.wantBud {
201+ t.Errorf("%s/%s: budget=%v want %d", tc.modelID, tc.level, got.ThinkingBudget, tc.wantBud)
202+ }
203+ }
204+ }
205+}
206+
207+// TestDiscoverGoogle exercises the discovery helper against a fake
208+// /v1beta/models endpoint, confirming pagination plus filtering of
209+// non-chat ids (embedding, aqa).
210+func TestDiscoverGoogle(t *testing.T) {
211+ calls := 0
212+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
213+ if r.Header.Get("x-goog-api-key") != "k" {
214+ t.Errorf("missing api key")
215+ }
216+ calls++
217+ if calls == 1 {
218+ _, _ = io.WriteString(w, `{
219+ "models": [
220+ {"name":"models/gemini-2.5-pro","displayName":"Gemini 2.5 Pro","supportedGenerationMethods":["generateContent","streamGenerateContent"],"inputTokenLimit":1048576,"outputTokenLimit":65536},
221+ {"name":"models/text-embedding-004","displayName":"Text Embedding","supportedGenerationMethods":["embedContent"]},
222+ {"name":"models/aqa","displayName":"AQA","supportedGenerationMethods":["generateAnswer"]}
223+ ],
224+ "nextPageToken": "p2"
225+ }`)
226+ } else {
227+ _, _ = io.WriteString(w, `{
228+ "models": [
229+ {"name":"models/gemini-2.5-flash","displayName":"Gemini 2.5 Flash","supportedGenerationMethods":["generateContent"]}
230+ ]
231+ }`)
232+ }
233+ }))
234+ defer srv.Close()
235+
236+ got, err := DiscoverGoogle(context.Background(), "k", srv.URL)
237+ if err != nil {
238+ t.Fatal(err)
239+ }
240+ if calls != 2 {
241+ t.Errorf("want 2 calls, got %d", calls)
242+ }
243+ if len(got) != 2 {
244+ t.Fatalf("want 2 models, got %d: %+v", len(got), got)
245+ }
246+ if got[0].ID != "gemini-2.5-pro" || got[0].Provider != "google" {
247+ t.Errorf("first model wrong: %+v", got[0])
248+ }
249+ if got[1].ID != "gemini-2.5-flash" {
250+ t.Errorf("second model wrong: %+v", got[1])
251+ }
252+}
M internal/provider/models.go +27 -0
@@ -171,6 +171,33 @@ var Catalog = []Model{
171171 PriceInput: 15.00, PriceOutput: 60.00, PriceCacheRead: 7.50,
172172 },
173173
174+ // ---- Google / Gemini ----
175+ {
176+ Provider: "google", ID: "gemini-2.5-pro", DisplayName: "Gemini 2.5 Pro",
177+ ContextWindow: 1_048_576, MaxOutput: 65536, Reasoning: true,
178+ PriceInput: 1.25, PriceOutput: 10.00, PriceCacheRead: 0.31, PriceCacheWrite: 0,
179+ },
180+ {
181+ Provider: "google", ID: "gemini-2.5-flash", DisplayName: "Gemini 2.5 Flash",
182+ ContextWindow: 1_048_576, MaxOutput: 65536, Reasoning: true,
183+ PriceInput: 0.30, PriceOutput: 2.50, PriceCacheRead: 0.075, PriceCacheWrite: 0,
184+ },
185+ {
186+ Provider: "google", ID: "gemini-2.5-flash-lite", DisplayName: "Gemini 2.5 Flash-Lite",
187+ ContextWindow: 1_048_576, MaxOutput: 65536, Reasoning: true,
188+ PriceInput: 0.10, PriceOutput: 0.40, PriceCacheRead: 0.025, PriceCacheWrite: 0,
189+ },
190+ {
191+ Provider: "google", ID: "gemini-2.0-flash", DisplayName: "Gemini 2.0 Flash",
192+ ContextWindow: 1_048_576, MaxOutput: 8192, Reasoning: false,
193+ PriceInput: 0.10, PriceOutput: 0.40, PriceCacheRead: 0.025, PriceCacheWrite: 0,
194+ },
195+ {
196+ Provider: "google", ID: "gemini-2.0-flash-lite", DisplayName: "Gemini 2.0 Flash-Lite",
197+ ContextWindow: 1_048_576, MaxOutput: 8192, Reasoning: false,
198+ PriceInput: 0.075, PriceOutput: 0.30, PriceCacheRead: 0, PriceCacheWrite: 0,
199+ },
200+
174201 // ---- Speculative: Anthropic ----
175202 {
176203 Provider: "anthropic", ID: "claude-opus-4-5", DisplayName: "Claude Opus 4.5",
@@ -171,6 +171,33 @@ var Catalog = []Model{
171 PriceInput: 15.00, PriceOutput: 60.00, PriceCacheRead: 7.50,171 PriceInput: 15.00, PriceOutput: 60.00, PriceCacheRead: 7.50,
172 },172 },
173 173
174+ // ---- Google / Gemini ----
175+ {
176+ Provider: "google", ID: "gemini-2.5-pro", DisplayName: "Gemini 2.5 Pro",
177+ ContextWindow: 1_048_576, MaxOutput: 65536, Reasoning: true,
178+ PriceInput: 1.25, PriceOutput: 10.00, PriceCacheRead: 0.31, PriceCacheWrite: 0,
179+ },
180+ {
181+ Provider: "google", ID: "gemini-2.5-flash", DisplayName: "Gemini 2.5 Flash",
182+ ContextWindow: 1_048_576, MaxOutput: 65536, Reasoning: true,
183+ PriceInput: 0.30, PriceOutput: 2.50, PriceCacheRead: 0.075, PriceCacheWrite: 0,
184+ },
185+ {
186+ Provider: "google", ID: "gemini-2.5-flash-lite", DisplayName: "Gemini 2.5 Flash-Lite",
187+ ContextWindow: 1_048_576, MaxOutput: 65536, Reasoning: true,
188+ PriceInput: 0.10, PriceOutput: 0.40, PriceCacheRead: 0.025, PriceCacheWrite: 0,
189+ },
190+ {
191+ Provider: "google", ID: "gemini-2.0-flash", DisplayName: "Gemini 2.0 Flash",
192+ ContextWindow: 1_048_576, MaxOutput: 8192, Reasoning: false,
193+ PriceInput: 0.10, PriceOutput: 0.40, PriceCacheRead: 0.025, PriceCacheWrite: 0,
194+ },
195+ {
196+ Provider: "google", ID: "gemini-2.0-flash-lite", DisplayName: "Gemini 2.0 Flash-Lite",
197+ ContextWindow: 1_048_576, MaxOutput: 8192, Reasoning: false,
198+ PriceInput: 0.075, PriceOutput: 0.30, PriceCacheRead: 0, PriceCacheWrite: 0,
199+ },
200+
174 // ---- Speculative: Anthropic ----201 // ---- Speculative: Anthropic ----
175 {202 {
176 Provider: "anthropic", ID: "claude-opus-4-5", DisplayName: "Claude Opus 4.5",203 Provider: "anthropic", ID: "claude-opus-4-5", DisplayName: "Claude Opus 4.5",

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