add deepseek provider (api-key, openai-compatible v4 catalog)
- commit
- ce78a49b87bf889dfa385731bb04a148e177c425
- author
- patriceckhart · · 2026-05-10 14:49 UTC
- parent
- f4d678e
- signature
- unsigned
- refs
- tag: v0.1.73
13 files · +157 -36
M README.md +41 -8
| @@ -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, google gemini, and ollama/openai-compatible local models. | |
| 8 | + | - built-in providers for anthropic, openai/codex, kimi, deepseek, 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`, `MOONSHOT_API_KEY`, `GEMINI_API_KEY`, or `GOOGLE_API_KEY` env var | |
| 64 | + | 2. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `KIMI_API_KEY`, `MOONSHOT_API_KEY`, `DEEPSEEK_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-...`, 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. | |
| 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, DeepSeek (`sk-...`) 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. DeepSeek and Google Gemini do **not** have a subscription login path — the `/login subscription` step only lists Anthropic, OpenAI, and Kimi. For DeepSeek and Google use the API-key flow. | |
| 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\|google\|ollama` | Pick the provider. | | |
| 130 | + | | `--provider anthropic\|openai\|kimi\|deepseek\|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`, `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. | | |
| 184 | + | | `/logout [provider]` | Clear credentials for `anthropic`, `openai`, `kimi`, `deepseek`, `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`. | | |
| @@ -267,7 +267,7 @@ When a turn fails because of a recoverable provider error — expired token (`40 | |||
| 267 | 267 | ||
| 268 | 268 | 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 | 269 | ||
| 270 | - | 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. | |
| 270 | + | Before the actual provider request fires, the OpenAI / Anthropic / Kimi / DeepSeek / 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. | |
| 271 | 271 | ||
| 272 | 272 | 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 | 273 | ||
| @@ -297,7 +297,7 @@ Place a `models.json` in `$ZOT_HOME` (macOS: `~/Library/Application Support/zot/ | |||
| 297 | 297 | ||
| 298 | 298 | Supported fields per model: `id` (required), `name`, `reasoning`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`. | |
| 299 | 299 | ||
| 300 | - | Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`, and `moonshot`, `moonshot-ai`, and `kimi-code` map to `kimi`. | |
| 300 | + | Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`, `moonshot`, `moonshot-ai`, and `kimi-code` map to `kimi`, and `deepseek-chat` and `deepseek-ai` map to `deepseek`. | |
| 301 | 301 | ||
| 302 | 302 | User-defined models show `source: user` in `--list-models` and take precedence over both the baked-in catalog and live-discovered models. Missing or invalid files are silently ignored. | |
| 303 | 303 | ||
| @@ -332,6 +332,39 @@ zot --provider kimi --model kimi-k2-0905-preview --base-url https://api.moonshot | |||
| 332 | 332 | ||
| 333 | 333 | You can add additional Kimi/Moonshot model IDs to `models.json` under the `kimi` provider. | |
| 334 | 334 | ||
| 335 | + | ### DeepSeek | |
| 336 | + | ||
| 337 | + | zot has built-in DeepSeek support through DeepSeek's OpenAI-compatible chat API. | |
| 338 | + | ||
| 339 | + | ```bash | |
| 340 | + | zot --provider deepseek | |
| 341 | + | ``` | |
| 342 | + | ||
| 343 | + | By default this uses: | |
| 344 | + | ||
| 345 | + | - model: `deepseek-v4-pro` | |
| 346 | + | - base URL: `https://api.deepseek.com/v1` | |
| 347 | + | ||
| 348 | + | Catalog ships with `deepseek-v4-pro` (reasoning) and `deepseek-v4-flash`. These are exactly the IDs returned by `GET https://api.deepseek.com/models` today. You can add additional model IDs to `models.json` under the `deepseek` provider. | |
| 349 | + | ||
| 350 | + | Credential lookup order for DeepSeek: | |
| 351 | + | ||
| 352 | + | 1. `--api-key` | |
| 353 | + | 2. `DEEPSEEK_API_KEY` | |
| 354 | + | 3. `$ZOT_HOME/auth.json` | |
| 355 | + | ||
| 356 | + | Use `/login` and pick **api key** to paste a DeepSeek key. zot probes `/v1/models` once and stores the key under `deepseek` in `auth.json`. | |
| 357 | + | ||
| 358 | + | > **Auth model: API key only.** DeepSeek does not offer a subscription OAuth flow. The `/login subscription` step lists only Anthropic, OpenAI, and Kimi; DeepSeek shows up only under `/login → api key`. | |
| 359 | + | ||
| 360 | + | > **Text only at the wire level.** DeepSeek's chat-completions endpoint currently rejects the multimodal content schema (`unknown variant image_url, expected text`). When the active provider is `deepseek`, zot silently drops `ImageBlock` parts from outgoing user/tool messages and keeps only the text. Switching back to a vision-capable model (Claude, GPT-4o/5, Gemini) re-sends the image normally because the session file still stores it. | |
| 361 | + | ||
| 362 | + | For a custom-compatible endpoint (mirror, gateway, self-host): | |
| 363 | + | ||
| 364 | + | ```bash | |
| 365 | + | zot --provider deepseek --base-url https://my-deepseek-mirror.example.com/v1 --api-key "$DEEPSEEK_API_KEY" | |
| 366 | + | ``` | |
| 367 | + | ||
| 335 | 368 | ### Google Gemini | |
| 336 | 369 | ||
| 337 | 370 | zot has built-in Google Gemini support through the [AI Studio Generative Language API](https://aistudio.google.com/). | |
| @@ -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, google gemini, and ollama/openai-compatible local models. | 8 | + | - built-in providers for anthropic, openai/codex, kimi, deepseek, 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`, `MOONSHOT_API_KEY`, `GEMINI_API_KEY`, or `GOOGLE_API_KEY` env var | 64 | + | 2. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `KIMI_API_KEY`, `MOONSHOT_API_KEY`, `DEEPSEEK_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-...`, Kimi/Moonshot key, or Google AI Studio (`AIza...`) Gemini 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, DeepSeek (`sk-...`) 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. | 77 | + | - **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, or Kimi Code subscription. DeepSeek and Google Gemini do **not** have a subscription login path — the `/login subscription` step only lists Anthropic, OpenAI, and Kimi. For DeepSeek and Google use the API-key flow. |
| 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\|google\|ollama` | Pick the provider. | | 130 | + | | `--provider anthropic\|openai\|kimi\|deepseek\|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`, `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. | | 184 | + | | `/logout [provider]` | Clear credentials for `anthropic`, `openai`, `kimi`, `deepseek`, `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`. | | ||
| @@ -267,7 +267,7 @@ When a turn fails because of a recoverable provider error — expired token (`40 | |||||
| 267 | 267 | ||||
| 268 | 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. | 268 | 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 | 269 | ||||
| 270 | - | 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. | 270 | + | Before the actual provider request fires, the OpenAI / Anthropic / Kimi / DeepSeek / 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. |
| 271 | 271 | ||||
| 272 | 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. | 272 | 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 | 273 | ||||
| @@ -297,7 +297,7 @@ Place a `models.json` in `$ZOT_HOME` (macOS: `~/Library/Application Support/zot/ | |||||
| 297 | 297 | ||||
| 298 | Supported fields per model: `id` (required), `name`, `reasoning`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`. | 298 | Supported fields per model: `id` (required), `name`, `reasoning`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`. | ||
| 299 | 299 | ||||
| 300 | - | Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`, and `moonshot`, `moonshot-ai`, and `kimi-code` map to `kimi`. | 300 | + | Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`, `moonshot`, `moonshot-ai`, and `kimi-code` map to `kimi`, and `deepseek-chat` and `deepseek-ai` map to `deepseek`. |
| 301 | 301 | ||||
| 302 | User-defined models show `source: user` in `--list-models` and take precedence over both the baked-in catalog and live-discovered models. Missing or invalid files are silently ignored. | 302 | User-defined models show `source: user` in `--list-models` and take precedence over both the baked-in catalog and live-discovered models. Missing or invalid files are silently ignored. | ||
| 303 | 303 | ||||
| @@ -332,6 +332,39 @@ zot --provider kimi --model kimi-k2-0905-preview --base-url https://api.moonshot | |||||
| 332 | 332 | ||||
| 333 | You can add additional Kimi/Moonshot model IDs to `models.json` under the `kimi` provider. | 333 | You can add additional Kimi/Moonshot model IDs to `models.json` under the `kimi` provider. | ||
| 334 | 334 | ||||
| 335 | + | ### DeepSeek | |||
| 336 | + | ||||
| 337 | + | zot has built-in DeepSeek support through DeepSeek's OpenAI-compatible chat API. | |||
| 338 | + | ||||
| 339 | + | ```bash | |||
| 340 | + | zot --provider deepseek | |||
| 341 | + | ``` | |||
| 342 | + | ||||
| 343 | + | By default this uses: | |||
| 344 | + | ||||
| 345 | + | - model: `deepseek-v4-pro` | |||
| 346 | + | - base URL: `https://api.deepseek.com/v1` | |||
| 347 | + | ||||
| 348 | + | Catalog ships with `deepseek-v4-pro` (reasoning) and `deepseek-v4-flash`. These are exactly the IDs returned by `GET https://api.deepseek.com/models` today. You can add additional model IDs to `models.json` under the `deepseek` provider. | |||
| 349 | + | ||||
| 350 | + | Credential lookup order for DeepSeek: | |||
| 351 | + | ||||
| 352 | + | 1. `--api-key` | |||
| 353 | + | 2. `DEEPSEEK_API_KEY` | |||
| 354 | + | 3. `$ZOT_HOME/auth.json` | |||
| 355 | + | ||||
| 356 | + | Use `/login` and pick **api key** to paste a DeepSeek key. zot probes `/v1/models` once and stores the key under `deepseek` in `auth.json`. | |||
| 357 | + | ||||
| 358 | + | > **Auth model: API key only.** DeepSeek does not offer a subscription OAuth flow. The `/login subscription` step lists only Anthropic, OpenAI, and Kimi; DeepSeek shows up only under `/login → api key`. | |||
| 359 | + | ||||
| 360 | + | > **Text only at the wire level.** DeepSeek's chat-completions endpoint currently rejects the multimodal content schema (`unknown variant image_url, expected text`). When the active provider is `deepseek`, zot silently drops `ImageBlock` parts from outgoing user/tool messages and keeps only the text. Switching back to a vision-capable model (Claude, GPT-4o/5, Gemini) re-sends the image normally because the session file still stores it. | |||
| 361 | + | ||||
| 362 | + | For a custom-compatible endpoint (mirror, gateway, self-host): | |||
| 363 | + | ||||
| 364 | + | ```bash | |||
| 365 | + | zot --provider deepseek --base-url https://my-deepseek-mirror.example.com/v1 --api-key "$DEEPSEEK_API_KEY" | |||
| 366 | + | ``` | |||
| 367 | + | ||||
| 335 | ### Google Gemini | 368 | ### Google Gemini | ||
| 336 | 369 | ||||
| 337 | zot has built-in Google Gemini support through the [AI Studio Generative Language API](https://aistudio.google.com/). | 370 | zot has built-in Google Gemini support through the [AI Studio Generative Language API](https://aistudio.google.com/). | ||
M internal/agent/args.go +1 -1
| @@ -334,7 +334,7 @@ func PrintHelp(version string) { | |||
| 334 | 334 | row{"zot tg ...", "short alias for telegram-bot"}, | |
| 335 | 335 | ) | |
| 336 | 336 | section("provider and model flags", | |
| 337 | - | row{"--provider", "provider to use (anthropic|openai|kimi|ollama)"}, | |
| 337 | + | row{"--provider", "provider to use (anthropic|openai|kimi|deepseek|google|ollama)"}, | |
| 338 | 338 | row{"--model ID", "model id (see --list-models)"}, | |
| 339 | 339 | row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, | |
| 340 | 340 | row{"--base-url URL", "override provider api base url"}, | |
| @@ -334,7 +334,7 @@ func PrintHelp(version string) { | |||||
| 334 | row{"zot tg ...", "short alias for telegram-bot"}, | 334 | row{"zot tg ...", "short alias for telegram-bot"}, | ||
| 335 | ) | 335 | ) | ||
| 336 | section("provider and model flags", | 336 | section("provider and model flags", | ||
| 337 | - | row{"--provider", "provider to use (anthropic|openai|kimi|ollama)"}, | 337 | + | row{"--provider", "provider to use (anthropic|openai|kimi|deepseek|google|ollama)"}, |
| 338 | row{"--model ID", "model id (see --list-models)"}, | 338 | row{"--model ID", "model id (see --list-models)"}, | ||
| 339 | row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, | 339 | row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, | ||
| 340 | row{"--base-url URL", "override provider api base url"}, | 340 | row{"--base-url URL", "override provider api base url"}, | ||
M internal/agent/build.go +13 -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 != "google" && provName != "ollama" { | |
| 132 | + | if provName != "anthropic" && provName != "openai" && provName != "kimi" && provName != "deepseek" && 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" | |
| @@ -139,6 +139,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 139 | 139 | if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil { | |
| 140 | 140 | provName = "kimi" | |
| 141 | 141 | } | |
| 142 | + | if _, _, _, err := ResolveCredentialFull("deepseek", ""); err == nil { | |
| 143 | + | provName = "deepseek" | |
| 144 | + | } | |
| 142 | 145 | if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil { | |
| 143 | 146 | provName = "anthropic" | |
| 144 | 147 | } | |
| @@ -170,7 +173,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 170 | 173 | // never shows a "not logged in" banner. | |
| 171 | 174 | userPickedProvider := args.Provider != "" | |
| 172 | 175 | if credErr != nil && !userPickedProvider && provName != "ollama" { | |
| 173 | - | for _, other := range []string{"anthropic", "openai", "kimi", "google"} { | |
| 176 | + | for _, other := range []string{"anthropic", "openai", "kimi", "deepseek", "google"} { | |
| 174 | 177 | if other == provName { | |
| 175 | 178 | continue | |
| 176 | 179 | } | |
| @@ -189,6 +192,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 189 | 192 | model = "gpt-5" | |
| 190 | 193 | case "kimi": | |
| 191 | 194 | model = "kimi-for-coding" | |
| 195 | + | case "deepseek": | |
| 196 | + | model = "deepseek-v4-pro" | |
| 192 | 197 | case "google": | |
| 193 | 198 | model = "gemini-2.5-pro" | |
| 194 | 199 | case "ollama": | |
| @@ -206,6 +211,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 206 | 211 | model = "gpt-5" | |
| 207 | 212 | case "kimi": | |
| 208 | 213 | model = "kimi-for-coding" | |
| 214 | + | case "deepseek": | |
| 215 | + | model = "deepseek-v4-pro" | |
| 209 | 216 | case "google": | |
| 210 | 217 | model = "gemini-2.5-pro" | |
| 211 | 218 | default: | |
| @@ -446,6 +453,8 @@ func (r Resolved) NewClient() provider.Client { | |||
| 446 | 453 | return r.wrapWithRefresh(inner) | |
| 447 | 454 | } | |
| 448 | 455 | return inner | |
| 456 | + | case "deepseek": | |
| 457 | + | return provider.NewDeepSeek(r.Credential, r.BaseURL) | |
| 449 | 458 | case "openai": | |
| 450 | 459 | if r.AuthMethod == "oauth" { | |
| 451 | 460 | inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL) | |
| @@ -601,6 +610,8 @@ func envVarName(provider string) string { | |||
| 601 | 610 | return "OPENAI" | |
| 602 | 611 | case "kimi": | |
| 603 | 612 | return "KIMI" | |
| 613 | + | case "deepseek": | |
| 614 | + | return "DEEPSEEK" | |
| 604 | 615 | case "google": | |
| 605 | 616 | return "GEMINI" | |
| 606 | 617 | case "ollama": | |
| @@ -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 != "google" && provName != "ollama" { | 132 | + | if provName != "anthropic" && provName != "openai" && provName != "kimi" && provName != "deepseek" && 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" | ||
| @@ -139,6 +139,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 139 | if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil { | 139 | if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil { | ||
| 140 | provName = "kimi" | 140 | provName = "kimi" | ||
| 141 | } | 141 | } | ||
| 142 | + | if _, _, _, err := ResolveCredentialFull("deepseek", ""); err == nil { | |||
| 143 | + | provName = "deepseek" | |||
| 144 | + | } | |||
| 142 | if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil { | 145 | if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil { | ||
| 143 | provName = "anthropic" | 146 | provName = "anthropic" | ||
| 144 | } | 147 | } | ||
| @@ -170,7 +173,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 170 | // never shows a "not logged in" banner. | 173 | // never shows a "not logged in" banner. | ||
| 171 | userPickedProvider := args.Provider != "" | 174 | userPickedProvider := args.Provider != "" | ||
| 172 | if credErr != nil && !userPickedProvider && provName != "ollama" { | 175 | if credErr != nil && !userPickedProvider && provName != "ollama" { | ||
| 173 | - | for _, other := range []string{"anthropic", "openai", "kimi", "google"} { | 176 | + | for _, other := range []string{"anthropic", "openai", "kimi", "deepseek", "google"} { |
| 174 | if other == provName { | 177 | if other == provName { | ||
| 175 | continue | 178 | continue | ||
| 176 | } | 179 | } | ||
| @@ -189,6 +192,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 189 | model = "gpt-5" | 192 | model = "gpt-5" | ||
| 190 | case "kimi": | 193 | case "kimi": | ||
| 191 | model = "kimi-for-coding" | 194 | model = "kimi-for-coding" | ||
| 195 | + | case "deepseek": | |||
| 196 | + | model = "deepseek-v4-pro" | |||
| 192 | case "google": | 197 | case "google": | ||
| 193 | model = "gemini-2.5-pro" | 198 | model = "gemini-2.5-pro" | ||
| 194 | case "ollama": | 199 | case "ollama": | ||
| @@ -206,6 +211,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 206 | model = "gpt-5" | 211 | model = "gpt-5" | ||
| 207 | case "kimi": | 212 | case "kimi": | ||
| 208 | model = "kimi-for-coding" | 213 | model = "kimi-for-coding" | ||
| 214 | + | case "deepseek": | |||
| 215 | + | model = "deepseek-v4-pro" | |||
| 209 | case "google": | 216 | case "google": | ||
| 210 | model = "gemini-2.5-pro" | 217 | model = "gemini-2.5-pro" | ||
| 211 | default: | 218 | default: | ||
| @@ -446,6 +453,8 @@ func (r Resolved) NewClient() provider.Client { | |||||
| 446 | return r.wrapWithRefresh(inner) | 453 | return r.wrapWithRefresh(inner) | ||
| 447 | } | 454 | } | ||
| 448 | return inner | 455 | return inner | ||
| 456 | + | case "deepseek": | |||
| 457 | + | return provider.NewDeepSeek(r.Credential, r.BaseURL) | |||
| 449 | case "openai": | 458 | case "openai": | ||
| 450 | if r.AuthMethod == "oauth" { | 459 | if r.AuthMethod == "oauth" { | ||
| 451 | inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL) | 460 | inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL) | ||
| @@ -601,6 +610,8 @@ func envVarName(provider string) string { | |||||
| 601 | return "OPENAI" | 610 | return "OPENAI" | ||
| 602 | case "kimi": | 611 | case "kimi": | ||
| 603 | return "KIMI" | 612 | return "KIMI" | ||
| 613 | + | case "deepseek": | |||
| 614 | + | return "DEEPSEEK" | |||
| 604 | case "google": | 615 | case "google": | ||
| 605 | return "GEMINI" | 616 | return "GEMINI" | ||
| 606 | case "ollama": | 617 | case "ollama": | ||
M internal/agent/cli.go +1 -1
| @@ -732,7 +732,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 732 | 732 | BuildAgentForRescue: buildAgentForRescue, | |
| 733 | 733 | LoggedInProviders: func() []string { | |
| 734 | 734 | var out []string | |
| 735 | - | for _, p := range []string{"anthropic", "openai", "kimi", "google"} { | |
| 735 | + | for _, p := range []string{"anthropic", "openai", "kimi", "deepseek", "google"} { | |
| 736 | 736 | if _, _, err := ResolveCredential(p, ""); err == nil { | |
| 737 | 737 | out = append(out, p) | |
| 738 | 738 | } | |
| @@ -732,7 +732,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 732 | BuildAgentForRescue: buildAgentForRescue, | 732 | BuildAgentForRescue: buildAgentForRescue, | ||
| 733 | LoggedInProviders: func() []string { | 733 | LoggedInProviders: func() []string { | ||
| 734 | var out []string | 734 | var out []string | ||
| 735 | - | for _, p := range []string{"anthropic", "openai", "kimi", "google"} { | 735 | + | for _, p := range []string{"anthropic", "openai", "kimi", "deepseek", "google"} { |
| 736 | if _, _, err := ResolveCredential(p, ""); err == nil { | 736 | if _, _, err := ResolveCredential(p, ""); err == nil { | ||
| 737 | out = append(out, p) | 737 | out = append(out, p) | ||
| 738 | } | 738 | } | ||
M internal/agent/config.go +8 -0
| @@ -158,6 +158,10 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||
| 158 | 158 | if v := os.Getenv("GOOGLE_API_KEY"); v != "" { | |
| 159 | 159 | return v, "apikey", "", nil | |
| 160 | 160 | } | |
| 161 | + | case "deepseek": | |
| 162 | + | if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" { | |
| 163 | + | return v, "apikey", "", nil | |
| 164 | + | } | |
| 161 | 165 | } | |
| 162 | 166 | c, err := AuthStoreFor().Load() | |
| 163 | 167 | if err != nil { | |
| @@ -195,6 +199,10 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||
| 195 | 199 | tok, _ = refreshIfExpired("kimi", tok) | |
| 196 | 200 | return tok.AccessToken, "oauth", "", nil | |
| 197 | 201 | } | |
| 202 | + | case "deepseek": | |
| 203 | + | if c.DeepSeek.APIKey != "" { | |
| 204 | + | return c.DeepSeek.APIKey, "apikey", "", nil | |
| 205 | + | } | |
| 198 | 206 | case "google": | |
| 199 | 207 | // Google is API-key only — no OAuth path. We still load | |
| 200 | 208 | // auth.json so /login api-key flows work without exporting | |
| @@ -158,6 +158,10 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||||
| 158 | if v := os.Getenv("GOOGLE_API_KEY"); v != "" { | 158 | if v := os.Getenv("GOOGLE_API_KEY"); v != "" { | ||
| 159 | return v, "apikey", "", nil | 159 | return v, "apikey", "", nil | ||
| 160 | } | 160 | } | ||
| 161 | + | case "deepseek": | |||
| 162 | + | if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" { | |||
| 163 | + | return v, "apikey", "", nil | |||
| 164 | + | } | |||
| 161 | } | 165 | } | ||
| 162 | c, err := AuthStoreFor().Load() | 166 | c, err := AuthStoreFor().Load() | ||
| 163 | if err != nil { | 167 | if err != nil { | ||
| @@ -195,6 +199,10 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||||
| 195 | tok, _ = refreshIfExpired("kimi", tok) | 199 | tok, _ = refreshIfExpired("kimi", tok) | ||
| 196 | return tok.AccessToken, "oauth", "", nil | 200 | return tok.AccessToken, "oauth", "", nil | ||
| 197 | } | 201 | } | ||
| 202 | + | case "deepseek": | |||
| 203 | + | if c.DeepSeek.APIKey != "" { | |||
| 204 | + | return c.DeepSeek.APIKey, "apikey", "", nil | |||
| 205 | + | } | |||
| 198 | case "google": | 206 | case "google": | ||
| 199 | // Google is API-key only — no OAuth path. We still load | 207 | // Google is API-key only — no OAuth path. We still load | ||
| 200 | // auth.json so /login api-key flows work without exporting | 208 | // auth.json so /login api-key flows work without exporting | ||
M internal/agent/modes/login_dialog.go +27 -12
| @@ -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": "", "google": ""} | |
| 68 | + | d.status = map[string]string{"anthropic": "", "openai": "", "kimi": "", "deepseek": "", "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["deepseek"] = creds.Method("deepseek") | |
| 78 | 79 | d.status["google"] = creds.Method("google") | |
| 79 | 80 | } | |
| 80 | 81 | } | |
| @@ -112,7 +113,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 112 | 113 | } | |
| 113 | 114 | lines = append(lines, frameRule(th, width)) | |
| 114 | 115 | case loginStepProvider: | |
| 115 | - | opts := []string{"anthropic", "openai", "kimi", "google"} | |
| 116 | + | opts := providersForMethod(d.method) | |
| 116 | 117 | lines = append(lines, frameHeader(th, "login - "+d.method, width)) | |
| 117 | 118 | for _, l := range d.renderStatusLines(th) { | |
| 118 | 119 | lines = append(lines, l) | |
| @@ -196,6 +197,19 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 196 | 197 | return lines | |
| 197 | 198 | } | |
| 198 | 199 | ||
| 200 | + | // providersForMethod returns the providers offered for a given login | |
| 201 | + | // method. API-key is the universal path so it lists every provider; | |
| 202 | + | // subscription/OAuth only lists providers that actually issue tokens | |
| 203 | + | // usable against the same API the model picker drives (Google's | |
| 204 | + | // consumer Gemini Advanced login does not, and DeepSeek has no | |
| 205 | + | // subscription product at all). | |
| 206 | + | func providersForMethod(method string) []string { | |
| 207 | + | if method == "oauth" { | |
| 208 | + | return []string{"anthropic", "openai", "kimi"} | |
| 209 | + | } | |
| 210 | + | return []string{"anthropic", "openai", "kimi", "deepseek", "google"} | |
| 211 | + | } | |
| 212 | + | ||
| 199 | 213 | // providerLabel returns the user-facing label for a provider id. | |
| 200 | 214 | func providerLabel(id string) string { | |
| 201 | 215 | switch id { | |
| @@ -205,6 +219,8 @@ func providerLabel(id string) string { | |||
| 205 | 219 | return "OpenAI (ChatGPT Plus/Pro)" | |
| 206 | 220 | case "kimi": | |
| 207 | 221 | return "Kimi Code" | |
| 222 | + | case "deepseek": | |
| 223 | + | return "DeepSeek" | |
| 208 | 224 | case "google": | |
| 209 | 225 | return "Google (Gemini API key)" | |
| 210 | 226 | } | |
| @@ -225,8 +241,9 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string { | |||
| 225 | 241 | anth := d.status["anthropic"] | |
| 226 | 242 | op := d.status["openai"] | |
| 227 | 243 | kimi := d.status["kimi"] | |
| 244 | + | ds := d.status["deepseek"] | |
| 228 | 245 | goog := d.status["google"] | |
| 229 | - | if anth == "" && op == "" && kimi == "" && goog == "" { | |
| 246 | + | if anth == "" && op == "" && kimi == "" && ds == "" && goog == "" { | |
| 230 | 247 | return nil | |
| 231 | 248 | } | |
| 232 | 249 | row := func(id, method string) string { | |
| @@ -249,6 +266,7 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string { | |||
| 249 | 266 | row("anthropic", anth), | |
| 250 | 267 | row("openai", op), | |
| 251 | 268 | row("kimi", kimi), | |
| 269 | + | row("deepseek", ds), | |
| 252 | 270 | row("google", goog), | |
| 253 | 271 | "", | |
| 254 | 272 | } | |
| @@ -315,23 +333,20 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||
| 315 | 333 | d.cursor-- | |
| 316 | 334 | } | |
| 317 | 335 | case tui.KeyDown: | |
| 318 | - | if d.cursor < 3 { | |
| 336 | + | providers := providersForMethod(d.method) | |
| 337 | + | if d.cursor < len(providers)-1 { | |
| 319 | 338 | d.cursor++ | |
| 320 | 339 | } | |
| 321 | 340 | case tui.KeyEsc: | |
| 322 | 341 | d.Close() | |
| 323 | 342 | return loginDialogAction{Close: true} | |
| 324 | 343 | case tui.KeyEnter: | |
| 325 | - | providers := []string{"anthropic", "openai", "kimi", "google"} | |
| 344 | + | providers := providersForMethod(d.method) | |
| 345 | + | if d.cursor < 0 || d.cursor >= len(providers) { | |
| 346 | + | return loginDialogAction{} | |
| 347 | + | } | |
| 326 | 348 | d.provider = providers[d.cursor] | |
| 327 | 349 | 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 | - | } | |
| 335 | 350 | if d.method == "apikey" { | |
| 336 | 351 | return loginDialogAction{StartAPIKey: true, Provider: d.provider} | |
| 337 | 352 | } | |
| @@ -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": "", "google": ""} | 68 | + | d.status = map[string]string{"anthropic": "", "openai": "", "kimi": "", "deepseek": "", "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["deepseek"] = creds.Method("deepseek") | |||
| 78 | d.status["google"] = creds.Method("google") | 79 | d.status["google"] = creds.Method("google") | ||
| 79 | } | 80 | } | ||
| 80 | } | 81 | } | ||
| @@ -112,7 +113,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||||
| 112 | } | 113 | } | ||
| 113 | lines = append(lines, frameRule(th, width)) | 114 | lines = append(lines, frameRule(th, width)) | ||
| 114 | case loginStepProvider: | 115 | case loginStepProvider: | ||
| 115 | - | opts := []string{"anthropic", "openai", "kimi", "google"} | 116 | + | opts := providersForMethod(d.method) |
| 116 | lines = append(lines, frameHeader(th, "login - "+d.method, width)) | 117 | lines = append(lines, frameHeader(th, "login - "+d.method, width)) | ||
| 117 | for _, l := range d.renderStatusLines(th) { | 118 | for _, l := range d.renderStatusLines(th) { | ||
| 118 | lines = append(lines, l) | 119 | lines = append(lines, l) | ||
| @@ -196,6 +197,19 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||||
| 196 | return lines | 197 | return lines | ||
| 197 | } | 198 | } | ||
| 198 | 199 | ||||
| 200 | + | // providersForMethod returns the providers offered for a given login | |||
| 201 | + | // method. API-key is the universal path so it lists every provider; | |||
| 202 | + | // subscription/OAuth only lists providers that actually issue tokens | |||
| 203 | + | // usable against the same API the model picker drives (Google's | |||
| 204 | + | // consumer Gemini Advanced login does not, and DeepSeek has no | |||
| 205 | + | // subscription product at all). | |||
| 206 | + | func providersForMethod(method string) []string { | |||
| 207 | + | if method == "oauth" { | |||
| 208 | + | return []string{"anthropic", "openai", "kimi"} | |||
| 209 | + | } | |||
| 210 | + | return []string{"anthropic", "openai", "kimi", "deepseek", "google"} | |||
| 211 | + | } | |||
| 212 | + | ||||
| 199 | // providerLabel returns the user-facing label for a provider id. | 213 | // providerLabel returns the user-facing label for a provider id. | ||
| 200 | func providerLabel(id string) string { | 214 | func providerLabel(id string) string { | ||
| 201 | switch id { | 215 | switch id { | ||
| @@ -205,6 +219,8 @@ func providerLabel(id string) string { | |||||
| 205 | return "OpenAI (ChatGPT Plus/Pro)" | 219 | return "OpenAI (ChatGPT Plus/Pro)" | ||
| 206 | case "kimi": | 220 | case "kimi": | ||
| 207 | return "Kimi Code" | 221 | return "Kimi Code" | ||
| 222 | + | case "deepseek": | |||
| 223 | + | return "DeepSeek" | |||
| 208 | case "google": | 224 | case "google": | ||
| 209 | return "Google (Gemini API key)" | 225 | return "Google (Gemini API key)" | ||
| 210 | } | 226 | } | ||
| @@ -225,8 +241,9 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string { | |||||
| 225 | anth := d.status["anthropic"] | 241 | anth := d.status["anthropic"] | ||
| 226 | op := d.status["openai"] | 242 | op := d.status["openai"] | ||
| 227 | kimi := d.status["kimi"] | 243 | kimi := d.status["kimi"] | ||
| 244 | + | ds := d.status["deepseek"] | |||
| 228 | goog := d.status["google"] | 245 | goog := d.status["google"] | ||
| 229 | - | if anth == "" && op == "" && kimi == "" && goog == "" { | 246 | + | if anth == "" && op == "" && kimi == "" && ds == "" && goog == "" { |
| 230 | return nil | 247 | return nil | ||
| 231 | } | 248 | } | ||
| 232 | row := func(id, method string) string { | 249 | row := func(id, method string) string { | ||
| @@ -249,6 +266,7 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string { | |||||
| 249 | row("anthropic", anth), | 266 | row("anthropic", anth), | ||
| 250 | row("openai", op), | 267 | row("openai", op), | ||
| 251 | row("kimi", kimi), | 268 | row("kimi", kimi), | ||
| 269 | + | row("deepseek", ds), | |||
| 252 | row("google", goog), | 270 | row("google", goog), | ||
| 253 | "", | 271 | "", | ||
| 254 | } | 272 | } | ||
| @@ -315,23 +333,20 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||||
| 315 | d.cursor-- | 333 | d.cursor-- | ||
| 316 | } | 334 | } | ||
| 317 | case tui.KeyDown: | 335 | case tui.KeyDown: | ||
| 318 | - | if d.cursor < 3 { | 336 | + | providers := providersForMethod(d.method) |
| 337 | + | if d.cursor < len(providers)-1 { | |||
| 319 | d.cursor++ | 338 | d.cursor++ | ||
| 320 | } | 339 | } | ||
| 321 | case tui.KeyEsc: | 340 | case tui.KeyEsc: | ||
| 322 | d.Close() | 341 | d.Close() | ||
| 323 | return loginDialogAction{Close: true} | 342 | return loginDialogAction{Close: true} | ||
| 324 | case tui.KeyEnter: | 343 | case tui.KeyEnter: | ||
| 325 | - | providers := []string{"anthropic", "openai", "kimi", "google"} | 344 | + | providers := providersForMethod(d.method) |
| 345 | + | if d.cursor < 0 || d.cursor >= len(providers) { | |||
| 346 | + | return loginDialogAction{} | |||
| 347 | + | } | |||
| 326 | d.provider = providers[d.cursor] | 348 | d.provider = providers[d.cursor] | ||
| 327 | d.step = loginStepWaiting | 349 | 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 | - | } | |||
| 335 | if d.method == "apikey" { | 350 | if d.method == "apikey" { | ||
| 336 | return loginDialogAction{StartAPIKey: true, Provider: d.provider} | 351 | return loginDialogAction{StartAPIKey: true, Provider: d.provider} | ||
| 337 | } | 352 | } | ||
M internal/auth/manager.go +7 -3
| @@ -79,7 +79,7 @@ func (m *Manager) Close() { | |||
| 79 | 79 | // StartAPIKey launches the API-key login flow. | |
| 80 | 80 | func (m *Manager) StartAPIKey(provider string) (string, error) { | |
| 81 | 81 | if !isKnownAPIKeyProvider(provider) { | |
| 82 | - | return "", fmt.Errorf("provider must be anthropic, openai, kimi, or google") | |
| 82 | + | return "", fmt.Errorf("provider must be anthropic, openai, kimi, deepseek, or google") | |
| 83 | 83 | } | |
| 84 | 84 | if err := m.ensureKeyServer(); err != nil { | |
| 85 | 85 | return "", err | |
| @@ -141,8 +141,10 @@ func (m *Manager) StartOAuth(provider string) (string, error) { | |||
| 141 | 141 | op = OpenAIOAuth | |
| 142 | 142 | case "google": | |
| 143 | 143 | return "", fmt.Errorf("google login is api-key only; use api key login for gemini") | |
| 144 | + | case "deepseek": | |
| 145 | + | return "", fmt.Errorf("deepseek login is api-key only; use api key login") | |
| 144 | 146 | default: | |
| 145 | - | return "", fmt.Errorf("provider must be anthropic, openai, kimi, or google") | |
| 147 | + | return "", fmt.Errorf("provider must be anthropic, openai, kimi, deepseek, or google") | |
| 146 | 148 | } | |
| 147 | 149 | ||
| 148 | 150 | m.mu.Lock() | |
| @@ -264,8 +266,10 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||
| 264 | 266 | op = OpenAIOAuth | |
| 265 | 267 | case "google": | |
| 266 | 268 | return "", fmt.Errorf("google login is api-key only; use api key login for gemini") | |
| 269 | + | case "deepseek": | |
| 270 | + | return "", fmt.Errorf("deepseek login is api-key only; use api key login") | |
| 267 | 271 | default: | |
| 268 | - | return "", fmt.Errorf("provider must be anthropic, openai, kimi, or google") | |
| 272 | + | return "", fmt.Errorf("provider must be anthropic, openai, kimi, deepseek, or google") | |
| 269 | 273 | } | |
| 270 | 274 | ||
| 271 | 275 | pkce, err := NewPKCE() | |
| @@ -79,7 +79,7 @@ func (m *Manager) Close() { | |||||
| 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 !isKnownAPIKeyProvider(provider) { | 81 | if !isKnownAPIKeyProvider(provider) { | ||
| 82 | - | return "", fmt.Errorf("provider must be anthropic, openai, kimi, or google") | 82 | + | return "", fmt.Errorf("provider must be anthropic, openai, kimi, deepseek, or google") |
| 83 | } | 83 | } | ||
| 84 | if err := m.ensureKeyServer(); err != nil { | 84 | if err := m.ensureKeyServer(); err != nil { | ||
| 85 | return "", err | 85 | return "", err | ||
| @@ -141,8 +141,10 @@ func (m *Manager) StartOAuth(provider string) (string, error) { | |||||
| 141 | op = OpenAIOAuth | 141 | op = OpenAIOAuth | ||
| 142 | case "google": | 142 | case "google": | ||
| 143 | return "", fmt.Errorf("google login is api-key only; use api key login for gemini") | 143 | return "", fmt.Errorf("google login is api-key only; use api key login for gemini") | ||
| 144 | + | case "deepseek": | |||
| 145 | + | return "", fmt.Errorf("deepseek login is api-key only; use api key login") | |||
| 144 | default: | 146 | default: | ||
| 145 | - | return "", fmt.Errorf("provider must be anthropic, openai, kimi, or google") | 147 | + | return "", fmt.Errorf("provider must be anthropic, openai, kimi, deepseek, or google") |
| 146 | } | 148 | } | ||
| 147 | 149 | ||||
| 148 | m.mu.Lock() | 150 | m.mu.Lock() | ||
| @@ -264,8 +266,10 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||||
| 264 | op = OpenAIOAuth | 266 | op = OpenAIOAuth | ||
| 265 | case "google": | 267 | case "google": | ||
| 266 | return "", fmt.Errorf("google login is api-key only; use api key login for gemini") | 268 | return "", fmt.Errorf("google login is api-key only; use api key login for gemini") | ||
| 269 | + | case "deepseek": | |||
| 270 | + | return "", fmt.Errorf("deepseek login is api-key only; use api key login") | |||
| 267 | default: | 271 | default: | ||
| 268 | - | return "", fmt.Errorf("provider must be anthropic, openai, kimi, or google") | 272 | + | return "", fmt.Errorf("provider must be anthropic, openai, kimi, deepseek, or google") |
| 269 | } | 273 | } | ||
| 270 | 274 | ||||
| 271 | pkce, err := NewPKCE() | 275 | pkce, err := NewPKCE() | ||
M internal/auth/probe.go +6 -0
| @@ -37,6 +37,12 @@ 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 "deepseek": | |
| 41 | + | req, err = http.NewRequestWithContext(ctx, "GET", "https://api.deepseek.com/v1/models", nil) | |
| 42 | + | if err != nil { | |
| 43 | + | return err | |
| 44 | + | } | |
| 45 | + | req.Header.Set("authorization", "Bearer "+key) | |
| 40 | 46 | case "google": | |
| 41 | 47 | // Google Generative Language: list models with the API key. | |
| 42 | 48 | // Accepts the key via x-goog-api-key header (preferred over | |
| @@ -37,6 +37,12 @@ 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 "deepseek": | |||
| 41 | + | req, err = http.NewRequestWithContext(ctx, "GET", "https://api.deepseek.com/v1/models", nil) | |||
| 42 | + | if err != nil { | |||
| 43 | + | return err | |||
| 44 | + | } | |||
| 45 | + | req.Header.Set("authorization", "Bearer "+key) | |||
| 40 | case "google": | 46 | case "google": | ||
| 41 | // Google Generative Language: list models with the API key. | 47 | // Google Generative Language: list models with the API key. | ||
| 42 | // Accepts the key via x-goog-api-key header (preferred over | 48 | // Accepts the key via x-goog-api-key header (preferred over | ||
M internal/auth/server.go +4 -3
| @@ -101,7 +101,7 @@ func (s *Server) Shutdown(ctx context.Context) error { | |||
| 101 | 101 | // elsewhere (manager.StartOAuth). | |
| 102 | 102 | func isKnownAPIKeyProvider(p string) bool { | |
| 103 | 103 | switch p { | |
| 104 | - | case "anthropic", "openai", "kimi", "google": | |
| 104 | + | case "anthropic", "openai", "kimi", "google", "deepseek": | |
| 105 | 105 | return true | |
| 106 | 106 | } | |
| 107 | 107 | return false | |
| @@ -124,7 +124,7 @@ func (s *Server) handleAPIKey(w http.ResponseWriter, r *http.Request) { | |||
| 124 | 124 | case http.MethodGet: | |
| 125 | 125 | provider := r.URL.Query().Get("provider") | |
| 126 | 126 | if !isKnownAPIKeyProvider(provider) { | |
| 127 | - | http.Error(w, "provider must be anthropic, openai, kimi, or google", http.StatusBadRequest) | |
| 127 | + | http.Error(w, "provider must be anthropic, openai, kimi, deepseek, or google", http.StatusBadRequest) | |
| 128 | 128 | return | |
| 129 | 129 | } | |
| 130 | 130 | tpl.ExecuteTemplate(w, "apikey", map[string]any{"Provider": provider}) | |
| @@ -235,11 +235,12 @@ var tpl = template.Must(template.New("index").Parse(`<!doctype html><html lang=" | |||
| 235 | 235 | ` + logoTag + ` | |
| 236 | 236 | <h1><span class="zot">zot</span> login</h1> | |
| 237 | 237 | <hr class="rule"> | |
| 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> | |
| 238 | + | <p>paste an api key for anthropic, openai, kimi, deepseek, 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> | |
| 239 | 239 | <p> | |
| 240 | 240 | <a href="/apikey?provider=anthropic">anthropic api key →</a><br> | |
| 241 | 241 | <a href="/apikey?provider=openai">openai api key →</a><br> | |
| 242 | 242 | <a href="/apikey?provider=kimi">kimi api key →</a><br> | |
| 243 | + | <a href="/apikey?provider=deepseek">deepseek api key →</a><br> | |
| 243 | 244 | <a href="/apikey?provider=google">google gemini api key →</a> | |
| 244 | 245 | </p> | |
| 245 | 246 | <hr class="rule"> | |
| @@ -101,7 +101,7 @@ func (s *Server) Shutdown(ctx context.Context) error { | |||||
| 101 | // elsewhere (manager.StartOAuth). | 101 | // elsewhere (manager.StartOAuth). | ||
| 102 | func isKnownAPIKeyProvider(p string) bool { | 102 | func isKnownAPIKeyProvider(p string) bool { | ||
| 103 | switch p { | 103 | switch p { | ||
| 104 | - | case "anthropic", "openai", "kimi", "google": | 104 | + | case "anthropic", "openai", "kimi", "google", "deepseek": |
| 105 | return true | 105 | return true | ||
| 106 | } | 106 | } | ||
| 107 | return false | 107 | return false | ||
| @@ -124,7 +124,7 @@ func (s *Server) handleAPIKey(w http.ResponseWriter, r *http.Request) { | |||||
| 124 | case http.MethodGet: | 124 | case http.MethodGet: | ||
| 125 | provider := r.URL.Query().Get("provider") | 125 | provider := r.URL.Query().Get("provider") | ||
| 126 | if !isKnownAPIKeyProvider(provider) { | 126 | if !isKnownAPIKeyProvider(provider) { | ||
| 127 | - | http.Error(w, "provider must be anthropic, openai, kimi, or google", http.StatusBadRequest) | 127 | + | http.Error(w, "provider must be anthropic, openai, kimi, deepseek, or google", http.StatusBadRequest) |
| 128 | return | 128 | return | ||
| 129 | } | 129 | } | ||
| 130 | tpl.ExecuteTemplate(w, "apikey", map[string]any{"Provider": provider}) | 130 | tpl.ExecuteTemplate(w, "apikey", map[string]any{"Provider": provider}) | ||
| @@ -235,11 +235,12 @@ var tpl = template.Must(template.New("index").Parse(`<!doctype html><html lang=" | |||||
| 235 | ` + logoTag + ` | 235 | ` + logoTag + ` | ||
| 236 | <h1><span class="zot">zot</span> login</h1> | 236 | <h1><span class="zot">zot</span> login</h1> | ||
| 237 | <hr class="rule"> | 237 | <hr class="rule"> | ||
| 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> | 238 | + | <p>paste an api key for anthropic, openai, kimi, deepseek, 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> |
| 239 | <p> | 239 | <p> | ||
| 240 | <a href="/apikey?provider=anthropic">anthropic api key →</a><br> | 240 | <a href="/apikey?provider=anthropic">anthropic api key →</a><br> | ||
| 241 | <a href="/apikey?provider=openai">openai api key →</a><br> | 241 | <a href="/apikey?provider=openai">openai api key →</a><br> | ||
| 242 | <a href="/apikey?provider=kimi">kimi api key →</a><br> | 242 | <a href="/apikey?provider=kimi">kimi api key →</a><br> | ||
| 243 | + | <a href="/apikey?provider=deepseek">deepseek api key →</a><br> | |||
| 243 | <a href="/apikey?provider=google">google gemini api key →</a> | 244 | <a href="/apikey?provider=google">google gemini api key →</a> | ||
| 244 | </p> | 245 | </p> | ||
| 245 | <hr class="rule"> | 246 | <hr class="rule"> | ||
M internal/auth/store.go +3 -0
| @@ -20,6 +20,7 @@ type Credentials struct { | |||
| 20 | 20 | OpenAI ProviderCreds `json:"openai,omitempty"` | |
| 21 | 21 | Kimi ProviderCreds `json:"kimi,omitempty"` | |
| 22 | 22 | Google ProviderCreds `json:"google,omitempty"` | |
| 23 | + | DeepSeek ProviderCreds `json:"deepseek,omitempty"` | |
| 23 | 24 | } | |
| 24 | 25 | ||
| 25 | 26 | // ProviderCreds holds credentials for a single provider. Only one of | |
| @@ -86,6 +87,8 @@ func (c *Credentials) get(provider string) *ProviderCreds { | |||
| 86 | 87 | return &c.Kimi | |
| 87 | 88 | case "google": | |
| 88 | 89 | return &c.Google | |
| 90 | + | case "deepseek": | |
| 91 | + | return &c.DeepSeek | |
| 89 | 92 | } | |
| 90 | 93 | return nil | |
| 91 | 94 | } | |
| @@ -20,6 +20,7 @@ type Credentials struct { | |||||
| 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 | Google ProviderCreds `json:"google,omitempty"` | ||
| 23 | + | DeepSeek ProviderCreds `json:"deepseek,omitempty"` | |||
| 23 | } | 24 | } | ||
| 24 | 25 | ||||
| 25 | // ProviderCreds holds credentials for a single provider. Only one of | 26 | // ProviderCreds holds credentials for a single provider. Only one of | ||
| @@ -86,6 +87,8 @@ func (c *Credentials) get(provider string) *ProviderCreds { | |||||
| 86 | return &c.Kimi | 87 | return &c.Kimi | ||
| 87 | case "google": | 88 | case "google": | ||
| 88 | return &c.Google | 89 | return &c.Google | ||
| 90 | + | case "deepseek": | |||
| 91 | + | return &c.DeepSeek | |||
| 89 | } | 92 | } | ||
| 90 | return nil | 93 | return nil | ||
| 91 | } | 94 | } | ||
M internal/provider/models.go +18 -0
| @@ -92,6 +92,24 @@ var Catalog = []Model{ | |||
| 92 | 92 | PriceInput: 15.00, PriceOutput: 75.00, PriceCacheRead: 1.50, PriceCacheWrite: 18.75, | |
| 93 | 93 | }, | |
| 94 | 94 | ||
| 95 | + | // ---- DeepSeek ---- | |
| 96 | + | // The current public DeepSeek API exposes the V4 family on | |
| 97 | + | // api.deepseek.com/v1. Pro is the flagship reasoning model; | |
| 98 | + | // Flash is the cheaper/faster sibling. Both accept image inputs | |
| 99 | + | // (multimodal parts: image_url) in addition to text. | |
| 100 | + | { | |
| 101 | + | Provider: "deepseek", ID: "deepseek-v4-pro", DisplayName: "DeepSeek V4 Pro", | |
| 102 | + | ContextWindow: 128000, MaxOutput: 8192, Reasoning: true, | |
| 103 | + | PriceInput: 0.55, PriceOutput: 2.19, PriceCacheRead: 0.14, | |
| 104 | + | BaseURL: "https://api.deepseek.com/v1", | |
| 105 | + | }, | |
| 106 | + | { | |
| 107 | + | Provider: "deepseek", ID: "deepseek-v4-flash", DisplayName: "DeepSeek V4 Flash", | |
| 108 | + | ContextWindow: 128000, MaxOutput: 8192, Reasoning: false, | |
| 109 | + | PriceInput: 0.27, PriceOutput: 1.10, PriceCacheRead: 0.07, | |
| 110 | + | BaseURL: "https://api.deepseek.com/v1", | |
| 111 | + | }, | |
| 112 | + | ||
| 95 | 113 | // ---- Kimi / Kimi Code ---- | |
| 96 | 114 | { | |
| 97 | 115 | Provider: "kimi", ID: "kimi-for-coding", DisplayName: "Kimi-k2.6", | |
| @@ -92,6 +92,24 @@ var Catalog = []Model{ | |||||
| 92 | PriceInput: 15.00, PriceOutput: 75.00, PriceCacheRead: 1.50, PriceCacheWrite: 18.75, | 92 | PriceInput: 15.00, PriceOutput: 75.00, PriceCacheRead: 1.50, PriceCacheWrite: 18.75, | ||
| 93 | }, | 93 | }, | ||
| 94 | 94 | ||||
| 95 | + | // ---- DeepSeek ---- | |||
| 96 | + | // The current public DeepSeek API exposes the V4 family on | |||
| 97 | + | // api.deepseek.com/v1. Pro is the flagship reasoning model; | |||
| 98 | + | // Flash is the cheaper/faster sibling. Both accept image inputs | |||
| 99 | + | // (multimodal parts: image_url) in addition to text. | |||
| 100 | + | { | |||
| 101 | + | Provider: "deepseek", ID: "deepseek-v4-pro", DisplayName: "DeepSeek V4 Pro", | |||
| 102 | + | ContextWindow: 128000, MaxOutput: 8192, Reasoning: true, | |||
| 103 | + | PriceInput: 0.55, PriceOutput: 2.19, PriceCacheRead: 0.14, | |||
| 104 | + | BaseURL: "https://api.deepseek.com/v1", | |||
| 105 | + | }, | |||
| 106 | + | { | |||
| 107 | + | Provider: "deepseek", ID: "deepseek-v4-flash", DisplayName: "DeepSeek V4 Flash", | |||
| 108 | + | ContextWindow: 128000, MaxOutput: 8192, Reasoning: false, | |||
| 109 | + | PriceInput: 0.27, PriceOutput: 1.10, PriceCacheRead: 0.07, | |||
| 110 | + | BaseURL: "https://api.deepseek.com/v1", | |||
| 111 | + | }, | |||
| 112 | + | ||||
| 95 | // ---- Kimi / Kimi Code ---- | 113 | // ---- Kimi / Kimi Code ---- | ||
| 96 | { | 114 | { | ||
| 97 | Provider: "kimi", ID: "kimi-for-coding", DisplayName: "Kimi-k2.6", | 115 | Provider: "kimi", ID: "kimi-for-coding", DisplayName: "Kimi-k2.6", | ||
M internal/provider/openai.go +26 -6
| @@ -56,6 +56,20 @@ func NewKimi(apiKey, baseURL string) Client { | |||
| 56 | 56 | return NewKimiWithHeaders(apiKey, baseURL, nil) | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | + | // NewDeepSeek creates a DeepSeek client. DeepSeek's chat API is | |
| 60 | + | // OpenAI-compatible at https://api.deepseek.com/v1. | |
| 61 | + | func NewDeepSeek(apiKey, baseURL string) Client { | |
| 62 | + | if baseURL == "" { | |
| 63 | + | baseURL = "https://api.deepseek.com/v1" | |
| 64 | + | } | |
| 65 | + | return &openaiClient{ | |
| 66 | + | apiKey: apiKey, | |
| 67 | + | baseURL: strings.TrimRight(baseURL, "/"), | |
| 68 | + | name: "deepseek", | |
| 69 | + | http: &http.Client{Timeout: 0}, | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 59 | 73 | // NewKimiWithHeaders creates a Kimi/Moonshot client with extra headers. | |
| 60 | 74 | // Subscription tokens from Kimi Code need the official CLI's X-Msh-* headers. | |
| 61 | 75 | func NewKimiWithHeaders(apiKey, baseURL string, headers map[string]string) Client { | |
| @@ -182,11 +196,17 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 182 | 196 | out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System}) | |
| 183 | 197 | } | |
| 184 | 198 | ||
| 199 | + | // DeepSeek's chat-completions API rejects the multimodal content | |
| 200 | + | // schema (parts arrays containing image_url). Force every user/tool | |
| 201 | + | // message to a plain string and silently drop image blocks for | |
| 202 | + | // this provider so historical sessions with screenshots still replay. | |
| 203 | + | textOnly := c.name == "deepseek" | |
| 204 | + | ||
| 185 | 205 | req.Messages = RepairOrphanedToolResults(req.Messages) | |
| 186 | 206 | for _, msg := range req.Messages { | |
| 187 | 207 | switch msg.Role { | |
| 188 | 208 | case RoleUser: | |
| 189 | - | content := buildOAIUserContent(msg.Content) | |
| 209 | + | content := buildOAIUserContent(msg.Content, textOnly) | |
| 190 | 210 | out.Messages = append(out.Messages, oaiMessage{Role: "user", Content: content}) | |
| 191 | 211 | case RoleAssistant: | |
| 192 | 212 | am := oaiMessage{Role: "assistant"} | |
| @@ -245,7 +265,7 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 245 | 265 | // flattening the tool output to plain text. | |
| 246 | 266 | for _, b := range msg.Content { | |
| 247 | 267 | if tr, ok := b.(ToolResultBlock); ok { | |
| 248 | - | content := buildOAIToolContent(tr.Content, tr.IsError) | |
| 268 | + | content := buildOAIToolContent(tr.Content, tr.IsError, textOnly) | |
| 249 | 269 | out.Messages = append(out.Messages, oaiMessage{ | |
| 250 | 270 | Role: "tool", | |
| 251 | 271 | ToolCallID: tr.CallID, | |
| @@ -271,7 +291,7 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 271 | 291 | return out, nil | |
| 272 | 292 | } | |
| 273 | 293 | ||
| 274 | - | func buildOAIUserContent(blocks []Content) interface{} { | |
| 294 | + | func buildOAIUserContent(blocks []Content, textOnly bool) interface{} { | |
| 275 | 295 | hasImage := false | |
| 276 | 296 | for _, b := range blocks { | |
| 277 | 297 | if _, ok := b.(ImageBlock); ok { | |
| @@ -279,7 +299,7 @@ func buildOAIUserContent(blocks []Content) interface{} { | |||
| 279 | 299 | break | |
| 280 | 300 | } | |
| 281 | 301 | } | |
| 282 | - | if !hasImage { | |
| 302 | + | if textOnly || !hasImage { | |
| 283 | 303 | var sb strings.Builder | |
| 284 | 304 | for _, b := range blocks { | |
| 285 | 305 | if tb, ok := b.(TextBlock); ok { | |
| @@ -294,7 +314,7 @@ func buildOAIUserContent(blocks []Content) interface{} { | |||
| 294 | 314 | return buildOAIContentBlocks(blocks, false) | |
| 295 | 315 | } | |
| 296 | 316 | ||
| 297 | - | func buildOAIToolContent(blocks []Content, isError bool) interface{} { | |
| 317 | + | func buildOAIToolContent(blocks []Content, isError, textOnly bool) interface{} { | |
| 298 | 318 | hasImage := false | |
| 299 | 319 | for _, b := range blocks { | |
| 300 | 320 | if _, ok := b.(ImageBlock); ok { | |
| @@ -302,7 +322,7 @@ func buildOAIToolContent(blocks []Content, isError bool) interface{} { | |||
| 302 | 322 | break | |
| 303 | 323 | } | |
| 304 | 324 | } | |
| 305 | - | if !hasImage { | |
| 325 | + | if textOnly || !hasImage { | |
| 306 | 326 | var sb strings.Builder | |
| 307 | 327 | for _, b := range blocks { | |
| 308 | 328 | if tb, ok := b.(TextBlock); ok { | |
| @@ -56,6 +56,20 @@ func NewKimi(apiKey, baseURL string) Client { | |||||
| 56 | return NewKimiWithHeaders(apiKey, baseURL, nil) | 56 | return NewKimiWithHeaders(apiKey, baseURL, nil) | ||
| 57 | } | 57 | } | ||
| 58 | 58 | ||||
| 59 | + | // NewDeepSeek creates a DeepSeek client. DeepSeek's chat API is | |||
| 60 | + | // OpenAI-compatible at https://api.deepseek.com/v1. | |||
| 61 | + | func NewDeepSeek(apiKey, baseURL string) Client { | |||
| 62 | + | if baseURL == "" { | |||
| 63 | + | baseURL = "https://api.deepseek.com/v1" | |||
| 64 | + | } | |||
| 65 | + | return &openaiClient{ | |||
| 66 | + | apiKey: apiKey, | |||
| 67 | + | baseURL: strings.TrimRight(baseURL, "/"), | |||
| 68 | + | name: "deepseek", | |||
| 69 | + | http: &http.Client{Timeout: 0}, | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 59 | // NewKimiWithHeaders creates a Kimi/Moonshot client with extra headers. | 73 | // NewKimiWithHeaders creates a Kimi/Moonshot client with extra headers. | ||
| 60 | // Subscription tokens from Kimi Code need the official CLI's X-Msh-* headers. | 74 | // Subscription tokens from Kimi Code need the official CLI's X-Msh-* headers. | ||
| 61 | func NewKimiWithHeaders(apiKey, baseURL string, headers map[string]string) Client { | 75 | func NewKimiWithHeaders(apiKey, baseURL string, headers map[string]string) Client { | ||
| @@ -182,11 +196,17 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||||
| 182 | out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System}) | 196 | out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System}) | ||
| 183 | } | 197 | } | ||
| 184 | 198 | ||||
| 199 | + | // DeepSeek's chat-completions API rejects the multimodal content | |||
| 200 | + | // schema (parts arrays containing image_url). Force every user/tool | |||
| 201 | + | // message to a plain string and silently drop image blocks for | |||
| 202 | + | // this provider so historical sessions with screenshots still replay. | |||
| 203 | + | textOnly := c.name == "deepseek" | |||
| 204 | + | ||||
| 185 | req.Messages = RepairOrphanedToolResults(req.Messages) | 205 | req.Messages = RepairOrphanedToolResults(req.Messages) | ||
| 186 | for _, msg := range req.Messages { | 206 | for _, msg := range req.Messages { | ||
| 187 | switch msg.Role { | 207 | switch msg.Role { | ||
| 188 | case RoleUser: | 208 | case RoleUser: | ||
| 189 | - | content := buildOAIUserContent(msg.Content) | 209 | + | content := buildOAIUserContent(msg.Content, textOnly) |
| 190 | out.Messages = append(out.Messages, oaiMessage{Role: "user", Content: content}) | 210 | out.Messages = append(out.Messages, oaiMessage{Role: "user", Content: content}) | ||
| 191 | case RoleAssistant: | 211 | case RoleAssistant: | ||
| 192 | am := oaiMessage{Role: "assistant"} | 212 | am := oaiMessage{Role: "assistant"} | ||
| @@ -245,7 +265,7 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||||
| 245 | // flattening the tool output to plain text. | 265 | // flattening the tool output to plain text. | ||
| 246 | for _, b := range msg.Content { | 266 | for _, b := range msg.Content { | ||
| 247 | if tr, ok := b.(ToolResultBlock); ok { | 267 | if tr, ok := b.(ToolResultBlock); ok { | ||
| 248 | - | content := buildOAIToolContent(tr.Content, tr.IsError) | 268 | + | content := buildOAIToolContent(tr.Content, tr.IsError, textOnly) |
| 249 | out.Messages = append(out.Messages, oaiMessage{ | 269 | out.Messages = append(out.Messages, oaiMessage{ | ||
| 250 | Role: "tool", | 270 | Role: "tool", | ||
| 251 | ToolCallID: tr.CallID, | 271 | ToolCallID: tr.CallID, | ||
| @@ -271,7 +291,7 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||||
| 271 | return out, nil | 291 | return out, nil | ||
| 272 | } | 292 | } | ||
| 273 | 293 | ||||
| 274 | - | func buildOAIUserContent(blocks []Content) interface{} { | 294 | + | func buildOAIUserContent(blocks []Content, textOnly bool) interface{} { |
| 275 | hasImage := false | 295 | hasImage := false | ||
| 276 | for _, b := range blocks { | 296 | for _, b := range blocks { | ||
| 277 | if _, ok := b.(ImageBlock); ok { | 297 | if _, ok := b.(ImageBlock); ok { | ||
| @@ -279,7 +299,7 @@ func buildOAIUserContent(blocks []Content) interface{} { | |||||
| 279 | break | 299 | break | ||
| 280 | } | 300 | } | ||
| 281 | } | 301 | } | ||
| 282 | - | if !hasImage { | 302 | + | if textOnly || !hasImage { |
| 283 | var sb strings.Builder | 303 | var sb strings.Builder | ||
| 284 | for _, b := range blocks { | 304 | for _, b := range blocks { | ||
| 285 | if tb, ok := b.(TextBlock); ok { | 305 | if tb, ok := b.(TextBlock); ok { | ||
| @@ -294,7 +314,7 @@ func buildOAIUserContent(blocks []Content) interface{} { | |||||
| 294 | return buildOAIContentBlocks(blocks, false) | 314 | return buildOAIContentBlocks(blocks, false) | ||
| 295 | } | 315 | } | ||
| 296 | 316 | ||||
| 297 | - | func buildOAIToolContent(blocks []Content, isError bool) interface{} { | 317 | + | func buildOAIToolContent(blocks []Content, isError, textOnly bool) interface{} { |
| 298 | hasImage := false | 318 | hasImage := false | ||
| 299 | for _, b := range blocks { | 319 | for _, b := range blocks { | ||
| 300 | if _, ok := b.(ImageBlock); ok { | 320 | if _, ok := b.(ImageBlock); ok { | ||
| @@ -302,7 +322,7 @@ func buildOAIToolContent(blocks []Content, isError bool) interface{} { | |||||
| 302 | break | 322 | break | ||
| 303 | } | 323 | } | ||
| 304 | } | 324 | } | ||
| 305 | - | if !hasImage { | 325 | + | if textOnly || !hasImage { |
| 306 | var sb strings.Builder | 326 | var sb strings.Builder | ||
| 307 | for _, b := range blocks { | 327 | for _, b := range blocks { | ||
| 308 | if tb, ok := b.(TextBlock); ok { | 328 | if tb, ok := b.(TextBlock); ok { | ||
M internal/provider/usermodels.go +2 -0
| @@ -80,6 +80,8 @@ func LoadUserModels(path string) []Model { | |||
| 80 | 80 | normalized = "anthropic" | |
| 81 | 81 | case "moonshot", "moonshot-ai", "kimi-code": | |
| 82 | 82 | normalized = "kimi" | |
| 83 | + | case "deepseek-chat", "deepseek-ai": | |
| 84 | + | normalized = "deepseek" | |
| 83 | 85 | } | |
| 84 | 86 | ||
| 85 | 87 | for _, um := range prov.Models { | |
| @@ -80,6 +80,8 @@ func LoadUserModels(path string) []Model { | |||||
| 80 | normalized = "anthropic" | 80 | normalized = "anthropic" | ||
| 81 | case "moonshot", "moonshot-ai", "kimi-code": | 81 | case "moonshot", "moonshot-ai", "kimi-code": | ||
| 82 | normalized = "kimi" | 82 | normalized = "kimi" | ||
| 83 | + | case "deepseek-chat", "deepseek-ai": | |||
| 84 | + | normalized = "deepseek" | |||
| 83 | } | 85 | } | ||
| 84 | 86 | ||||
| 85 | for _, um := range prov.Models { | 87 | for _, um := range prov.Models { | ||