detached ce78a49

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 @@
55 Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
66
77 - one static binary.
8-- built-in providers for anthropic, openai/codex, kimi, 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.
99 - four tools (read, write, edit, bash).
1010 - three run modes (interactive tui, print, json).
1111 - built-in telegram bot.
@@ -61,7 +61,7 @@ The easiest way is to just run `zot` and type `/login`. The TUI opens even witho
6161 ### Credential lookup order
6262
6363 1. `--api-key` flag
64-2. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `KIMI_API_KEY`, `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
6565 3. `$ZOT_HOME/auth.json` (API key or OAuth token; mode 0600)
6666
6767 `$ZOT_HOME` defaults to:
@@ -73,8 +73,8 @@ The easiest way is to just run `zot` and type `/login`. The TUI opens even witho
7373
7474 Run `zot` and type `/login`. Pick one of two methods:
7575
76-- **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you paste your `sk-ant-...`, `sk-...`, 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.
7878 - Anthropic and OpenAI pin the browser callback to fixed provider-specific ports (`localhost:53692` for Anthropic, `localhost:1455` for OpenAI) because those are the only ports their auth servers will redirect to.
7979 - Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers.
8080 - OpenAI uses the Codex CLI OAuth flow. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token.
@@ -127,7 +127,7 @@ zot --help
127127
128128 | Flag | Description |
129129 |---|---|
130-| `--provider anthropic\|openai\|kimi\|google\|ollama` | Pick the provider. |
130+| `--provider anthropic\|openai\|kimi\|deepseek\|google\|ollama` | Pick the provider. |
131131 | `--model <id>` | Pick the model (see `--list-models`). |
132132 | `--api-key <key>` | Override the API key. |
133133 | `--base-url <url>` | Override the provider base URL (tests, self-hosted). |
@@ -181,7 +181,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands:
181181 |---|---|
182182 | `/help` | Show key bindings and commands. |
183183 | `/login` | Log in via API key or subscription (opens a dialog). |
184-| `/logout [provider]` | Clear credentials for `anthropic`, `openai`, `kimi`, `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. |
185185 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |
186186 | `/sessions` | Resume a previous session for this directory. |
187187 | `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. |
@@ -267,7 +267,7 @@ When a turn fails because of a recoverable provider error — expired token (`40
267267
268268 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.
269269
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.
271271
272272 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.
273273
@@ -297,7 +297,7 @@ Place a `models.json` in `$ZOT_HOME` (macOS: `~/Library/Application Support/zot/
297297
298298 Supported fields per model: `id` (required), `name`, `reasoning`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.
299299
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`.
301301
302302 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.
303303
@@ -332,6 +332,39 @@ zot --provider kimi --model kimi-k2-0905-preview --base-url https://api.moonshot
332332
333333 You can add additional Kimi/Moonshot model IDs to `models.json` under the `kimi` provider.
334334
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+
335368 ### Google Gemini
336369
337370 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 order61 ### Credential lookup order
62 62
63 1. `--api-key` flag63 1. `--api-key` flag
64-2. `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `KIMI_API_KEY`, `MOONSHOT_API_KEY`, `GEMINI_API_KEY`, or `GOOGLE_API_KEY` env var64+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 Gemini368 ### 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) {
334334 row{"zot tg ...", "short alias for telegram-bot"},
335335 )
336336 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)"},
338338 row{"--model ID", "model id (see --list-models)"},
339339 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},
340340 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) {
129129
130130 // User-requested provider (explicit > config > default).
131131 provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
132- if provName != "anthropic" && provName != "openai" && provName != "kimi" && provName != "google" && provName != "ollama" {
132+ if provName != "anthropic" && provName != "openai" && provName != "kimi" && provName != "deepseek" && provName != "google" && provName != "ollama" {
133133 // Unknown provider (maybe removed or renamed). Fall back to
134134 // the first provider that has credentials, or anthropic.
135135 provName = "anthropic"
@@ -139,6 +139,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
139139 if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil {
140140 provName = "kimi"
141141 }
142+ if _, _, _, err := ResolveCredentialFull("deepseek", ""); err == nil {
143+ provName = "deepseek"
144+ }
142145 if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {
143146 provName = "anthropic"
144147 }
@@ -170,7 +173,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
170173 // never shows a "not logged in" banner.
171174 userPickedProvider := args.Provider != ""
172175 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"} {
174177 if other == provName {
175178 continue
176179 }
@@ -189,6 +192,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
189192 model = "gpt-5"
190193 case "kimi":
191194 model = "kimi-for-coding"
195+ case "deepseek":
196+ model = "deepseek-v4-pro"
192197 case "google":
193198 model = "gemini-2.5-pro"
194199 case "ollama":
@@ -206,6 +211,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
206211 model = "gpt-5"
207212 case "kimi":
208213 model = "kimi-for-coding"
214+ case "deepseek":
215+ model = "deepseek-v4-pro"
209216 case "google":
210217 model = "gemini-2.5-pro"
211218 default:
@@ -446,6 +453,8 @@ func (r Resolved) NewClient() provider.Client {
446453 return r.wrapWithRefresh(inner)
447454 }
448455 return inner
456+ case "deepseek":
457+ return provider.NewDeepSeek(r.Credential, r.BaseURL)
449458 case "openai":
450459 if r.AuthMethod == "oauth" {
451460 inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
@@ -601,6 +610,8 @@ func envVarName(provider string) string {
601610 return "OPENAI"
602611 case "kimi":
603612 return "KIMI"
613+ case "deepseek":
614+ return "DEEPSEEK"
604615 case "google":
605616 return "GEMINI"
606617 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 to133 // Unknown provider (maybe removed or renamed). Fall back to
134 // the first provider that has credentials, or anthropic.134 // the first provider that has credentials, or anthropic.
135 provName = "anthropic"135 provName = "anthropic"
@@ -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 continue178 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 inner455 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 {
732732 BuildAgentForRescue: buildAgentForRescue,
733733 LoggedInProviders: func() []string {
734734 var out []string
735- for _, p := range []string{"anthropic", "openai", "kimi", "google"} {
735+ for _, p := range []string{"anthropic", "openai", "kimi", "deepseek", "google"} {
736736 if _, _, err := ResolveCredential(p, ""); err == nil {
737737 out = append(out, p)
738738 }
@@ -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 []string734 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
158158 if v := os.Getenv("GOOGLE_API_KEY"); v != "" {
159159 return v, "apikey", "", nil
160160 }
161+ case "deepseek":
162+ if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" {
163+ return v, "apikey", "", nil
164+ }
161165 }
162166 c, err := AuthStoreFor().Load()
163167 if err != nil {
@@ -195,6 +199,10 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
195199 tok, _ = refreshIfExpired("kimi", tok)
196200 return tok.AccessToken, "oauth", "", nil
197201 }
202+ case "deepseek":
203+ if c.DeepSeek.APIKey != "" {
204+ return c.DeepSeek.APIKey, "apikey", "", nil
205+ }
198206 case "google":
199207 // Google is API-key only — no OAuth path. We still load
200208 // 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", "", nil159 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", "", nil200 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 load207 // Google is API-key only — no OAuth path. We still load
200 // auth.json so /login api-key flows work without exporting208 // 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) {
6565 d.success = false
6666 d.url = ""
6767 d.cursor = 0
68- d.status = map[string]string{"anthropic": "", "openai": "", "kimi": "", "google": ""}
68+ d.status = map[string]string{"anthropic": "", "openai": "", "kimi": "", "deepseek": "", "google": ""}
6969 // Best-effort: if the auth file can't be read, treat every
7070 // provider as not-logged-in. The status line just won't show
7171 // anything useful in that case, which is fine — the user
@@ -75,6 +75,7 @@ func (d *loginDialog) Open(zotHome string) {
7575 d.status["anthropic"] = creds.Method("anthropic")
7676 d.status["openai"] = creds.Method("openai")
7777 d.status["kimi"] = creds.Method("kimi")
78+ d.status["deepseek"] = creds.Method("deepseek")
7879 d.status["google"] = creds.Method("google")
7980 }
8081 }
@@ -112,7 +113,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
112113 }
113114 lines = append(lines, frameRule(th, width))
114115 case loginStepProvider:
115- opts := []string{"anthropic", "openai", "kimi", "google"}
116+ opts := providersForMethod(d.method)
116117 lines = append(lines, frameHeader(th, "login - "+d.method, width))
117118 for _, l := range d.renderStatusLines(th) {
118119 lines = append(lines, l)
@@ -196,6 +197,19 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
196197 return lines
197198 }
198199
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+
199213 // providerLabel returns the user-facing label for a provider id.
200214 func providerLabel(id string) string {
201215 switch id {
@@ -205,6 +219,8 @@ func providerLabel(id string) string {
205219 return "OpenAI (ChatGPT Plus/Pro)"
206220 case "kimi":
207221 return "Kimi Code"
222+ case "deepseek":
223+ return "DeepSeek"
208224 case "google":
209225 return "Google (Gemini API key)"
210226 }
@@ -225,8 +241,9 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
225241 anth := d.status["anthropic"]
226242 op := d.status["openai"]
227243 kimi := d.status["kimi"]
244+ ds := d.status["deepseek"]
228245 goog := d.status["google"]
229- if anth == "" && op == "" && kimi == "" && goog == "" {
246+ if anth == "" && op == "" && kimi == "" && ds == "" && goog == "" {
230247 return nil
231248 }
232249 row := func(id, method string) string {
@@ -249,6 +266,7 @@ func (d *loginDialog) renderStatusLines(th tui.Theme) []string {
249266 row("anthropic", anth),
250267 row("openai", op),
251268 row("kimi", kimi),
269+ row("deepseek", ds),
252270 row("google", goog),
253271 "",
254272 }
@@ -315,23 +333,20 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
315333 d.cursor--
316334 }
317335 case tui.KeyDown:
318- if d.cursor < 3 {
336+ providers := providersForMethod(d.method)
337+ if d.cursor < len(providers)-1 {
319338 d.cursor++
320339 }
321340 case tui.KeyEsc:
322341 d.Close()
323342 return loginDialogAction{Close: true}
324343 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+ }
326348 d.provider = providers[d.cursor]
327349 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- }
335350 if d.method == "apikey" {
336351 return loginDialogAction{StartAPIKey: true, Provider: d.provider}
337352 }
@@ -65,7 +65,7 @@ func (d *loginDialog) Open(zotHome string) {
65 d.success = false65 d.success = false
66 d.url = ""66 d.url = ""
67 d.cursor = 067 d.cursor = 0
68- d.status = map[string]string{"anthropic": "", "openai": "", "kimi": "", "google": ""}68+ d.status = map[string]string{"anthropic": "", "openai": "", "kimi": "", "deepseek": "", "google": ""}
69 // Best-effort: if the auth file can't be read, treat every69 // Best-effort: if the auth file can't be read, treat every
70 // provider as not-logged-in. The status line just won't show70 // provider as not-logged-in. The status line just won't show
71 // anything useful in that case, which is fine — the user71 // anything useful in that case, which is fine — the user
@@ -75,6 +75,7 @@ func (d *loginDialog) Open(zotHome string) {
75 d.status["anthropic"] = creds.Method("anthropic")75 d.status["anthropic"] = creds.Method("anthropic")
76 d.status["openai"] = creds.Method("openai")76 d.status["openai"] = creds.Method("openai")
77 d.status["kimi"] = creds.Method("kimi")77 d.status["kimi"] = creds.Method("kimi")
78+ d.status["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 lines197 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 nil247 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 = loginStepWaiting349 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() {
7979 // StartAPIKey launches the API-key login flow.
8080 func (m *Manager) StartAPIKey(provider string) (string, error) {
8181 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")
8383 }
8484 if err := m.ensureKeyServer(); err != nil {
8585 return "", err
@@ -141,8 +141,10 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
141141 op = OpenAIOAuth
142142 case "google":
143143 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")
144146 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")
146148 }
147149
148150 m.mu.Lock()
@@ -264,8 +266,10 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
264266 op = OpenAIOAuth
265267 case "google":
266268 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")
267271 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")
269273 }
270274
271275 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 "", err85 return "", err
@@ -141,8 +141,10 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
141 op = OpenAIOAuth141 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 = OpenAIOAuth266 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 {
3737 return err
3838 }
3939 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)
4046 case "google":
4147 // Google Generative Language: list models with the API key.
4248 // 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 err37 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 over48 // 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 {
101101 // elsewhere (manager.StartOAuth).
102102 func isKnownAPIKeyProvider(p string) bool {
103103 switch p {
104- case "anthropic", "openai", "kimi", "google":
104+ case "anthropic", "openai", "kimi", "google", "deepseek":
105105 return true
106106 }
107107 return false
@@ -124,7 +124,7 @@ func (s *Server) handleAPIKey(w http.ResponseWriter, r *http.Request) {
124124 case http.MethodGet:
125125 provider := r.URL.Query().Get("provider")
126126 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)
128128 return
129129 }
130130 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="
235235 ` + logoTag + `
236236 <h1><span class="zot">zot</span> login</h1>
237237 <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>
239239 <p>
240240 <a href="/apikey?provider=anthropic">anthropic api key →</a><br>
241241 <a href="/apikey?provider=openai">openai api key →</a><br>
242242 <a href="/apikey?provider=kimi">kimi api key →</a><br>
243+ <a href="/apikey?provider=deepseek">deepseek api key →</a><br>
243244 <a href="/apikey?provider=google">google gemini api key →</a>
244245 </p>
245246 <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 true105 return true
106 }106 }
107 return false107 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 return128 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 {
2020 OpenAI ProviderCreds `json:"openai,omitempty"`
2121 Kimi ProviderCreds `json:"kimi,omitempty"`
2222 Google ProviderCreds `json:"google,omitempty"`
23+ DeepSeek ProviderCreds `json:"deepseek,omitempty"`
2324 }
2425
2526 // ProviderCreds holds credentials for a single provider. Only one of
@@ -86,6 +87,8 @@ func (c *Credentials) get(provider string) *ProviderCreds {
8687 return &c.Kimi
8788 case "google":
8889 return &c.Google
90+ case "deepseek":
91+ return &c.DeepSeek
8992 }
9093 return nil
9194 }
@@ -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 of26 // ProviderCreds holds credentials for a single provider. Only one of
@@ -86,6 +87,8 @@ func (c *Credentials) get(provider string) *ProviderCreds {
86 return &c.Kimi87 return &c.Kimi
87 case "google":88 case "google":
88 return &c.Google89 return &c.Google
90+ case "deepseek":
91+ return &c.DeepSeek
89 }92 }
90 return nil93 return nil
91 }94 }
M internal/provider/models.go +18 -0
@@ -92,6 +92,24 @@ var Catalog = []Model{
9292 PriceInput: 15.00, PriceOutput: 75.00, PriceCacheRead: 1.50, PriceCacheWrite: 18.75,
9393 },
9494
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+
95113 // ---- Kimi / Kimi Code ----
96114 {
97115 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 {
5656 return NewKimiWithHeaders(apiKey, baseURL, nil)
5757 }
5858
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+
5973 // NewKimiWithHeaders creates a Kimi/Moonshot client with extra headers.
6074 // Subscription tokens from Kimi Code need the official CLI's X-Msh-* headers.
6175 func NewKimiWithHeaders(apiKey, baseURL string, headers map[string]string) Client {
@@ -182,11 +196,17 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
182196 out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System})
183197 }
184198
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+
185205 req.Messages = RepairOrphanedToolResults(req.Messages)
186206 for _, msg := range req.Messages {
187207 switch msg.Role {
188208 case RoleUser:
189- content := buildOAIUserContent(msg.Content)
209+ content := buildOAIUserContent(msg.Content, textOnly)
190210 out.Messages = append(out.Messages, oaiMessage{Role: "user", Content: content})
191211 case RoleAssistant:
192212 am := oaiMessage{Role: "assistant"}
@@ -245,7 +265,7 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
245265 // flattening the tool output to plain text.
246266 for _, b := range msg.Content {
247267 if tr, ok := b.(ToolResultBlock); ok {
248- content := buildOAIToolContent(tr.Content, tr.IsError)
268+ content := buildOAIToolContent(tr.Content, tr.IsError, textOnly)
249269 out.Messages = append(out.Messages, oaiMessage{
250270 Role: "tool",
251271 ToolCallID: tr.CallID,
@@ -271,7 +291,7 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
271291 return out, nil
272292 }
273293
274-func buildOAIUserContent(blocks []Content) interface{} {
294+func buildOAIUserContent(blocks []Content, textOnly bool) interface{} {
275295 hasImage := false
276296 for _, b := range blocks {
277297 if _, ok := b.(ImageBlock); ok {
@@ -279,7 +299,7 @@ func buildOAIUserContent(blocks []Content) interface{} {
279299 break
280300 }
281301 }
282- if !hasImage {
302+ if textOnly || !hasImage {
283303 var sb strings.Builder
284304 for _, b := range blocks {
285305 if tb, ok := b.(TextBlock); ok {
@@ -294,7 +314,7 @@ func buildOAIUserContent(blocks []Content) interface{} {
294314 return buildOAIContentBlocks(blocks, false)
295315 }
296316
297-func buildOAIToolContent(blocks []Content, isError bool) interface{} {
317+func buildOAIToolContent(blocks []Content, isError, textOnly bool) interface{} {
298318 hasImage := false
299319 for _, b := range blocks {
300320 if _, ok := b.(ImageBlock); ok {
@@ -302,7 +322,7 @@ func buildOAIToolContent(blocks []Content, isError bool) interface{} {
302322 break
303323 }
304324 }
305- if !hasImage {
325+ if textOnly || !hasImage {
306326 var sb strings.Builder
307327 for _, b := range blocks {
308328 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, nil291 return out, nil
272 }292 }
273 293
274-func buildOAIUserContent(blocks []Content) interface{} {294+func buildOAIUserContent(blocks []Content, textOnly bool) interface{} {
275 hasImage := false295 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 break299 break
280 }300 }
281 }301 }
282- if !hasImage {302+ if textOnly || !hasImage {
283 var sb strings.Builder303 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 := false318 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 break322 break
303 }323 }
304 }324 }
305- if !hasImage {325+ if textOnly || !hasImage {
306 var sb strings.Builder326 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 {
8080 normalized = "anthropic"
8181 case "moonshot", "moonshot-ai", "kimi-code":
8282 normalized = "kimi"
83+ case "deepseek-chat", "deepseek-ai":
84+ normalized = "deepseek"
8385 }
8486
8587 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 {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close