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 @@ | |||
| 5 | 5 | Yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |
| 6 | 6 | ||
| 7 | 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 | 9 | - four tools (read, write, edit, bash). | |
| 10 | 10 | - three run modes (interactive tui, print, json). | |
| 11 | 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 | 61 | ### Credential lookup order | |
| 62 | 62 | ||
| 63 | 63 | 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 | |
| 65 | 65 | 3. `$ZOT_HOME/auth.json` (API key or OAuth token; mode 0600) | |
| 66 | 66 | ||
| 67 | 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 | 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. | |
| 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. | |
| 78 | 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 | 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 | 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 | 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 | 131 | | `--model <id>` | Pick the model (see `--list-models`). | | |
| 132 | 132 | | `--api-key <key>` | Override the API key. | | |
| 133 | 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 | 182 | | `/help` | Show key bindings and commands. | | |
| 183 | 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 | 185 | | `/model` | Pick a model from a list (or `/model <id>` to set directly). | | |
| 186 | 186 | | `/sessions` | Resume a previous session for this directory. | | |
| 187 | 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 | 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 | 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 | 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 | 368 | ### Local models with ollama | |
| 337 | 369 | ||
| 338 | 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. | |
| @@ -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 order | 61 | ### Credential lookup order | ||
| 62 | 62 | ||||
| 63 | 1. `--api-key` flag | 63 | 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 |
| 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 ollama | 368 | ### 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) { | |||
| 129 | 129 | ||
| 130 | 130 | // User-requested provider (explicit > config > default). | |
| 131 | 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 | 133 | // Unknown provider (maybe removed or renamed). Fall back to | |
| 134 | 134 | // the first provider that has credentials, or anthropic. | |
| 135 | 135 | provName = "anthropic" | |
| @@ -170,7 +170,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 170 | 170 | // never shows a "not logged in" banner. | |
| 171 | 171 | userPickedProvider := args.Provider != "" | |
| 172 | 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 | 174 | if other == provName { | |
| 175 | 175 | continue | |
| 176 | 176 | } | |
| @@ -189,6 +189,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 189 | 189 | model = "gpt-5" | |
| 190 | 190 | case "kimi": | |
| 191 | 191 | model = "kimi-for-coding" | |
| 192 | + | case "google": | |
| 193 | + | model = "gemini-2.5-pro" | |
| 192 | 194 | case "ollama": | |
| 193 | 195 | return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)") | |
| 194 | 196 | default: | |
| @@ -204,6 +206,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 204 | 206 | model = "gpt-5" | |
| 205 | 207 | case "kimi": | |
| 206 | 208 | model = "kimi-for-coding" | |
| 209 | + | case "google": | |
| 210 | + | model = "gemini-2.5-pro" | |
| 207 | 211 | default: | |
| 208 | 212 | model = provider.DefaultModel.ID | |
| 209 | 213 | } | |
| @@ -368,6 +372,9 @@ func (r Resolved) NewClient() provider.Client { | |||
| 368 | 372 | return r.wrapWithRefresh(inner) | |
| 369 | 373 | } | |
| 370 | 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 | 378 | default: | |
| 372 | 379 | if r.AuthMethod == "oauth" { | |
| 373 | 380 | inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL) | |
| @@ -514,6 +521,8 @@ func envVarName(provider string) string { | |||
| 514 | 521 | return "OPENAI" | |
| 515 | 522 | case "kimi": | |
| 516 | 523 | return "KIMI" | |
| 524 | + | case "google": | |
| 525 | + | return "GEMINI" | |
| 517 | 526 | case "ollama": | |
| 518 | 527 | return "OLLAMA" | |
| 519 | 528 | 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 to | 133 | // 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 | continue | 175 | 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.ID | 212 | 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 { | |||
| 721 | 721 | BuildAgentForRescue: buildAgentForRescue, | |
| 722 | 722 | LoggedInProviders: func() []string { | |
| 723 | 723 | var out []string | |
| 724 | - | for _, p := range []string{"anthropic", "openai", "kimi"} { | |
| 724 | + | for _, p := range []string{"anthropic", "openai", "kimi", "google"} { | |
| 725 | 725 | if _, _, err := ResolveCredential(p, ""); err == nil { | |
| 726 | 726 | out = append(out, p) | |
| 727 | 727 | } | |
| @@ -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 []string | 723 | 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 | |||
| 148 | 148 | if v := os.Getenv("MOONSHOT_API_KEY"); v != "" { | |
| 149 | 149 | 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 | 162 | c, err := AuthStoreFor().Load() | |
| 153 | 163 | if err != nil { | |
| @@ -185,6 +195,13 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||
| 185 | 195 | tok, _ = refreshIfExpired("kimi", tok) | |
| 186 | 196 | 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 | 206 | return "", "", "", fmt.Errorf("no credential for %s", provider) | |
| 190 | 207 | } | |
| @@ -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", "", nil | 149 | 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", "", nil | 196 | 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() { | |||
| 81 | 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 | 90 | if len(all) == 0 { | |
| 86 | 91 | 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 | return | 91 | return | ||
M internal/agent/modes/interactive.go +4 -4
| @@ -2460,7 +2460,7 @@ func (i *Interactive) openLogoutDialog() { | |||
| 2460 | 2460 | } | |
| 2461 | 2461 | ||
| 2462 | 2462 | var items []logoutItem | |
| 2463 | - | for _, p := range []string{"anthropic", "openai", "kimi"} { | |
| 2463 | + | for _, p := range []string{"anthropic", "openai", "kimi", "google"} { | |
| 2464 | 2464 | if creds.Has(p) { | |
| 2465 | 2465 | method := creds.Method(p) | |
| 2466 | 2466 | if method == "oauth" { | |
| @@ -2513,12 +2513,12 @@ func (i *Interactive) doLogout(target string) { | |||
| 2513 | 2513 | var providers []string | |
| 2514 | 2514 | switch target { | |
| 2515 | 2515 | 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": | |
| 2518 | 2518 | providers = []string{target} | |
| 2519 | 2519 | default: | |
| 2520 | 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 | 2522 | i.mu.Unlock() | |
| 2523 | 2523 | return | |
| 2524 | 2524 | } | |
| @@ -2460,7 +2460,7 @@ func (i *Interactive) openLogoutDialog() { | |||||
| 2460 | } | 2460 | } | ||
| 2461 | 2461 | ||||
| 2462 | var items []logoutItem | 2462 | 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 []string | 2513 | 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 | return | 2523 | return | ||
| 2524 | } | 2524 | } | ||
M internal/agent/modes/login_dialog.go +19 -7
| @@ -28,7 +28,7 @@ const ( | |||
| 28 | 28 | type loginDialog struct { | |
| 29 | 29 | step loginStep | |
| 30 | 30 | method string // "apikey" | "oauth" | |
| 31 | - | provider string // "anthropic" | "openai" | "kimi" | |
| 31 | + | provider string // "anthropic" | "openai" | "kimi" | "google" | |
| 32 | 32 | message string | |
| 33 | 33 | success bool | |
| 34 | 34 | url string | |
| @@ -39,7 +39,7 @@ type loginDialog struct { | |||
| 39 | 39 | // provider, captured when Open() runs. Rendered above the | |
| 40 | 40 | // method picker so the user can see whether they're already | |
| 41 | 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 | 43 | // (not logged in). | |
| 44 | 44 | status map[string]string | |
| 45 | 45 | } | |
| @@ -65,7 +65,7 @@ func (d *loginDialog) Open(zotHome string) { | |||
| 65 | 65 | d.success = false | |
| 66 | 66 | d.url = "" | |
| 67 | 67 | d.cursor = 0 | |
| 68 | - | d.status = map[string]string{"anthropic": "", "openai": "", "kimi": ""} | |
| 68 | + | d.status = map[string]string{"anthropic": "", "openai": "", "kimi": "", "google": ""} | |
| 69 | 69 | // Best-effort: if the auth file can't be read, treat every | |
| 70 | 70 | // provider as not-logged-in. The status line just won't show | |
| 71 | 71 | // anything useful in that case, which is fine — the user | |
| @@ -75,6 +75,7 @@ func (d *loginDialog) Open(zotHome string) { | |||
| 75 | 75 | d.status["anthropic"] = creds.Method("anthropic") | |
| 76 | 76 | d.status["openai"] = creds.Method("openai") | |
| 77 | 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 | 113 | lines = append(lines, frameRule(th, width)) | |
| 113 | 114 | case loginStepProvider: | |
| 114 | - | opts := []string{"anthropic", "openai", "kimi"} | |
| 115 | + | opts := []string{"anthropic", "openai", "kimi", "google"} | |
| 115 | 116 | lines = append(lines, frameHeader(th, "login - "+d.method, width)) | |
| 116 | 117 | for _, l := range d.renderStatusLines(th) { | |
| 117 | 118 | lines = append(lines, l) | |
| @@ -204,6 +205,8 @@ func providerLabel(id string) string { | |||
| 204 | 205 | return "OpenAI (ChatGPT Plus/Pro)" | |
| 205 | 206 | case "kimi": | |
| 206 | 207 | return "Kimi Code" | |
| 208 | + | case "google": | |
| 209 | + | return "Google (Gemini API key)" | |
| 207 | 210 | } | |
| 208 | 211 | return id | |
| 209 | 212 | } | |
| @@ -222,7 +225,8 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string { | |||
| 222 | 225 | anth := d.status["anthropic"] | |
| 223 | 226 | op := d.status["openai"] | |
| 224 | 227 | kimi := d.status["kimi"] | |
| 225 | - | if anth == "" && op == "" && kimi == "" { | |
| 228 | + | goog := d.status["google"] | |
| 229 | + | if anth == "" && op == "" && kimi == "" && goog == "" { | |
| 226 | 230 | return nil | |
| 227 | 231 | } | |
| 228 | 232 | row := func(id, method string) string { | |
| @@ -245,6 +249,7 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string { | |||
| 245 | 249 | row("anthropic", anth), | |
| 246 | 250 | row("openai", op), | |
| 247 | 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 | 315 | d.cursor-- | |
| 311 | 316 | } | |
| 312 | 317 | case tui.KeyDown: | |
| 313 | - | if d.cursor < 2 { | |
| 318 | + | if d.cursor < 3 { | |
| 314 | 319 | d.cursor++ | |
| 315 | 320 | } | |
| 316 | 321 | case tui.KeyEsc: | |
| 317 | 322 | d.Close() | |
| 318 | 323 | return loginDialogAction{Close: true} | |
| 319 | 324 | case tui.KeyEnter: | |
| 320 | - | providers := []string{"anthropic", "openai", "kimi"} | |
| 325 | + | providers := []string{"anthropic", "openai", "kimi", "google"} | |
| 321 | 326 | d.provider = providers[d.cursor] | |
| 322 | 327 | 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 | 335 | if d.method == "apikey" { | |
| 324 | 336 | return loginDialogAction{StartAPIKey: true, Provider: d.provider} | |
| 325 | 337 | } | |
| @@ -28,7 +28,7 @@ const ( | |||||
| 28 | type loginDialog struct { | 28 | type loginDialog struct { | ||
| 29 | step loginStep | 29 | 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 string | 32 | message string | ||
| 33 | success bool | 33 | success bool | ||
| 34 | url string | 34 | url string | ||
| @@ -39,7 +39,7 @@ type loginDialog struct { | |||||
| 39 | // provider, captured when Open() runs. Rendered above the | 39 | // provider, captured when Open() runs. Rendered above the | ||
| 40 | // method picker so the user can see whether they're already | 40 | // 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]string | 44 | status map[string]string | ||
| 45 | } | 45 | } | ||
| @@ -65,7 +65,7 @@ func (d *loginDialog) Open(zotHome string) { | |||||
| 65 | d.success = false | 65 | d.success = false | ||
| 66 | d.url = "" | 66 | d.url = "" | ||
| 67 | d.cursor = 0 | 67 | 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 every | 69 | // Best-effort: if the auth file can't be read, treat every | ||
| 70 | // provider as not-logged-in. The status line just won't show | 70 | // provider as not-logged-in. The status line just won't show | ||
| 71 | // anything useful in that case, which is fine — the user | 71 | // 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 id | 211 | 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 nil | 230 | 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 = loginStepWaiting | 327 | 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 { | |||
| 292 | 292 | if i := strings.Index(msg, ":"); i > 0 { | |
| 293 | 293 | head := strings.TrimSpace(msg[:i]) | |
| 294 | 294 | switch head { | |
| 295 | - | case "anthropic", "openai", "openai-codex", "kimi", "ollama": | |
| 295 | + | case "anthropic", "openai", "openai-codex", "kimi", "google", "ollama": | |
| 296 | 296 | return head | |
| 297 | 297 | } | |
| 298 | 298 | } | |
| @@ -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 head | 296 | return head | ||
| 297 | } | 297 | } | ||
| 298 | } | 298 | } | ||
M internal/auth/manager.go +13 -4
| @@ -78,8 +78,8 @@ func (m *Manager) Close() { | |||
| 78 | 78 | ||
| 79 | 79 | // StartAPIKey launches the API-key login flow. | |
| 80 | 80 | 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") | |
| 83 | 83 | } | |
| 84 | 84 | if err := m.ensureKeyServer(); err != nil { | |
| 85 | 85 | return "", err | |
| @@ -124,6 +124,11 @@ func (m *Manager) consumeKeyServerResults() { | |||
| 124 | 124 | // StartOAuth launches the subscription OAuth flow for provider. | |
| 125 | 125 | // Only one oauth flow may be in progress at a time (because the | |
| 126 | 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 | 132 | func (m *Manager) StartOAuth(provider string) (string, error) { | |
| 128 | 133 | if provider == "kimi" { | |
| 129 | 134 | return m.StartKimiDeviceOAuth() | |
| @@ -134,8 +139,10 @@ func (m *Manager) StartOAuth(provider string) (string, error) { | |||
| 134 | 139 | op = AnthropicOAuth | |
| 135 | 140 | case "openai": | |
| 136 | 141 | op = OpenAIOAuth | |
| 142 | + | case "google": | |
| 143 | + | return "", fmt.Errorf("google login is api-key only; use api key login for gemini") | |
| 137 | 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 | 148 | m.mu.Lock() | |
| @@ -255,8 +262,10 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||
| 255 | 262 | op = AnthropicManualOAuth | |
| 256 | 263 | case "openai": | |
| 257 | 264 | op = OpenAIOAuth | |
| 265 | + | case "google": | |
| 266 | + | return "", fmt.Errorf("google login is api-key only; use api key login for gemini") | |
| 258 | 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 | 271 | 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 "", err | 85 | 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 the | 125 | // 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 = AnthropicOAuth | 139 | op = AnthropicOAuth | ||
| 135 | case "openai": | 140 | case "openai": | ||
| 136 | op = OpenAIOAuth | 141 | 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 = AnthropicManualOAuth | 262 | op = AnthropicManualOAuth | ||
| 256 | case "openai": | 263 | case "openai": | ||
| 257 | op = OpenAIOAuth | 264 | 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 { | |||
| 37 | 37 | return err | |
| 38 | 38 | } | |
| 39 | 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 | 49 | default: | |
| 41 | 50 | return fmt.Errorf("unknown provider %q", provider) | |
| 42 | 51 | } | |
| @@ -37,6 +37,15 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error { | |||||
| 37 | return err | 37 | 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 { | |||
| 95 | 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 | 110 | // ---- handlers ---- | |
| 99 | 111 | ||
| 100 | 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 | 123 | switch r.Method { | |
| 112 | 124 | case http.MethodGet: | |
| 113 | 125 | 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) | |
| 116 | 128 | return | |
| 117 | 129 | } | |
| 118 | 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 | 235 | ` + logoTag + ` | |
| 224 | 236 | <h1><span class="zot">zot</span> login</h1> | |
| 225 | 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 | 239 | <p> | |
| 228 | 240 | <a href="/apikey?provider=anthropic">anthropic api key →</a><br> | |
| 229 | 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 | 244 | </p> | |
| 232 | 245 | <hr class="rule"> | |
| 233 | 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> | |
| @@ -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 | return | 128 | 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 { | |||
| 19 | 19 | Anthropic ProviderCreds `json:"anthropic,omitempty"` | |
| 20 | 20 | OpenAI ProviderCreds `json:"openai,omitempty"` | |
| 21 | 21 | Kimi ProviderCreds `json:"kimi,omitempty"` | |
| 22 | + | Google ProviderCreds `json:"google,omitempty"` | |
| 22 | 23 | } | |
| 23 | 24 | ||
| 24 | 25 | // ProviderCreds holds credentials for a single provider. Only one of | |
| @@ -83,6 +84,8 @@ func (c *Credentials) get(provider string) *ProviderCreds { | |||
| 83 | 84 | return &c.OpenAI | |
| 84 | 85 | case "kimi": | |
| 85 | 86 | return &c.Kimi | |
| 87 | + | case "google": | |
| 88 | + | return &c.Google | |
| 86 | 89 | } | |
| 87 | 90 | return nil | |
| 88 | 91 | } | |
| @@ -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 of | 25 | // ProviderCreds holds credentials for a single provider. Only one of | ||
| @@ -83,6 +84,8 @@ func (c *Credentials) get(provider string) *ProviderCreds { | |||||
| 83 | return &c.OpenAI | 84 | return &c.OpenAI | ||
| 84 | case "kimi": | 85 | case "kimi": | ||
| 85 | return &c.Kimi | 86 | return &c.Kimi | ||
| 87 | + | case "google": | |||
| 88 | + | return &c.Google | |||
| 86 | } | 89 | } | ||
| 87 | return nil | 90 | 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 | |||
| 112 | 112 | 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 | 209 | // looksLikeChatModel returns true for OpenAI ids that can plausibly be | |
| 116 | 210 | // used with the chat/completions endpoint. Errs on the side of inclusion. | |
| 117 | 211 | func looksLikeChatModel(id string) bool { | |
| @@ -112,6 +112,100 @@ func DiscoverOpenAI(ctx context.Context, apiKey, baseURL string) ([]Model, error | |||||
| 112 | return out, nil | 112 | 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 be | 209 | // 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{ | |||
| 171 | 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 | 201 | // ---- Speculative: Anthropic ---- | |
| 175 | 202 | { | |
| 176 | 203 | 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", | ||