detached 6bae491

Add model-aware reasoning levels

commit
6bae491a4c211bc67c113b6b516d148594992337
author
patriceckhart · · 2026-08-03 15:40 UTC
parent
6c955d6
signature
unsigned
refs
tag: v0.3.32
24 files · +676 -144
M README.md +9 -5
@@ -210,7 +210,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
210210 | `--insecure` | Skip TLS certificate verification for the explicit `--base-url` endpoint or a `baseUrl` defined for a user model in `models.json` (self-signed local/internal inference servers). Built-in providers, auth, and model discovery keep normal TLS verification. |
211211 | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). |
212212 | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |
213-| `--reasoning off\|minimum\|low\|medium\|high\|xhigh\|max` | Set thinking level on supported models (default: off). `max` is a separate opt-in tier above `xhigh`. |
213+| `--reasoning off\|minimum\|low\|medium\|high\|xhigh\|max` | Set the reasoning level on supported models (default: off). `max` is a separate opt-in tier above `xhigh`. |
214214 | `--stats <path>` | With `-p`/`--print`, write generation statistics as JSON. |
215215 | `-c`, `--continue` | Resume the latest session for this cwd. |
216216 | `-r`, `--resume` | Pick a session to resume. |
@@ -274,6 +274,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
274274 | `/login` | Log in via API key or subscription (opens a dialog). |
275275 | `/logout [provider]` | Clear credentials for any logged-in provider, or all when omitted. `/logout openai-codex` clears ChatGPT/Codex subscription auth while preserving a public OpenAI API key; `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zot again. |
276276 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |
277+| `/reasoning` | Set the reasoning level for subsequent model calls. |
277278 | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. |
278279 | `/sessions` | Resume a previous session for this directory. |
279280 | `/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`. |
@@ -388,7 +389,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
388389 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
389390 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
390391 - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields).
391-- **thinking level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call.
392+- **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`.
392393 - **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md).
393394 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.
394395
@@ -458,6 +459,7 @@ Place a `models.json` in `$ZOT_HOME` (`$XDG_STATE_HOME/zot/` when set, otherwise
458459 "id": "gpt-5.5",
459460 "name": "GPT-5.5",
460461 "reasoning": true,
462+ "reasoningLevelMap": {"minimum": "low", "max": ""},
461463 "contextWindow": 400000,
462464 "maxTokens": 128000
463465 }
@@ -467,7 +469,9 @@ Place a `models.json` in `$ZOT_HOME` (`$XDG_STATE_HOME/zot/` when set, otherwise
467469 }
468470 ```
469471
470-Supported fields per model: `id` (required), `name`, `reasoning`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.
472+Supported fields per model: `id` (required), `name`, `reasoning`, `reasoningLevelMap`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.
473+
474+`reasoningLevelMap` is optional. Protocol defaults apply when it is omitted. Add only model-specific exceptions using `minimum`, `low`, `medium`, `high`, `xhigh`, or `max` as keys. Map a key to another level when both inputs are equivalent, or to an empty string or `off` to remove it. The same effective mapping drives `/reasoning` and provider requests.
471475
472476 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`. Built-in provider ids such as `groq`, `openrouter`, `github-copilot`, `amazon-bedrock`, `google-vertex`, `azure-openai-responses`, `fireworks`, `vercel-ai-gateway`, `mistral`, and `xai` can also be used directly.
473477
@@ -613,7 +617,7 @@ Use `/login` and pick **api key** to paste an AI Studio key. zot probes `/v1beta
613617
614618 > **Free-tier rate limits.** AI Studio's free tier has tight per-minute and per-day caps that vary by model: `gemini-2.5-pro` is the strictest (a few requests per minute, ~50 per day), Flash and Flash-Lite are far more generous. If a Pro turn 429s with `"You exceeded your current quota"` while Flash on the same key still works, you've hit the Pro free-tier RPD. Either switch to Flash for agent loops, or [enable billing](https://aistudio.google.com/app/apikey) on your AI Studio project to flip the same key from free to pay-as-you-go pricing (`$1.25/M` input, `$10/M` output for Pro).
615619
616-Reasoning levels (`--reasoning off|minimum|low|medium|high|xhigh|max`, also configurable in `/settings` as **thinking level**) map differently per generation. `max` is a distinct opt-in tier above `xhigh`. GPT-5.6 and adaptive-thinking Claude models receive native `max`; unsupported providers clamp it to their highest accepted effort. Budget-based providers retain their provider/model caps. Gemini 3.x uses the `thinkingLevel` enum (`MINIMAL`/`LOW`/`MEDIUM`/`HIGH`), with Gemini-3-Pro pinned to `LOW` minimum and `HIGH` for any medium-or-higher request. `off` sends no reasoning config. Gemini 2.0 models have no thinking config.
620+Reasoning levels (`--reasoning off|minimum|low|medium|high|xhigh|max`, also configurable through `/reasoning` or in `/settings` as **reasoning level**) map differently per generation. `max` is a distinct opt-in tier above `xhigh`. GPT-5.6 and adaptive-thinking Claude models receive native `max`; unsupported providers clamp it to their highest accepted effort. Budget-based providers retain their provider/model caps. Gemini 3.x uses the `thinkingLevel` enum (`MINIMAL`/`LOW`/`MEDIUM`/`HIGH`), with Gemini-3-Pro pinned to `LOW` minimum and `HIGH` for any medium-or-higher request. `off` sends no reasoning config. Gemini 2.0 models have no thinking config.
617621
618622 You can add additional Gemini model IDs to `models.json` under the `google` provider.
619623
@@ -836,7 +840,7 @@ You can keep typing while the agent is working. Pressing `enter` during a turn q
836840
837841 To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zot's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`.
838842
839-Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.
843+Slash commands also work while the agent is busy. Non-destructive ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/reasoning`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.
840844
841845
842846 ## Keys (interactive mode)
@@ -210,7 +210,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
210 | `--insecure` | Skip TLS certificate verification for the explicit `--base-url` endpoint or a `baseUrl` defined for a user model in `models.json` (self-signed local/internal inference servers). Built-in providers, auth, and model discovery keep normal TLS verification. |210 | `--insecure` | Skip TLS certificate verification for the explicit `--base-url` endpoint or a `baseUrl` defined for a user model in `models.json` (self-signed local/internal inference servers). Built-in providers, auth, and model discovery keep normal TLS verification. |
211 | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). |211 | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). |
212 | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |212 | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |
213-| `--reasoning off\|minimum\|low\|medium\|high\|xhigh\|max` | Set thinking level on supported models (default: off). `max` is a separate opt-in tier above `xhigh`. |213+| `--reasoning off\|minimum\|low\|medium\|high\|xhigh\|max` | Set the reasoning level on supported models (default: off). `max` is a separate opt-in tier above `xhigh`. |
214 | `--stats <path>` | With `-p`/`--print`, write generation statistics as JSON. |214 | `--stats <path>` | With `-p`/`--print`, write generation statistics as JSON. |
215 | `-c`, `--continue` | Resume the latest session for this cwd. |215 | `-c`, `--continue` | Resume the latest session for this cwd. |
216 | `-r`, `--resume` | Pick a session to resume. |216 | `-r`, `--resume` | Pick a session to resume. |
@@ -274,6 +274,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
274 | `/login` | Log in via API key or subscription (opens a dialog). |274 | `/login` | Log in via API key or subscription (opens a dialog). |
275 | `/logout [provider]` | Clear credentials for any logged-in provider, or all when omitted. `/logout openai-codex` clears ChatGPT/Codex subscription auth while preserving a public OpenAI API key; `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zot again. |275 | `/logout [provider]` | Clear credentials for any logged-in provider, or all when omitted. `/logout openai-codex` clears ChatGPT/Codex subscription auth while preserving a public OpenAI API key; `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zot again. |
276 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |276 | `/model` | Pick a model from a list (or `/model <id>` to set directly). |
277+| `/reasoning` | Set the reasoning level for subsequent model calls. |
277 | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. |278 | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. |
278 | `/sessions` | Resume a previous session for this directory. |279 | `/sessions` | Resume a previous session for this directory. |
279 | `/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`. |280 | `/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`. |
@@ -388,7 +389,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
388 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.389 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
389 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.390 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
390 - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields).391 - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields).
391-- **thinking level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call.392+- **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`.
392 - **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md).393 - **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md).
393 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.394 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.
394 395
@@ -458,6 +459,7 @@ Place a `models.json` in `$ZOT_HOME` (`$XDG_STATE_HOME/zot/` when set, otherwise
458 "id": "gpt-5.5",459 "id": "gpt-5.5",
459 "name": "GPT-5.5",460 "name": "GPT-5.5",
460 "reasoning": true,461 "reasoning": true,
462+ "reasoningLevelMap": {"minimum": "low", "max": ""},
461 "contextWindow": 400000,463 "contextWindow": 400000,
462 "maxTokens": 128000464 "maxTokens": 128000
463 }465 }
@@ -467,7 +469,9 @@ Place a `models.json` in `$ZOT_HOME` (`$XDG_STATE_HOME/zot/` when set, otherwise
467 }469 }
468 ```470 ```
469 471
470-Supported fields per model: `id` (required), `name`, `reasoning`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.472+Supported fields per model: `id` (required), `name`, `reasoning`, `reasoningLevelMap`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`.
473+
474+`reasoningLevelMap` is optional. Protocol defaults apply when it is omitted. Add only model-specific exceptions using `minimum`, `low`, `medium`, `high`, `xhigh`, or `max` as keys. Map a key to another level when both inputs are equivalent, or to an empty string or `off` to remove it. The same effective mapping drives `/reasoning` and provider requests.
471 475
472 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`. Built-in provider ids such as `groq`, `openrouter`, `github-copilot`, `amazon-bedrock`, `google-vertex`, `azure-openai-responses`, `fireworks`, `vercel-ai-gateway`, `mistral`, and `xai` can also be used directly.476 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`. Built-in provider ids such as `groq`, `openrouter`, `github-copilot`, `amazon-bedrock`, `google-vertex`, `azure-openai-responses`, `fireworks`, `vercel-ai-gateway`, `mistral`, and `xai` can also be used directly.
473 477
@@ -613,7 +617,7 @@ Use `/login` and pick **api key** to paste an AI Studio key. zot probes `/v1beta
613 617
614 > **Free-tier rate limits.** AI Studio's free tier has tight per-minute and per-day caps that vary by model: `gemini-2.5-pro` is the strictest (a few requests per minute, ~50 per day), Flash and Flash-Lite are far more generous. If a Pro turn 429s with `"You exceeded your current quota"` while Flash on the same key still works, you've hit the Pro free-tier RPD. Either switch to Flash for agent loops, or [enable billing](https://aistudio.google.com/app/apikey) on your AI Studio project to flip the same key from free to pay-as-you-go pricing (`$1.25/M` input, `$10/M` output for Pro).618 > **Free-tier rate limits.** AI Studio's free tier has tight per-minute and per-day caps that vary by model: `gemini-2.5-pro` is the strictest (a few requests per minute, ~50 per day), Flash and Flash-Lite are far more generous. If a Pro turn 429s with `"You exceeded your current quota"` while Flash on the same key still works, you've hit the Pro free-tier RPD. Either switch to Flash for agent loops, or [enable billing](https://aistudio.google.com/app/apikey) on your AI Studio project to flip the same key from free to pay-as-you-go pricing (`$1.25/M` input, `$10/M` output for Pro).
615 619
616-Reasoning levels (`--reasoning off|minimum|low|medium|high|xhigh|max`, also configurable in `/settings` as **thinking level**) map differently per generation. `max` is a distinct opt-in tier above `xhigh`. GPT-5.6 and adaptive-thinking Claude models receive native `max`; unsupported providers clamp it to their highest accepted effort. Budget-based providers retain their provider/model caps. Gemini 3.x uses the `thinkingLevel` enum (`MINIMAL`/`LOW`/`MEDIUM`/`HIGH`), with Gemini-3-Pro pinned to `LOW` minimum and `HIGH` for any medium-or-higher request. `off` sends no reasoning config. Gemini 2.0 models have no thinking config.620+Reasoning levels (`--reasoning off|minimum|low|medium|high|xhigh|max`, also configurable through `/reasoning` or in `/settings` as **reasoning level**) map differently per generation. `max` is a distinct opt-in tier above `xhigh`. GPT-5.6 and adaptive-thinking Claude models receive native `max`; unsupported providers clamp it to their highest accepted effort. Budget-based providers retain their provider/model caps. Gemini 3.x uses the `thinkingLevel` enum (`MINIMAL`/`LOW`/`MEDIUM`/`HIGH`), with Gemini-3-Pro pinned to `LOW` minimum and `HIGH` for any medium-or-higher request. `off` sends no reasoning config. Gemini 2.0 models have no thinking config.
617 621
618 You can add additional Gemini model IDs to `models.json` under the `google` provider.622 You can add additional Gemini model IDs to `models.json` under the `google` provider.
619 623
@@ -836,7 +840,7 @@ You can keep typing while the agent is working. Pressing `enter` during a turn q
836 840
837 To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zot's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`.841 To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zot's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`.
838 842
839-Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.843+Slash commands also work while the agent is busy. Non-destructive ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/reasoning`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.
840 844
841 845
842 ## Keys (interactive mode)846 ## Keys (interactive mode)
M docs/providers.md +1 -1
@@ -211,7 +211,7 @@ Some Bedrock models require regional inference-profile IDs for on-demand
211211 throughput, such as `us.` or `eu.` prefixed model IDs. zot rewrites known
212212 families automatically where possible. Claude Opus 5 uses
213213 `global.anthropic.claude-opus-5`; zot maps the bare foundation-model ID to that
214-global profile and supports its adaptive thinking levels. Explicit profile IDs
214+global profile and supports its adaptive reasoning levels. Explicit profile IDs
215215 and ARNs are left unchanged.
216216
217217 ### Google Vertex AI
@@ -211,7 +211,7 @@ Some Bedrock models require regional inference-profile IDs for on-demand
211 throughput, such as `us.` or `eu.` prefixed model IDs. zot rewrites known211 throughput, such as `us.` or `eu.` prefixed model IDs. zot rewrites known
212 families automatically where possible. Claude Opus 5 uses212 families automatically where possible. Claude Opus 5 uses
213 `global.anthropic.claude-opus-5`; zot maps the bare foundation-model ID to that213 `global.anthropic.claude-opus-5`; zot maps the bare foundation-model ID to that
214-global profile and supports its adaptive thinking levels. Explicit profile IDs214+global profile and supports its adaptive reasoning levels. Explicit profile IDs
215 and ARNs are left unchanged.215 and ARNs are left unchanged.
216 216
217 ### Google Vertex AI217 ### Google Vertex AI
M docs/rpc.md +1 -1
@@ -155,7 +155,7 @@ Cross-provider swaps require relaunching `zot rpc` with the new `--provider`.
155155
156156 ### `set_reasoning`
157157
158-Set the thinking level for subsequent prompts without restarting the process. Accepted values are `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, and `max`.
158+Set the reasoning level for subsequent prompts without restarting the process. Accepted values are `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, and `max`.
159159
160160 ```json
161161 {"id":"8","type":"set_reasoning","reasoning":"max"}
@@ -155,7 +155,7 @@ Cross-provider swaps require relaunching `zot rpc` with the new `--provider`.
155 155
156 ### `set_reasoning`156 ### `set_reasoning`
157 157
158-Set the thinking level for subsequent prompts without restarting the process. Accepted values are `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, and `max`.158+Set the reasoning level for subsequent prompts without restarting the process. Accepted values are `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, and `max`.
159 159
160 ```json160 ```json
161 {"id":"8","type":"set_reasoning","reasoning":"max"}161 {"id":"8","type":"set_reasoning","reasoning":"max"}
M docs/themes.md +1 -1
@@ -191,7 +191,7 @@ Most color fields are xterm-256 indexes (`0`–`255`).
191191 - `error` — errors, refused calls, diff deletions.
192192 - `warning` — warnings and high context-usage state.
193193 - `spinner` — reserved spinner color slot.
194-- `thinkingMax` or `thinking_max` — status color used when the opt-in `max` thinking level is active. Missing values inherit the built-in theme color.
194+- `thinkingMax` or `thinking_max` (names retained for compatibility) - status color used when the opt-in `max` reasoning level is active. Missing values inherit the built-in theme color.
195195 - `selection_bg` — highlighted row background.
196196 - `selection_fg` — highlighted row foreground.
197197
@@ -191,7 +191,7 @@ Most color fields are xterm-256 indexes (`0`–`255`).
191 - `error` — errors, refused calls, diff deletions.191 - `error` — errors, refused calls, diff deletions.
192 - `warning` — warnings and high context-usage state.192 - `warning` — warnings and high context-usage state.
193 - `spinner` — reserved spinner color slot.193 - `spinner` — reserved spinner color slot.
194-- `thinkingMax` or `thinking_max` — status color used when the opt-in `max` thinking level is active. Missing values inherit the built-in theme color.194+- `thinkingMax` or `thinking_max` (names retained for compatibility) - status color used when the opt-in `max` reasoning level is active. Missing values inherit the built-in theme color.
195 - `selection_bg` — highlighted row background.195 - `selection_bg` — highlighted row background.
196 - `selection_fg` — highlighted row foreground.196 - `selection_fg` — highlighted row foreground.
197 197
M packages/agent/args.go +1 -1
@@ -418,7 +418,7 @@ func PrintHelp(version string) {
418418 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},
419419 row{"--base-url URL", "override provider api base url"},
420420 row{"--insecure", "skip TLS certificate verification (for self-signed-cert endpoints)"},
421- row{"--reasoning off|minimum|low|medium|high|xhigh|max", "set thinking level on supported models"},
421+ row{"--reasoning off|minimum|low|medium|high|xhigh|max", "set reasoning level on supported models"},
422422 row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"},
423423 )
424424 section("prompt and session flags",
@@ -418,7 +418,7 @@ func PrintHelp(version string) {
418 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},418 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},
419 row{"--base-url URL", "override provider api base url"},419 row{"--base-url URL", "override provider api base url"},
420 row{"--insecure", "skip TLS certificate verification (for self-signed-cert endpoints)"},420 row{"--insecure", "skip TLS certificate verification (for self-signed-cert endpoints)"},
421- row{"--reasoning off|minimum|low|medium|high|xhigh|max", "set thinking level on supported models"},421+ row{"--reasoning off|minimum|low|medium|high|xhigh|max", "set reasoning level on supported models"},
422 row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"},422 row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"},
423 )423 )
424 section("prompt and session flags",424 section("prompt and session flags",
M packages/agent/modes/interactive.go +69 -32
@@ -3239,6 +3239,68 @@ func (i *Interactive) applyInputCursorColor() {
32393239 _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor256(15) + tui.CursorShapeBlock()))
32403240 }
32413241
3242+func reasoningSettingOptions(levels []string) []settingsOption {
3243+ descriptions := map[string]string{
3244+ "": "no reasoning",
3245+ "minimum": "very brief (~1k tokens)",
3246+ "low": "light (~2k tokens)",
3247+ "medium": "moderate (~8k tokens)",
3248+ "high": "deep (~16k tokens)",
3249+ "xhigh": "extra-high effort",
3250+ "max": "unconstrained effort",
3251+ }
3252+ options := make([]settingsOption, 0, len(levels))
3253+ for _, level := range levels {
3254+ label := level
3255+ if label == "" {
3256+ label = "off"
3257+ }
3258+ options = append(options, settingsOption{value: level, label: label, desc: descriptions[level]})
3259+ }
3260+ return options
3261+}
3262+
3263+func (i *Interactive) reasoningSettingItem() settingsItem {
3264+ levels := []string{"", "minimum", "low", "medium", "high", "xhigh", "max"}
3265+ reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
3266+ desc := "reasoning depth for reasoning-capable models"
3267+ hint := ""
3268+ if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
3269+ levels = provider.AvailableReasoningLevels(m)
3270+ reasoning = provider.ClampReasoningForModel(m, reasoning)
3271+ if !m.Reasoning {
3272+ hint = "current model does not support reasoning"
3273+ desc += "; current model does not support reasoning"
3274+ } else if len(levels) == 1 {
3275+ hint = "current model has no adjustable reasoning levels"
3276+ desc += "; current model has no adjustable reasoning levels"
3277+ } else {
3278+ desc += "; only levels supported by the current model are shown"
3279+ }
3280+ }
3281+ options := reasoningSettingOptions(levels)
3282+ choice := 0
3283+ for idx, opt := range options {
3284+ if opt.value == reasoning {
3285+ choice = idx
3286+ break
3287+ }
3288+ }
3289+
3290+ return settingsItem{
3291+ key: "reasoning",
3292+ label: "reasoning level",
3293+ desc: desc,
3294+ options: options,
3295+ choice: choice,
3296+ hint: hint,
3297+ }
3298+}
3299+
3300+func (i *Interactive) openReasoningDialog() {
3301+ i.settingsDialog.OpenDirectOption(i.reasoningSettingItem())
3302+}
3303+
32423304 func (i *Interactive) openSettingsDialog() {
32433305 detected := tui.DetectImageProtocol()
32443306 imgEnabled := detected != tui.ImageProtocolNone
@@ -3291,27 +3353,7 @@ func (i *Interactive) openSettingsDialog() {
32913353 }
32923354 }
32933355
3294- reasoningOptions := []settingsOption{
3295- {value: "", label: "off", desc: "no reasoning"},
3296- {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"},
3297- {value: "low", label: "low", desc: "light (~2k tokens)"},
3298- {value: "medium", label: "medium", desc: "moderate (~8k tokens)"},
3299- {value: "high", label: "high", desc: "deep (~16k tokens)"},
3300- {value: "xhigh", label: "xhigh", desc: "extra-high effort"},
3301- {value: "max", label: "max", desc: "unconstrained effort on supported models"},
3302- }
3303- reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
3304- reasoningChoice := 0
3305- for idx, opt := range reasoningOptions {
3306- if opt.value == reasoning {
3307- reasoningChoice = idx
3308- break
3309- }
3310- }
3311- reasoningHint := ""
3312- if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil && !m.Reasoning {
3313- reasoningHint = "current model does not support thinking"
3314- }
3356+ reasoningItem := i.reasoningSettingItem()
33153357
33163358 themeName := i.cfg.ThemeName
33173359 if themeName == "" {
@@ -3456,14 +3498,7 @@ func (i *Interactive) openSettingsDialog() {
34563498 },
34573499 },
34583500 },
3459- {
3460- key: "reasoning",
3461- label: "thinking level",
3462- desc: "reasoning depth for thinking-capable models",
3463- options: reasoningOptions,
3464- choice: reasoningChoice,
3465- hint: reasoningHint,
3466- },
3501+ reasoningItem,
34673502 {
34683503 key: "theme",
34693504 label: "color theme",
@@ -3600,7 +3635,7 @@ func (i *Interactive) openQuickModelPicker(slot int) {
36003635 if i.cfg.LoggedInProviders != nil {
36013636 loggedIn = i.cfg.LoggedInProviders()
36023637 }
3603- i.modelDialog.Open(current, loggedIn)
3638+ i.modelDialog.Open(current, loggedIn, i.cfg.Reasoning)
36043639 }
36053640
36063641 func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) {
@@ -4010,7 +4045,7 @@ func (i *Interactive) applyReasoningSetting(level string) {
40104045 if label == "" {
40114046 label = "off"
40124047 }
4013- i.statusOK = "thinking level " + label
4048+ i.statusOK = "reasoning level " + label
40144049 i.statusErr = ""
40154050 i.mu.Unlock()
40164051 }
@@ -4349,6 +4384,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
43494384 i.statusOK = ""
43504385 i.mu.Unlock()
43514386 }
4387+ case "/reasoning":
4388+ i.openReasoningDialog()
43524389 case "/settings":
43534390 i.openSettingsDialog()
43544391 case "/sessions":
@@ -4508,7 +4545,7 @@ func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
45084545 if i.cfg.LoggedInProviders != nil {
45094546 loggedIn = i.cfg.LoggedInProviders()
45104547 }
4511- i.modelDialog.Open(i.cfg.Model, loggedIn)
4548+ i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning)
45124549 i.mu.Lock()
45134550 if refreshErr != nil {
45144551 i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
@@ -3239,6 +3239,68 @@ func (i *Interactive) applyInputCursorColor() {
3239 _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor256(15) + tui.CursorShapeBlock()))3239 _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor256(15) + tui.CursorShapeBlock()))
3240 }3240 }
3241 3241
3242+func reasoningSettingOptions(levels []string) []settingsOption {
3243+ descriptions := map[string]string{
3244+ "": "no reasoning",
3245+ "minimum": "very brief (~1k tokens)",
3246+ "low": "light (~2k tokens)",
3247+ "medium": "moderate (~8k tokens)",
3248+ "high": "deep (~16k tokens)",
3249+ "xhigh": "extra-high effort",
3250+ "max": "unconstrained effort",
3251+ }
3252+ options := make([]settingsOption, 0, len(levels))
3253+ for _, level := range levels {
3254+ label := level
3255+ if label == "" {
3256+ label = "off"
3257+ }
3258+ options = append(options, settingsOption{value: level, label: label, desc: descriptions[level]})
3259+ }
3260+ return options
3261+}
3262+
3263+func (i *Interactive) reasoningSettingItem() settingsItem {
3264+ levels := []string{"", "minimum", "low", "medium", "high", "xhigh", "max"}
3265+ reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
3266+ desc := "reasoning depth for reasoning-capable models"
3267+ hint := ""
3268+ if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
3269+ levels = provider.AvailableReasoningLevels(m)
3270+ reasoning = provider.ClampReasoningForModel(m, reasoning)
3271+ if !m.Reasoning {
3272+ hint = "current model does not support reasoning"
3273+ desc += "; current model does not support reasoning"
3274+ } else if len(levels) == 1 {
3275+ hint = "current model has no adjustable reasoning levels"
3276+ desc += "; current model has no adjustable reasoning levels"
3277+ } else {
3278+ desc += "; only levels supported by the current model are shown"
3279+ }
3280+ }
3281+ options := reasoningSettingOptions(levels)
3282+ choice := 0
3283+ for idx, opt := range options {
3284+ if opt.value == reasoning {
3285+ choice = idx
3286+ break
3287+ }
3288+ }
3289+
3290+ return settingsItem{
3291+ key: "reasoning",
3292+ label: "reasoning level",
3293+ desc: desc,
3294+ options: options,
3295+ choice: choice,
3296+ hint: hint,
3297+ }
3298+}
3299+
3300+func (i *Interactive) openReasoningDialog() {
3301+ i.settingsDialog.OpenDirectOption(i.reasoningSettingItem())
3302+}
3303+
3242 func (i *Interactive) openSettingsDialog() {3304 func (i *Interactive) openSettingsDialog() {
3243 detected := tui.DetectImageProtocol()3305 detected := tui.DetectImageProtocol()
3244 imgEnabled := detected != tui.ImageProtocolNone3306 imgEnabled := detected != tui.ImageProtocolNone
@@ -3291,27 +3353,7 @@ func (i *Interactive) openSettingsDialog() {
3291 }3353 }
3292 }3354 }
3293 3355
3294- reasoningOptions := []settingsOption{3356+ reasoningItem := i.reasoningSettingItem()
3295- {value: "", label: "off", desc: "no reasoning"},
3296- {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"},
3297- {value: "low", label: "low", desc: "light (~2k tokens)"},
3298- {value: "medium", label: "medium", desc: "moderate (~8k tokens)"},
3299- {value: "high", label: "high", desc: "deep (~16k tokens)"},
3300- {value: "xhigh", label: "xhigh", desc: "extra-high effort"},
3301- {value: "max", label: "max", desc: "unconstrained effort on supported models"},
3302- }
3303- reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
3304- reasoningChoice := 0
3305- for idx, opt := range reasoningOptions {
3306- if opt.value == reasoning {
3307- reasoningChoice = idx
3308- break
3309- }
3310- }
3311- reasoningHint := ""
3312- if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil && !m.Reasoning {
3313- reasoningHint = "current model does not support thinking"
3314- }
3315 3357
3316 themeName := i.cfg.ThemeName3358 themeName := i.cfg.ThemeName
3317 if themeName == "" {3359 if themeName == "" {
@@ -3456,14 +3498,7 @@ func (i *Interactive) openSettingsDialog() {
3456 },3498 },
3457 },3499 },
3458 },3500 },
3459- {3501+ reasoningItem,
3460- key: "reasoning",
3461- label: "thinking level",
3462- desc: "reasoning depth for thinking-capable models",
3463- options: reasoningOptions,
3464- choice: reasoningChoice,
3465- hint: reasoningHint,
3466- },
3467 {3502 {
3468 key: "theme",3503 key: "theme",
3469 label: "color theme",3504 label: "color theme",
@@ -3600,7 +3635,7 @@ func (i *Interactive) openQuickModelPicker(slot int) {
3600 if i.cfg.LoggedInProviders != nil {3635 if i.cfg.LoggedInProviders != nil {
3601 loggedIn = i.cfg.LoggedInProviders()3636 loggedIn = i.cfg.LoggedInProviders()
3602 }3637 }
3603- i.modelDialog.Open(current, loggedIn)3638+ i.modelDialog.Open(current, loggedIn, i.cfg.Reasoning)
3604 }3639 }
3605 3640
3606 func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) {3641 func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) {
@@ -4010,7 +4045,7 @@ func (i *Interactive) applyReasoningSetting(level string) {
4010 if label == "" {4045 if label == "" {
4011 label = "off"4046 label = "off"
4012 }4047 }
4013- i.statusOK = "thinking level " + label4048+ i.statusOK = "reasoning level " + label
4014 i.statusErr = ""4049 i.statusErr = ""
4015 i.mu.Unlock()4050 i.mu.Unlock()
4016 }4051 }
@@ -4349,6 +4384,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4349 i.statusOK = ""4384 i.statusOK = ""
4350 i.mu.Unlock()4385 i.mu.Unlock()
4351 }4386 }
4387+ case "/reasoning":
4388+ i.openReasoningDialog()
4352 case "/settings":4389 case "/settings":
4353 i.openSettingsDialog()4390 i.openSettingsDialog()
4354 case "/sessions":4391 case "/sessions":
@@ -4508,7 +4545,7 @@ func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
4508 if i.cfg.LoggedInProviders != nil {4545 if i.cfg.LoggedInProviders != nil {
4509 loggedIn = i.cfg.LoggedInProviders()4546 loggedIn = i.cfg.LoggedInProviders()
4510 }4547 }
4511- i.modelDialog.Open(i.cfg.Model, loggedIn)4548+ i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning)
4512 i.mu.Lock()4549 i.mu.Lock()
4513 if refreshErr != nil {4550 if refreshErr != nil {
4514 i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()4551 i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
M packages/agent/modes/interactive_model_test.go +86 -0
@@ -2,9 +2,95 @@ package modes
22
33 import (
44 "context"
5+ "slices"
6+ "strings"
57 "testing"
8+
9+ "github.com/patriceckhart/zot/packages/tui"
610 )
711
12+func TestReasoningSelectorUsesCurrentModelLevels(t *testing.T) {
13+ i := &Interactive{cfg: InteractiveConfig{
14+ Provider: "openai",
15+ Model: "gpt-5.6-sol",
16+ Reasoning: "minimum",
17+ }}
18+
19+ item := i.reasoningSettingItem()
20+ var levels []string
21+ for _, option := range item.options {
22+ levels = append(levels, option.value)
23+ }
24+ want := []string{"", "low", "medium", "high", "xhigh", "max"}
25+ if !slices.Equal(levels, want) {
26+ t.Fatalf("reasoning levels = %q, want %q", levels, want)
27+ }
28+ if item.options[item.choice].value != "low" {
29+ t.Fatalf("clamped current level = %q, want low", item.options[item.choice].value)
30+ }
31+}
32+
33+func TestReasoningSelectorOnlyOffersOffForUnsupportedModel(t *testing.T) {
34+ i := &Interactive{cfg: InteractiveConfig{
35+ Provider: "google",
36+ Model: "gemini-2.0-flash",
37+ Reasoning: "high",
38+ }}
39+
40+ item := i.reasoningSettingItem()
41+ if len(item.options) != 1 || item.options[0].value != "" {
42+ t.Fatalf("reasoning levels = %#v, want off only", item.options)
43+ }
44+ if item.hint != "current model does not support reasoning" {
45+ t.Fatalf("hint = %q", item.hint)
46+ }
47+}
48+
49+func TestReasoningCommandOpensDirectSelector(t *testing.T) {
50+ i := &Interactive{
51+ cfg: InteractiveConfig{
52+ Reasoning: "high",
53+ },
54+ settingsDialog: newSettingsDialog(),
55+ }
56+
57+ i.runSlash(context.Background(), "/reasoning")
58+
59+ if !i.settingsDialog.Active() || !i.settingsDialog.selecting || !i.settingsDialog.direct {
60+ t.Fatal("reasoning command did not open the direct option selector")
61+ }
62+ lines := i.settingsDialog.Render(tui.Dark, 100)
63+ text := strings.Join(lines, "\n")
64+ if !strings.Contains(text, "reasoning level") || !strings.Contains(text, "high") {
65+ t.Fatalf("reasoning selector missing current level: %q", text)
66+ }
67+ i.settingsDialog.HandleKey(tui.Key{Kind: tui.KeyDown})
68+ act := i.settingsDialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
69+ i.applySettingChange(act)
70+ if i.cfg.Reasoning != "xhigh" || i.settingsDialog.Active() {
71+ t.Fatalf("reasoning selection was not applied and closed: level=%q active=%v", i.cfg.Reasoning, i.settingsDialog.Active())
72+ }
73+ if i.statusOK != "reasoning level xhigh" {
74+ t.Fatalf("reasoning status = %q", i.statusOK)
75+ }
76+
77+ i.runSlash(context.Background(), "/reasoning")
78+ act = i.settingsDialog.HandleKey(tui.Key{Kind: tui.KeyEsc})
79+ if !act.Close || i.settingsDialog.Active() {
80+ t.Fatal("escape did not close the direct reasoning selector")
81+ }
82+}
83+
84+func TestModelDialogAdvertisesReasoningSelector(t *testing.T) {
85+ d := newModelDialog()
86+ d.Open("", nil, "high")
87+
88+ text := strings.Join(d.Render(tui.Dark, 100), "\n")
89+ if !strings.Contains(text, "current reasoning: high") || !strings.Contains(text, "/reasoning") {
90+ t.Fatalf("model dialog missing reasoning guidance: %q", text)
91+ }
92+}
93+
894 func TestModelPickerSkipsLlamaRefreshWhenRouterIsNotConfigured(t *testing.T) {
995 i := &Interactive{
1096 cfg: InteractiveConfig{
@@ -2,9 +2,95 @@ package modes
2 2
3 import (3 import (
4 "context"4 "context"
5+ "slices"
6+ "strings"
5 "testing"7 "testing"
8+
9+ "github.com/patriceckhart/zot/packages/tui"
6 )10 )
7 11
12+func TestReasoningSelectorUsesCurrentModelLevels(t *testing.T) {
13+ i := &Interactive{cfg: InteractiveConfig{
14+ Provider: "openai",
15+ Model: "gpt-5.6-sol",
16+ Reasoning: "minimum",
17+ }}
18+
19+ item := i.reasoningSettingItem()
20+ var levels []string
21+ for _, option := range item.options {
22+ levels = append(levels, option.value)
23+ }
24+ want := []string{"", "low", "medium", "high", "xhigh", "max"}
25+ if !slices.Equal(levels, want) {
26+ t.Fatalf("reasoning levels = %q, want %q", levels, want)
27+ }
28+ if item.options[item.choice].value != "low" {
29+ t.Fatalf("clamped current level = %q, want low", item.options[item.choice].value)
30+ }
31+}
32+
33+func TestReasoningSelectorOnlyOffersOffForUnsupportedModel(t *testing.T) {
34+ i := &Interactive{cfg: InteractiveConfig{
35+ Provider: "google",
36+ Model: "gemini-2.0-flash",
37+ Reasoning: "high",
38+ }}
39+
40+ item := i.reasoningSettingItem()
41+ if len(item.options) != 1 || item.options[0].value != "" {
42+ t.Fatalf("reasoning levels = %#v, want off only", item.options)
43+ }
44+ if item.hint != "current model does not support reasoning" {
45+ t.Fatalf("hint = %q", item.hint)
46+ }
47+}
48+
49+func TestReasoningCommandOpensDirectSelector(t *testing.T) {
50+ i := &Interactive{
51+ cfg: InteractiveConfig{
52+ Reasoning: "high",
53+ },
54+ settingsDialog: newSettingsDialog(),
55+ }
56+
57+ i.runSlash(context.Background(), "/reasoning")
58+
59+ if !i.settingsDialog.Active() || !i.settingsDialog.selecting || !i.settingsDialog.direct {
60+ t.Fatal("reasoning command did not open the direct option selector")
61+ }
62+ lines := i.settingsDialog.Render(tui.Dark, 100)
63+ text := strings.Join(lines, "\n")
64+ if !strings.Contains(text, "reasoning level") || !strings.Contains(text, "high") {
65+ t.Fatalf("reasoning selector missing current level: %q", text)
66+ }
67+ i.settingsDialog.HandleKey(tui.Key{Kind: tui.KeyDown})
68+ act := i.settingsDialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
69+ i.applySettingChange(act)
70+ if i.cfg.Reasoning != "xhigh" || i.settingsDialog.Active() {
71+ t.Fatalf("reasoning selection was not applied and closed: level=%q active=%v", i.cfg.Reasoning, i.settingsDialog.Active())
72+ }
73+ if i.statusOK != "reasoning level xhigh" {
74+ t.Fatalf("reasoning status = %q", i.statusOK)
75+ }
76+
77+ i.runSlash(context.Background(), "/reasoning")
78+ act = i.settingsDialog.HandleKey(tui.Key{Kind: tui.KeyEsc})
79+ if !act.Close || i.settingsDialog.Active() {
80+ t.Fatal("escape did not close the direct reasoning selector")
81+ }
82+}
83+
84+func TestModelDialogAdvertisesReasoningSelector(t *testing.T) {
85+ d := newModelDialog()
86+ d.Open("", nil, "high")
87+
88+ text := strings.Join(d.Render(tui.Dark, 100), "\n")
89+ if !strings.Contains(text, "current reasoning: high") || !strings.Contains(text, "/reasoning") {
90+ t.Fatalf("model dialog missing reasoning guidance: %q", text)
91+ }
92+}
93+
8 func TestModelPickerSkipsLlamaRefreshWhenRouterIsNotConfigured(t *testing.T) {94 func TestModelPickerSkipsLlamaRefreshWhenRouterIsNotConfigured(t *testing.T) {
9 i := &Interactive{95 i := &Interactive{
10 cfg: InteractiveConfig{96 cfg: InteractiveConfig{
M packages/agent/modes/model_dialog.go +21 -7
@@ -17,12 +17,14 @@ import (
1717 // Typing characters narrows the list via a fuzzy substring match that
1818 // ignores punctuation (e.g. "opus46" matches "claude-opus-4-6").
1919 type modelDialog struct {
20- active bool
21- all []provider.Model // full catalog, sorted
22- view []provider.Model // filtered view shown to the user
23- cursor int
24- current string // currently selected model id (highlighted)
25- query string // live filter text typed by the user
20+ active bool
21+ all []provider.Model // full catalog, sorted
22+ view []provider.Model // filtered view shown to the user
23+ cursor int
24+ current string // currently selected model id (highlighted)
25+ reasoning string // current reasoning level, shown for discoverability
26+ showReasoning bool // whether this picker can route to /reasoning
27+ query string // live filter text typed by the user
2628
2729 // Column widths are computed once on Open() across the entire
2830 // catalog so the layout stays stable while the user scrolls or
@@ -46,7 +48,7 @@ func newModelDialog() *modelDialog {
4648
4749 // Open shows the dialog. current is the currently active model id so
4850 // it can be pre-selected.
49-func (d *modelDialog) Open(current string, loggedInProviders []string) {
51+func (d *modelDialog) Open(current string, loggedInProviders []string, reasoning ...string) {
5052 d.active = true
5153 // Only surface models the user can actually reach: a provider is
5254 // shown only when it has a resolvable credential (api key, oauth
@@ -65,6 +67,11 @@ func (d *modelDialog) Open(current string, loggedInProviders []string) {
6567 }
6668 d.all = sortedModels(filtered)
6769 d.current = current
70+ d.reasoning = ""
71+ d.showReasoning = len(reasoning) > 0
72+ if d.showReasoning {
73+ d.reasoning = provider.NormalizeReasoning(reasoning[0])
74+ }
6875 d.query = ""
6976 d.provW, d.idW = columnWidths(d.all)
7077 d.refilter()
@@ -147,6 +154,13 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
147154 hint += " - type to filter"
148155 }
149156 lines = append(lines, th.FG256(th.Muted, hint))
157+ if d.showReasoning {
158+ reasoning := d.reasoning
159+ if reasoning == "" {
160+ reasoning = "off"
161+ }
162+ lines = append(lines, th.FG256(th.Muted, "✦ supports reasoning; current reasoning: "+reasoning+"; change with /reasoning"))
163+ }
150164
151165 if len(d.view) == 0 {
152166 msg := " no models match " + fmt.Sprintf("%q", d.query)
@@ -17,12 +17,14 @@ import (
17 // Typing characters narrows the list via a fuzzy substring match that17 // Typing characters narrows the list via a fuzzy substring match that
18 // ignores punctuation (e.g. "opus46" matches "claude-opus-4-6").18 // ignores punctuation (e.g. "opus46" matches "claude-opus-4-6").
19 type modelDialog struct {19 type modelDialog struct {
20- active bool20+ active bool
21- all []provider.Model // full catalog, sorted21+ all []provider.Model // full catalog, sorted
22- view []provider.Model // filtered view shown to the user22+ view []provider.Model // filtered view shown to the user
23- cursor int23+ cursor int
24- current string // currently selected model id (highlighted)24+ current string // currently selected model id (highlighted)
25- query string // live filter text typed by the user25+ reasoning string // current reasoning level, shown for discoverability
26+ showReasoning bool // whether this picker can route to /reasoning
27+ query string // live filter text typed by the user
26 28
27 // Column widths are computed once on Open() across the entire29 // Column widths are computed once on Open() across the entire
28 // catalog so the layout stays stable while the user scrolls or30 // catalog so the layout stays stable while the user scrolls or
@@ -46,7 +48,7 @@ func newModelDialog() *modelDialog {
46 48
47 // Open shows the dialog. current is the currently active model id so49 // Open shows the dialog. current is the currently active model id so
48 // it can be pre-selected.50 // it can be pre-selected.
49-func (d *modelDialog) Open(current string, loggedInProviders []string) {51+func (d *modelDialog) Open(current string, loggedInProviders []string, reasoning ...string) {
50 d.active = true52 d.active = true
51 // Only surface models the user can actually reach: a provider is53 // Only surface models the user can actually reach: a provider is
52 // shown only when it has a resolvable credential (api key, oauth54 // shown only when it has a resolvable credential (api key, oauth
@@ -65,6 +67,11 @@ func (d *modelDialog) Open(current string, loggedInProviders []string) {
65 }67 }
66 d.all = sortedModels(filtered)68 d.all = sortedModels(filtered)
67 d.current = current69 d.current = current
70+ d.reasoning = ""
71+ d.showReasoning = len(reasoning) > 0
72+ if d.showReasoning {
73+ d.reasoning = provider.NormalizeReasoning(reasoning[0])
74+ }
68 d.query = ""75 d.query = ""
69 d.provW, d.idW = columnWidths(d.all)76 d.provW, d.idW = columnWidths(d.all)
70 d.refilter()77 d.refilter()
@@ -147,6 +154,13 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string {
147 hint += " - type to filter"154 hint += " - type to filter"
148 }155 }
149 lines = append(lines, th.FG256(th.Muted, hint))156 lines = append(lines, th.FG256(th.Muted, hint))
157+ if d.showReasoning {
158+ reasoning := d.reasoning
159+ if reasoning == "" {
160+ reasoning = "off"
161+ }
162+ lines = append(lines, th.FG256(th.Muted, "✦ supports reasoning; current reasoning: "+reasoning+"; change with /reasoning"))
163+ }
150 164
151 if len(d.view) == 0 {165 if len(d.view) == 0 {
152 msg := " no models match " + fmt.Sprintf("%q", d.query)166 msg := " no models match " + fmt.Sprintf("%q", d.query)
M packages/agent/modes/settings_dialog.go +37 -2
@@ -14,6 +14,7 @@ type settingsDialog struct {
1414 items []settingsItem
1515 cursor int
1616 selecting bool
17+ direct bool
1718 optionCursor int
1819 parentItems []settingsItem
1920 parentCursor int
@@ -57,6 +58,7 @@ func (d *settingsDialog) Open(items []settingsItem) bool {
5758 d.items = items
5859 d.cursor = 0
5960 d.selecting = false
61+ d.direct = false
6062 d.optionCursor = 0
6163 d.parentItems = nil
6264 d.parentCursor = 0
@@ -64,9 +66,29 @@ func (d *settingsDialog) Open(items []settingsItem) bool {
6466 return true
6567 }
6668
69+func (d *settingsDialog) OpenDirectOption(item settingsItem) bool {
70+ if len(item.options) == 0 {
71+ return false
72+ }
73+ d.title = item.label
74+ d.items = []settingsItem{item}
75+ d.cursor = 0
76+ d.selecting = true
77+ d.direct = true
78+ d.optionCursor = item.choice
79+ if d.optionCursor < 0 || d.optionCursor >= len(item.options) {
80+ d.optionCursor = 0
81+ }
82+ d.parentItems = nil
83+ d.parentCursor = 0
84+ d.active = true
85+ return true
86+}
87+
6788 func (d *settingsDialog) Close() {
6889 d.active = false
6990 d.selecting = false
91+ d.direct = false
7092 d.parentItems = nil
7193 }
7294 func (d *settingsDialog) Active() bool { return d != nil && d.active }
@@ -124,6 +146,10 @@ func (d *settingsDialog) handleOptionKey(k tui.Key) settingsAction {
124146 d.optionCursor++
125147 }
126148 case tui.KeyEsc:
149+ if d.direct {
150+ d.Close()
151+ return settingsAction{Close: true}
152+ }
127153 d.selecting = false
128154 case tui.KeyEnter:
129155 return d.selectCurrentOption()
@@ -194,7 +220,12 @@ func (d *settingsDialog) selectCurrentOption() settingsAction {
194220 it.choice = d.optionCursor
195221 d.items[d.cursor] = it
196222 d.selecting = false
197- return settingsAction{Toggle: true, Key: it.key, StringValue: it.options[it.choice].value}
223+ action := settingsAction{Toggle: true, Key: it.key, StringValue: it.options[it.choice].value}
224+ if d.direct {
225+ d.Close()
226+ action.Close = true
227+ }
228+ return action
198229 }
199230
200231 func (d *settingsDialog) Render(th tui.Theme, width int) []string {
@@ -253,7 +284,11 @@ func (d *settingsDialog) renderOptions(th tui.Theme, width int) []string {
253284 return d.Render(th, width)
254285 }
255286 it := d.items[d.cursor]
256- lines := []string{frameHeader(th, "settings: "+it.label, width)}
287+ title := "settings: " + it.label
288+ if d.direct {
289+ title = d.title
290+ }
291+ lines := []string{frameHeader(th, title, width)}
257292 if it.desc != "" {
258293 lines = append(lines, th.FG256(th.Muted, it.desc))
259294 }
@@ -14,6 +14,7 @@ type settingsDialog struct {
14 items []settingsItem14 items []settingsItem
15 cursor int15 cursor int
16 selecting bool16 selecting bool
17+ direct bool
17 optionCursor int18 optionCursor int
18 parentItems []settingsItem19 parentItems []settingsItem
19 parentCursor int20 parentCursor int
@@ -57,6 +58,7 @@ func (d *settingsDialog) Open(items []settingsItem) bool {
57 d.items = items58 d.items = items
58 d.cursor = 059 d.cursor = 0
59 d.selecting = false60 d.selecting = false
61+ d.direct = false
60 d.optionCursor = 062 d.optionCursor = 0
61 d.parentItems = nil63 d.parentItems = nil
62 d.parentCursor = 064 d.parentCursor = 0
@@ -64,9 +66,29 @@ func (d *settingsDialog) Open(items []settingsItem) bool {
64 return true66 return true
65 }67 }
66 68
69+func (d *settingsDialog) OpenDirectOption(item settingsItem) bool {
70+ if len(item.options) == 0 {
71+ return false
72+ }
73+ d.title = item.label
74+ d.items = []settingsItem{item}
75+ d.cursor = 0
76+ d.selecting = true
77+ d.direct = true
78+ d.optionCursor = item.choice
79+ if d.optionCursor < 0 || d.optionCursor >= len(item.options) {
80+ d.optionCursor = 0
81+ }
82+ d.parentItems = nil
83+ d.parentCursor = 0
84+ d.active = true
85+ return true
86+}
87+
67 func (d *settingsDialog) Close() {88 func (d *settingsDialog) Close() {
68 d.active = false89 d.active = false
69 d.selecting = false90 d.selecting = false
91+ d.direct = false
70 d.parentItems = nil92 d.parentItems = nil
71 }93 }
72 func (d *settingsDialog) Active() bool { return d != nil && d.active }94 func (d *settingsDialog) Active() bool { return d != nil && d.active }
@@ -124,6 +146,10 @@ func (d *settingsDialog) handleOptionKey(k tui.Key) settingsAction {
124 d.optionCursor++146 d.optionCursor++
125 }147 }
126 case tui.KeyEsc:148 case tui.KeyEsc:
149+ if d.direct {
150+ d.Close()
151+ return settingsAction{Close: true}
152+ }
127 d.selecting = false153 d.selecting = false
128 case tui.KeyEnter:154 case tui.KeyEnter:
129 return d.selectCurrentOption()155 return d.selectCurrentOption()
@@ -194,7 +220,12 @@ func (d *settingsDialog) selectCurrentOption() settingsAction {
194 it.choice = d.optionCursor220 it.choice = d.optionCursor
195 d.items[d.cursor] = it221 d.items[d.cursor] = it
196 d.selecting = false222 d.selecting = false
197- return settingsAction{Toggle: true, Key: it.key, StringValue: it.options[it.choice].value}223+ action := settingsAction{Toggle: true, Key: it.key, StringValue: it.options[it.choice].value}
224+ if d.direct {
225+ d.Close()
226+ action.Close = true
227+ }
228+ return action
198 }229 }
199 230
200 func (d *settingsDialog) Render(th tui.Theme, width int) []string {231 func (d *settingsDialog) Render(th tui.Theme, width int) []string {
@@ -253,7 +284,11 @@ func (d *settingsDialog) renderOptions(th tui.Theme, width int) []string {
253 return d.Render(th, width)284 return d.Render(th, width)
254 }285 }
255 it := d.items[d.cursor]286 it := d.items[d.cursor]
256- lines := []string{frameHeader(th, "settings: "+it.label, width)}287+ title := "settings: " + it.label
288+ if d.direct {
289+ title = d.title
290+ }
291+ lines := []string{frameHeader(th, title, width)}
257 if it.desc != "" {292 if it.desc != "" {
258 lines = append(lines, th.FG256(th.Muted, it.desc))293 lines = append(lines, th.FG256(th.Muted, it.desc))
259 }294 }
M packages/agent/modes/slash_suggest.go +1 -0
@@ -38,6 +38,7 @@ var slashCatalog = []slashCommand{
3838 {Name: "/login", Desc: "log in via api key or subscription"},
3939 {Name: "/logout", Desc: "clear a provider's credentials"},
4040 {Name: "/model", Desc: "pick a model (or /model <id>)"},
41+ {Name: "/reasoning", Desc: "set the reasoning level"},
4142 {Name: "/llama", Desc: "manage llama.cpp router models"},
4243 {Name: "/sessions", Desc: "resume a previous session for this directory"},
4344 {Name: "/session", Desc: "export the current session to a .zotsession file, or import one"},
@@ -38,6 +38,7 @@ var slashCatalog = []slashCommand{
38 {Name: "/login", Desc: "log in via api key or subscription"},38 {Name: "/login", Desc: "log in via api key or subscription"},
39 {Name: "/logout", Desc: "clear a provider's credentials"},39 {Name: "/logout", Desc: "clear a provider's credentials"},
40 {Name: "/model", Desc: "pick a model (or /model <id>)"},40 {Name: "/model", Desc: "pick a model (or /model <id>)"},
41+ {Name: "/reasoning", Desc: "set the reasoning level"},
41 {Name: "/llama", Desc: "manage llama.cpp router models"},42 {Name: "/llama", Desc: "manage llama.cpp router models"},
42 {Name: "/sessions", Desc: "resume a previous session for this directory"},43 {Name: "/sessions", Desc: "resume a previous session for this directory"},
43 {Name: "/session", Desc: "export the current session to a .zotsession file, or import one"},44 {Name: "/session", Desc: "export the current session to a .zotsession file, or import one"},
M packages/agent/sdk/sdk.go +1 -1
@@ -297,7 +297,7 @@ func (r *Runtime) SetModel(model string) error {
297297 return nil
298298 }
299299
300-// SetReasoning changes the thinking level used by subsequent prompts.
300+// SetReasoning changes the reasoning level used by subsequent prompts.
301301 func (r *Runtime) SetReasoning(level string) error {
302302 r.mu.Lock()
303303 defer r.mu.Unlock()
@@ -297,7 +297,7 @@ func (r *Runtime) SetModel(model string) error {
297 return nil297 return nil
298 }298 }
299 299
300-// SetReasoning changes the thinking level used by subsequent prompts.300+// SetReasoning changes the reasoning level used by subsequent prompts.
301 func (r *Runtime) SetReasoning(level string) error {301 func (r *Runtime) SetReasoning(level string) error {
302 r.mu.Lock()302 r.mu.Lock()
303 defer r.mu.Unlock()303 defer r.mu.Unlock()
M packages/provider/amazon_bedrock.go +3 -2
@@ -330,6 +330,7 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
330330 if catalogModel, err := FindModel("amazon-bedrock", resolvedModel); err == nil {
331331 model = catalogModel
332332 }
333+ reasoning := ClampReasoningForModel(model, req.Reasoning)
333334 adaptive := usesAdaptiveThinking(model)
334335
335336 if req.System != "" {
@@ -345,10 +346,10 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
345346 if !adaptive {
346347 out.InferenceConfig.Temperature = req.Temperature
347348 }
348- if adaptive && req.Reasoning != "" {
349+ if adaptive && reasoning != "" {
349350 out.AdditionalModelRequestFields = map[string]interface{}{
350351 "thinking": map[string]interface{}{"type": "adaptive"},
351- "output_config": map[string]interface{}{"effort": AnthropicAdaptiveEffort(req.Reasoning)},
352+ "output_config": map[string]interface{}{"effort": AnthropicAdaptiveEffort(reasoning)},
352353 }
353354 }
354355 out.InferenceConfig.MaxTokens = req.MaxTokens
@@ -330,6 +330,7 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
330 if catalogModel, err := FindModel("amazon-bedrock", resolvedModel); err == nil {330 if catalogModel, err := FindModel("amazon-bedrock", resolvedModel); err == nil {
331 model = catalogModel331 model = catalogModel
332 }332 }
333+ reasoning := ClampReasoningForModel(model, req.Reasoning)
333 adaptive := usesAdaptiveThinking(model)334 adaptive := usesAdaptiveThinking(model)
334 335
335 if req.System != "" {336 if req.System != "" {
@@ -345,10 +346,10 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
345 if !adaptive {346 if !adaptive {
346 out.InferenceConfig.Temperature = req.Temperature347 out.InferenceConfig.Temperature = req.Temperature
347 }348 }
348- if adaptive && req.Reasoning != "" {349+ if adaptive && reasoning != "" {
349 out.AdditionalModelRequestFields = map[string]interface{}{350 out.AdditionalModelRequestFields = map[string]interface{}{
350 "thinking": map[string]interface{}{"type": "adaptive"},351 "thinking": map[string]interface{}{"type": "adaptive"},
351- "output_config": map[string]interface{}{"effort": AnthropicAdaptiveEffort(req.Reasoning)},352+ "output_config": map[string]interface{}{"effort": AnthropicAdaptiveEffort(reasoning)},
352 }353 }
353 }354 }
354 out.InferenceConfig.MaxTokens = req.MaxTokens355 out.InferenceConfig.MaxTokens = req.MaxTokens
M packages/provider/anthropic.go +4 -3
@@ -230,6 +230,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
230230 maxTok = m.MaxOutput
231231 }
232232
233+ reasoning := ClampReasoningForModel(m, req.Reasoning)
233234 adaptive := usesAdaptiveThinking(m)
234235 adaptiveEffort := adaptive && !m.AdaptiveThinkingCompat
235236
@@ -284,19 +285,19 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
284285 }}
285286 }
286287
287- if req.Reasoning != "" && m.Reasoning {
288+ if reasoning != "" && m.Reasoning {
288289 if adaptive {
289290 // Adaptive-thinking models decide when and how much to think and
290291 // reject explicit budgets. Anthropic models also accept the
291292 // output_config.effort extension; compatible implementations may not.
292293 out.Thinking = &anthThinking{Type: "adaptive"}
293294 if adaptiveEffort {
294- if effort := AnthropicAdaptiveEffort(req.Reasoning); effort != "" {
295+ if effort := AnthropicAdaptiveEffort(reasoning); effort != "" {
295296 out.OutputConfig = &anthOutputConfig{Effort: effort}
296297 }
297298 }
298299 } else {
299- budget := anthropicReasoningBudget(req.Reasoning)
300+ budget := anthropicReasoningBudget(reasoning)
300301 if budget > 0 {
301302 // Reasoning requires max_tokens > budget. Keep at least a small
302303 // answer budget while respecting the model's advertised output cap.
@@ -230,6 +230,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
230 maxTok = m.MaxOutput230 maxTok = m.MaxOutput
231 }231 }
232 232
233+ reasoning := ClampReasoningForModel(m, req.Reasoning)
233 adaptive := usesAdaptiveThinking(m)234 adaptive := usesAdaptiveThinking(m)
234 adaptiveEffort := adaptive && !m.AdaptiveThinkingCompat235 adaptiveEffort := adaptive && !m.AdaptiveThinkingCompat
235 236
@@ -284,19 +285,19 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
284 }}285 }}
285 }286 }
286 287
287- if req.Reasoning != "" && m.Reasoning {288+ if reasoning != "" && m.Reasoning {
288 if adaptive {289 if adaptive {
289 // Adaptive-thinking models decide when and how much to think and290 // Adaptive-thinking models decide when and how much to think and
290 // reject explicit budgets. Anthropic models also accept the291 // reject explicit budgets. Anthropic models also accept the
291 // output_config.effort extension; compatible implementations may not.292 // output_config.effort extension; compatible implementations may not.
292 out.Thinking = &anthThinking{Type: "adaptive"}293 out.Thinking = &anthThinking{Type: "adaptive"}
293 if adaptiveEffort {294 if adaptiveEffort {
294- if effort := AnthropicAdaptiveEffort(req.Reasoning); effort != "" {295+ if effort := AnthropicAdaptiveEffort(reasoning); effort != "" {
295 out.OutputConfig = &anthOutputConfig{Effort: effort}296 out.OutputConfig = &anthOutputConfig{Effort: effort}
296 }297 }
297 }298 }
298 } else {299 } else {
299- budget := anthropicReasoningBudget(req.Reasoning)300+ budget := anthropicReasoningBudget(reasoning)
300 if budget > 0 {301 if budget > 0 {
301 // Reasoning requires max_tokens > budget. Keep at least a small302 // Reasoning requires max_tokens > budget. Keep at least a small
302 // answer budget while respecting the model's advertised output cap.303 // answer budget while respecting the model's advertised output cap.
M packages/provider/gemini.go +3 -2
@@ -141,6 +141,7 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
141141 }
142142 }
143143
144+ reasoning := ClampReasoningForModel(m, req.Reasoning)
144145 out := &gemRequest{}
145146
146147 // System prompt → systemInstruction.parts[0].text.
@@ -178,8 +179,8 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
178179 if maxTok > 0 {
179180 gc.MaxOutputTokens = &maxTok
180181 }
181- if req.Reasoning != "" && m.Reasoning {
182- tc := geminiThinkingConfig(m.ID, req.Reasoning)
182+ if reasoning != "" && m.Reasoning {
183+ tc := geminiThinkingConfig(m.ID, reasoning)
183184 if tc != nil {
184185 gc.ThinkingConfig = tc
185186 }
@@ -141,6 +141,7 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
141 }141 }
142 }142 }
143 143
144+ reasoning := ClampReasoningForModel(m, req.Reasoning)
144 out := &gemRequest{}145 out := &gemRequest{}
145 146
146 // System prompt → systemInstruction.parts[0].text.147 // System prompt → systemInstruction.parts[0].text.
@@ -178,8 +179,8 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
178 if maxTok > 0 {179 if maxTok > 0 {
179 gc.MaxOutputTokens = &maxTok180 gc.MaxOutputTokens = &maxTok
180 }181 }
181- if req.Reasoning != "" && m.Reasoning {182+ if reasoning != "" && m.Reasoning {
182- tc := geminiThinkingConfig(m.ID, req.Reasoning)183+ tc := geminiThinkingConfig(m.ID, reasoning)
183 if tc != nil {184 if tc != nil {
184 gc.ThinkingConfig = tc185 gc.ThinkingConfig = tc
185 }186 }
M packages/provider/models.go +13 -7
@@ -2,18 +2,20 @@ package provider
22
33 import (
44 "fmt"
5+ "maps"
56 "sync"
67 )
78
89 // Model describes a single LLM we know about.
910 type Model struct {
10- Provider string // "anthropic" | "openai"
11- ID string // API id
12- DisplayName string
13- API string // wire API override for providers that support multiple protocols
14- ContextWindow int
15- MaxOutput int
16- Reasoning bool // supports reasoning/thinking
11+ Provider string // "anthropic" | "openai"
12+ ID string // API id
13+ DisplayName string
14+ API string // wire API override for providers that support multiple protocols
15+ ContextWindow int
16+ MaxOutput int
17+ Reasoning bool // supports reasoning
18+ ReasoningLevelMap map[string]string // optional level overrides; empty values remove a level
1719
1820 // AdaptiveThinking marks Anthropic models that only support the
1921 // adaptive thinking mode (Opus 4.7+). These reject explicit
@@ -448,6 +450,9 @@ func Active() []Model {
448450 }
449451 out := make([]Model, len(src))
450452 copy(out, src)
453+ for i := range out {
454+ out[i].ReasoningLevelMap = maps.Clone(out[i].ReasoningLevelMap)
455+ }
451456 if len(managedModels) == 0 {
452457 return out
453458 }
@@ -456,6 +461,7 @@ func Active() []Model {
456461 index[model.Provider+"\x00"+model.ID] = i
457462 }
458463 for _, model := range managedModels {
464+ model.ReasoningLevelMap = maps.Clone(model.ReasoningLevelMap)
459465 key := model.Provider + "\x00" + model.ID
460466 if i, ok := index[key]; ok {
461467 out[i] = model
@@ -2,18 +2,20 @@ package provider
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "maps"
5 "sync"6 "sync"
6 )7 )
7 8
8 // Model describes a single LLM we know about.9 // Model describes a single LLM we know about.
9 type Model struct {10 type Model struct {
10- Provider string // "anthropic" | "openai"11+ Provider string // "anthropic" | "openai"
11- ID string // API id12+ ID string // API id
12- DisplayName string13+ DisplayName string
13- API string // wire API override for providers that support multiple protocols14+ API string // wire API override for providers that support multiple protocols
14- ContextWindow int15+ ContextWindow int
15- MaxOutput int16+ MaxOutput int
16- Reasoning bool // supports reasoning/thinking17+ Reasoning bool // supports reasoning
18+ ReasoningLevelMap map[string]string // optional level overrides; empty values remove a level
17 19
18 // AdaptiveThinking marks Anthropic models that only support the20 // AdaptiveThinking marks Anthropic models that only support the
19 // adaptive thinking mode (Opus 4.7+). These reject explicit21 // adaptive thinking mode (Opus 4.7+). These reject explicit
@@ -448,6 +450,9 @@ func Active() []Model {
448 }450 }
449 out := make([]Model, len(src))451 out := make([]Model, len(src))
450 copy(out, src)452 copy(out, src)
453+ for i := range out {
454+ out[i].ReasoningLevelMap = maps.Clone(out[i].ReasoningLevelMap)
455+ }
451 if len(managedModels) == 0 {456 if len(managedModels) == 0 {
452 return out457 return out
453 }458 }
@@ -456,6 +461,7 @@ func Active() []Model {
456 index[model.Provider+"\x00"+model.ID] = i461 index[model.Provider+"\x00"+model.ID] = i
457 }462 }
458 for _, model := range managedModels {463 for _, model := range managedModels {
464+ model.ReasoningLevelMap = maps.Clone(model.ReasoningLevelMap)
459 key := model.Provider + "\x00" + model.ID465 key := model.Provider + "\x00" + model.ID
460 if i, ok := index[key]; ok {466 if i, ok := index[key]; ok {
461 out[i] = model467 out[i] = model
M packages/provider/openai.go +9 -6
@@ -181,10 +181,12 @@ type oaiRequest struct {
181181 // ---- request building ----
182182
183183 func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
184- // Look up the model by id across all providers (not just openai)
185- // because the OpenAI client is also used for ollama and other
186- // OpenAI-compatible backends.
187- m, err := FindModel("", req.Model)
184+ // The OpenAI wire client is shared by many providers, so prefer its own
185+ // provider namespace before falling back to a provider-agnostic lookup.
186+ m, err := FindModel(c.Name(), req.Model)
187+ if err != nil {
188+ m, err = FindModel("", req.Model)
189+ }
188190 if err != nil {
189191 // Unknown model: use sensible defaults so local/custom
190192 // models still work without a catalog entry.
@@ -194,6 +196,7 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
194196 MaxOutput: 8192,
195197 }
196198 }
199+ reasoning := ClampReasoningForModel(m, req.Reasoning)
197200 out := &oaiRequest{
198201 Model: req.Model,
199202 Stream: true,
@@ -246,13 +249,13 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
246249 if maxTok > 0 {
247250 out.MaxCompletionTok = &maxTok
248251 }
249- effort := OpenAIReasoningEffort(req.Reasoning)
252+ effort := OpenAIReasoningEffort(reasoning)
250253 if usesAdaptiveThinking(m) {
251254 // Some gateways expose adaptive-thinking Anthropic models through
252255 // the OpenAI-compatible chat-completions wire. They accept the
253256 // same reasoning_effort knob, including the top "xhigh" tier;
254257 // don't clamp zot's "maximum" to "high" for those models.
255- effort = OpenAICompatAnthropicEffort(req.Reasoning)
258+ effort = OpenAICompatAnthropicEffort(reasoning)
256259 }
257260 if effort != "" {
258261 out.ReasoningEffort = effort
@@ -181,10 +181,12 @@ type oaiRequest struct {
181 // ---- request building ----181 // ---- request building ----
182 182
183 func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {183 func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
184- // Look up the model by id across all providers (not just openai)184+ // The OpenAI wire client is shared by many providers, so prefer its own
185- // because the OpenAI client is also used for ollama and other185+ // provider namespace before falling back to a provider-agnostic lookup.
186- // OpenAI-compatible backends.186+ m, err := FindModel(c.Name(), req.Model)
187- m, err := FindModel("", req.Model)187+ if err != nil {
188+ m, err = FindModel("", req.Model)
189+ }
188 if err != nil {190 if err != nil {
189 // Unknown model: use sensible defaults so local/custom191 // Unknown model: use sensible defaults so local/custom
190 // models still work without a catalog entry.192 // models still work without a catalog entry.
@@ -194,6 +196,7 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
194 MaxOutput: 8192,196 MaxOutput: 8192,
195 }197 }
196 }198 }
199+ reasoning := ClampReasoningForModel(m, req.Reasoning)
197 out := &oaiRequest{200 out := &oaiRequest{
198 Model: req.Model,201 Model: req.Model,
199 Stream: true,202 Stream: true,
@@ -246,13 +249,13 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
246 if maxTok > 0 {249 if maxTok > 0 {
247 out.MaxCompletionTok = &maxTok250 out.MaxCompletionTok = &maxTok
248 }251 }
249- effort := OpenAIReasoningEffort(req.Reasoning)252+ effort := OpenAIReasoningEffort(reasoning)
250 if usesAdaptiveThinking(m) {253 if usesAdaptiveThinking(m) {
251 // Some gateways expose adaptive-thinking Anthropic models through254 // Some gateways expose adaptive-thinking Anthropic models through
252 // the OpenAI-compatible chat-completions wire. They accept the255 // the OpenAI-compatible chat-completions wire. They accept the
253 // same reasoning_effort knob, including the top "xhigh" tier;256 // same reasoning_effort knob, including the top "xhigh" tier;
254 // don't clamp zot's "maximum" to "high" for those models.257 // don't clamp zot's "maximum" to "high" for those models.
255- effort = OpenAICompatAnthropicEffort(req.Reasoning)258+ effort = OpenAICompatAnthropicEffort(reasoning)
256 }259 }
257 if effort != "" {260 if effort != "" {
258 out.ReasoningEffort = effort261 out.ReasoningEffort = effort
M packages/provider/openai_codex.go +2 -2
@@ -184,7 +184,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
184184 if err != nil {
185185 return nil, err
186186 }
187- _ = m
187+ reasoning := ClampReasoningForModel(m, req.Reasoning)
188188
189189 body := &codexRequest{
190190 Model: req.Model,
@@ -195,7 +195,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
195195 Include: []string{"reasoning.encrypted_content"},
196196 }
197197 if m.Reasoning {
198- if effort := OpenAICodexReasoningEffort(req.Reasoning, req.Model); effort != "" {
198+ if effort := OpenAICodexReasoningEffort(reasoning, req.Model); effort != "" {
199199 body.Reasoning = &codexReasoningConfig{Effort: effort}
200200 }
201201 }
@@ -184,7 +184,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
184 if err != nil {184 if err != nil {
185 return nil, err185 return nil, err
186 }186 }
187- _ = m187+ reasoning := ClampReasoningForModel(m, req.Reasoning)
188 188
189 body := &codexRequest{189 body := &codexRequest{
190 Model: req.Model,190 Model: req.Model,
@@ -195,7 +195,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
195 Include: []string{"reasoning.encrypted_content"},195 Include: []string{"reasoning.encrypted_content"},
196 }196 }
197 if m.Reasoning {197 if m.Reasoning {
198- if effort := OpenAICodexReasoningEffort(req.Reasoning, req.Model); effort != "" {198+ if effort := OpenAICodexReasoningEffort(reasoning, req.Model); effort != "" {
199 body.Reasoning = &codexReasoningConfig{Effort: effort}199 body.Reasoning = &codexReasoningConfig{Effort: effort}
200 }200 }
201 }201 }
M packages/provider/reasoning.go +149 -4
@@ -2,8 +2,153 @@ package provider
22
33 import "strings"
44
5-// NormalizeReasoning canonicalizes zot's user-facing thinking levels.
6-// Empty string means reasoning/thinking is disabled. "maximum" remains
5+var reasoningLevelOrder = []string{"", "minimum", "low", "medium", "high", "xhigh", "max"}
6+
7+// AvailableReasoningLevels returns the distinct reasoning levels supported by
8+// a model. Protocol defaults are adjusted by optional per-model overrides. The
9+// empty string represents off.
10+func AvailableReasoningLevels(model Model) []string {
11+ defaults := defaultReasoningLevels(model)
12+ if !model.Reasoning || len(model.ReasoningLevelMap) == 0 {
13+ return defaults
14+ }
15+
16+ available := map[string]bool{"": true}
17+ for _, level := range reasoningLevelOrder[1:] {
18+ if !containsReasoningLevel(defaults, level) {
19+ if _, overridden := model.ReasoningLevelMap[level]; !overridden {
20+ continue
21+ }
22+ }
23+ effective := level
24+ if mapped, overridden := model.ReasoningLevelMap[level]; overridden {
25+ effective = NormalizeReasoning(mapped)
26+ }
27+ if effective != "" && !containsReasoningLevel(defaults, effective) {
28+ effective = nearestReasoningLevel(defaults, effective)
29+ }
30+ if reasoningLevelRank(effective) > 0 {
31+ available[effective] = true
32+ }
33+ }
34+
35+ levels := []string{""}
36+ for _, level := range reasoningLevelOrder[1:] {
37+ if available[level] {
38+ levels = append(levels, level)
39+ }
40+ }
41+ return levels
42+}
43+
44+func defaultReasoningLevels(model Model) []string {
45+ if !model.Reasoning {
46+ return []string{""}
47+ }
48+
49+ id := strings.ToLower(model.ID)
50+ if (model.Provider == "google" || model.Provider == "google-vertex") && strings.Contains(id, "gemini-3") {
51+ if strings.Contains(id, "-pro") {
52+ return []string{"", "low", "high"}
53+ }
54+ return []string{"", "minimum", "low", "medium", "high"}
55+ }
56+ if model.AdaptiveThinkingCompat {
57+ return []string{"", "high"}
58+ }
59+ if model.AdaptiveThinking {
60+ return []string{"", "low", "medium", "high", "xhigh", "max"}
61+ }
62+ if model.API == APIResponses || model.Provider == "openai-codex" || model.Provider == "openai-responses" || model.Provider == "azure-openai-responses" {
63+ levels := []string{"", "low", "medium", "high", "xhigh"}
64+ if strings.HasPrefix(id, "gpt-5.6-") {
65+ levels = append(levels, "max")
66+ }
67+ return levels
68+ }
69+ if model.Provider == "google" || model.Provider == "google-vertex" {
70+ if strings.Contains(id, "gemini-2.5") {
71+ return []string{"", "minimum", "low", "medium", "high", "xhigh"}
72+ }
73+ return []string{""}
74+ }
75+ if usesReasoningBudget(model) {
76+ return []string{"", "minimum", "low", "medium", "high", "xhigh"}
77+ }
78+ if model.Provider == "amazon-bedrock" {
79+ return []string{""}
80+ }
81+ return []string{"", "low", "medium", "high"}
82+}
83+
84+func containsReasoningLevel(levels []string, target string) bool {
85+ for _, level := range levels {
86+ if level == target {
87+ return true
88+ }
89+ }
90+ return false
91+}
92+
93+// ClampReasoningForModel maps a configured level to the nearest level exposed
94+// for the active model. Ties prefer the higher level.
95+func ClampReasoningForModel(model Model, level string) string {
96+ normalized := NormalizeReasoning(level)
97+ available := AvailableReasoningLevels(model)
98+ if mapped, overridden := model.ReasoningLevelMap[normalized]; overridden {
99+ target := NormalizeReasoning(mapped)
100+ if target != "" && containsReasoningLevel(available, target) {
101+ return target
102+ }
103+ }
104+ for _, candidate := range available {
105+ if candidate == normalized {
106+ return candidate
107+ }
108+ }
109+ return nearestReasoningLevel(available, normalized)
110+}
111+
112+func nearestReasoningLevel(available []string, requested string) string {
113+ if requested == "" || len(available) == 1 {
114+ return ""
115+ }
116+ requestedRank := reasoningLevelRank(requested)
117+ if requestedRank == 0 {
118+ return ""
119+ }
120+ best, bestDistance := available[1], len(reasoningLevelOrder)
121+ for _, candidate := range available[1:] {
122+ distance := reasoningLevelRank(candidate) - requestedRank
123+ if distance < 0 {
124+ distance = -distance
125+ }
126+ if distance <= bestDistance {
127+ best, bestDistance = candidate, distance
128+ }
129+ }
130+ return best
131+}
132+
133+func reasoningLevelRank(level string) int {
134+ for rank, candidate := range reasoningLevelOrder {
135+ if candidate == level {
136+ return rank
137+ }
138+ }
139+ return 0
140+}
141+
142+func usesReasoningBudget(model Model) bool {
143+ switch model.Provider {
144+ case "anthropic", "fireworks", "kimi", "minimax", "minimax-cn", "vercel-ai-gateway":
145+ return true
146+ }
147+ return model.API == "anthropic"
148+}
149+
150+// NormalizeReasoning canonicalizes zot's user-facing reasoning levels.
151+// Empty string means reasoning is disabled. "maximum" remains
7152 // an alias for xhigh; "max" is the separate opt-in tier above it.
8153 func NormalizeReasoning(level string) string {
9154 switch strings.ToLower(strings.TrimSpace(level)) {
@@ -26,7 +171,7 @@ func NormalizeReasoning(level string) string {
26171 }
27172 }
28173
29-// ReasoningBudget returns zot's approximate token budget for thinking-capable
174+// ReasoningBudget returns zot's approximate token budget for reasoning-capable
30175 // providers that accept explicit budgets.
31176 func ReasoningBudget(level string) int {
32177 switch NormalizeReasoning(level) {
@@ -45,7 +190,7 @@ func ReasoningBudget(level string) int {
45190 }
46191 }
47192
48-// AnthropicAdaptiveEffort maps zot's user-facing thinking levels onto the
193+// AnthropicAdaptiveEffort maps zot's user-facing reasoning levels onto the
49194 // effort enum used by adaptive-thinking models. These models reject explicit
50195 // thinking budgets; reasoning depth is controlled by output_config.effort.
51196 func AnthropicAdaptiveEffort(level string) string {
@@ -2,8 +2,153 @@ package provider
2 2
3 import "strings"3 import "strings"
4 4
5-// NormalizeReasoning canonicalizes zot's user-facing thinking levels.5+var reasoningLevelOrder = []string{"", "minimum", "low", "medium", "high", "xhigh", "max"}
6-// Empty string means reasoning/thinking is disabled. "maximum" remains6+
7+// AvailableReasoningLevels returns the distinct reasoning levels supported by
8+// a model. Protocol defaults are adjusted by optional per-model overrides. The
9+// empty string represents off.
10+func AvailableReasoningLevels(model Model) []string {
11+ defaults := defaultReasoningLevels(model)
12+ if !model.Reasoning || len(model.ReasoningLevelMap) == 0 {
13+ return defaults
14+ }
15+
16+ available := map[string]bool{"": true}
17+ for _, level := range reasoningLevelOrder[1:] {
18+ if !containsReasoningLevel(defaults, level) {
19+ if _, overridden := model.ReasoningLevelMap[level]; !overridden {
20+ continue
21+ }
22+ }
23+ effective := level
24+ if mapped, overridden := model.ReasoningLevelMap[level]; overridden {
25+ effective = NormalizeReasoning(mapped)
26+ }
27+ if effective != "" && !containsReasoningLevel(defaults, effective) {
28+ effective = nearestReasoningLevel(defaults, effective)
29+ }
30+ if reasoningLevelRank(effective) > 0 {
31+ available[effective] = true
32+ }
33+ }
34+
35+ levels := []string{""}
36+ for _, level := range reasoningLevelOrder[1:] {
37+ if available[level] {
38+ levels = append(levels, level)
39+ }
40+ }
41+ return levels
42+}
43+
44+func defaultReasoningLevels(model Model) []string {
45+ if !model.Reasoning {
46+ return []string{""}
47+ }
48+
49+ id := strings.ToLower(model.ID)
50+ if (model.Provider == "google" || model.Provider == "google-vertex") && strings.Contains(id, "gemini-3") {
51+ if strings.Contains(id, "-pro") {
52+ return []string{"", "low", "high"}
53+ }
54+ return []string{"", "minimum", "low", "medium", "high"}
55+ }
56+ if model.AdaptiveThinkingCompat {
57+ return []string{"", "high"}
58+ }
59+ if model.AdaptiveThinking {
60+ return []string{"", "low", "medium", "high", "xhigh", "max"}
61+ }
62+ if model.API == APIResponses || model.Provider == "openai-codex" || model.Provider == "openai-responses" || model.Provider == "azure-openai-responses" {
63+ levels := []string{"", "low", "medium", "high", "xhigh"}
64+ if strings.HasPrefix(id, "gpt-5.6-") {
65+ levels = append(levels, "max")
66+ }
67+ return levels
68+ }
69+ if model.Provider == "google" || model.Provider == "google-vertex" {
70+ if strings.Contains(id, "gemini-2.5") {
71+ return []string{"", "minimum", "low", "medium", "high", "xhigh"}
72+ }
73+ return []string{""}
74+ }
75+ if usesReasoningBudget(model) {
76+ return []string{"", "minimum", "low", "medium", "high", "xhigh"}
77+ }
78+ if model.Provider == "amazon-bedrock" {
79+ return []string{""}
80+ }
81+ return []string{"", "low", "medium", "high"}
82+}
83+
84+func containsReasoningLevel(levels []string, target string) bool {
85+ for _, level := range levels {
86+ if level == target {
87+ return true
88+ }
89+ }
90+ return false
91+}
92+
93+// ClampReasoningForModel maps a configured level to the nearest level exposed
94+// for the active model. Ties prefer the higher level.
95+func ClampReasoningForModel(model Model, level string) string {
96+ normalized := NormalizeReasoning(level)
97+ available := AvailableReasoningLevels(model)
98+ if mapped, overridden := model.ReasoningLevelMap[normalized]; overridden {
99+ target := NormalizeReasoning(mapped)
100+ if target != "" && containsReasoningLevel(available, target) {
101+ return target
102+ }
103+ }
104+ for _, candidate := range available {
105+ if candidate == normalized {
106+ return candidate
107+ }
108+ }
109+ return nearestReasoningLevel(available, normalized)
110+}
111+
112+func nearestReasoningLevel(available []string, requested string) string {
113+ if requested == "" || len(available) == 1 {
114+ return ""
115+ }
116+ requestedRank := reasoningLevelRank(requested)
117+ if requestedRank == 0 {
118+ return ""
119+ }
120+ best, bestDistance := available[1], len(reasoningLevelOrder)
121+ for _, candidate := range available[1:] {
122+ distance := reasoningLevelRank(candidate) - requestedRank
123+ if distance < 0 {
124+ distance = -distance
125+ }
126+ if distance <= bestDistance {
127+ best, bestDistance = candidate, distance
128+ }
129+ }
130+ return best
131+}
132+
133+func reasoningLevelRank(level string) int {
134+ for rank, candidate := range reasoningLevelOrder {
135+ if candidate == level {
136+ return rank
137+ }
138+ }
139+ return 0
140+}
141+
142+func usesReasoningBudget(model Model) bool {
143+ switch model.Provider {
144+ case "anthropic", "fireworks", "kimi", "minimax", "minimax-cn", "vercel-ai-gateway":
145+ return true
146+ }
147+ return model.API == "anthropic"
148+}
149+
150+// NormalizeReasoning canonicalizes zot's user-facing reasoning levels.
151+// Empty string means reasoning is disabled. "maximum" remains
7 // an alias for xhigh; "max" is the separate opt-in tier above it.152 // an alias for xhigh; "max" is the separate opt-in tier above it.
8 func NormalizeReasoning(level string) string {153 func NormalizeReasoning(level string) string {
9 switch strings.ToLower(strings.TrimSpace(level)) {154 switch strings.ToLower(strings.TrimSpace(level)) {
@@ -26,7 +171,7 @@ func NormalizeReasoning(level string) string {
26 }171 }
27 }172 }
28 173
29-// ReasoningBudget returns zot's approximate token budget for thinking-capable174+// ReasoningBudget returns zot's approximate token budget for reasoning-capable
30 // providers that accept explicit budgets.175 // providers that accept explicit budgets.
31 func ReasoningBudget(level string) int {176 func ReasoningBudget(level string) int {
32 switch NormalizeReasoning(level) {177 switch NormalizeReasoning(level) {
@@ -45,7 +190,7 @@ func ReasoningBudget(level string) int {
45 }190 }
46 }191 }
47 192
48-// AnthropicAdaptiveEffort maps zot's user-facing thinking levels onto the193+// AnthropicAdaptiveEffort maps zot's user-facing reasoning levels onto the
49 // effort enum used by adaptive-thinking models. These models reject explicit194 // effort enum used by adaptive-thinking models. These models reject explicit
50 // thinking budgets; reasoning depth is controlled by output_config.effort.195 // thinking budgets; reasoning depth is controlled by output_config.effort.
51 func AnthropicAdaptiveEffort(level string) string {196 func AnthropicAdaptiveEffort(level string) string {
M packages/provider/reasoning_test.go +87 -1
@@ -1,6 +1,92 @@
11 package provider
22
3-import "testing"
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+func TestAvailableReasoningLevels(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ model Model
12+ want []string
13+ }{
14+ {name: "unsupported", model: Model{}, want: []string{""}},
15+ {name: "generic openai", model: Model{Reasoning: true}, want: []string{"", "low", "medium", "high"}},
16+ {name: "responses", model: Model{Reasoning: true, API: APIResponses, ID: "gpt-5.5"}, want: []string{"", "low", "medium", "high", "xhigh"}},
17+ {name: "responses native max", model: Model{Reasoning: true, API: APIResponses, ID: "gpt-5.6-sol"}, want: []string{"", "low", "medium", "high", "xhigh", "max"}},
18+ {name: "adaptive", model: Model{Reasoning: true, AdaptiveThinking: true}, want: []string{"", "low", "medium", "high", "xhigh", "max"}},
19+ {name: "adaptive compatible", model: Model{Reasoning: true, AdaptiveThinkingCompat: true}, want: []string{"", "high"}},
20+ {name: "gemini pro", model: Model{Provider: "google", ID: "gemini-3-pro-preview", Reasoning: true}, want: []string{"", "low", "high"}},
21+ {name: "gemini flash", model: Model{Provider: "google", ID: "gemini-3-flash-preview", Reasoning: true}, want: []string{"", "minimum", "low", "medium", "high"}},
22+ {name: "gemini 2.5 budget", model: Model{Provider: "google", ID: "gemini-2.5-flash", Reasoning: true}, want: []string{"", "minimum", "low", "medium", "high", "xhigh"}},
23+ {name: "gemini alias without control", model: Model{Provider: "google", ID: "gemini-flash-latest", Reasoning: true}, want: []string{""}},
24+ {name: "anthropic budget", model: Model{Provider: "anthropic", Reasoning: true}, want: []string{"", "minimum", "low", "medium", "high", "xhigh"}},
25+ {name: "bedrock without effort control", model: Model{Provider: "amazon-bedrock", Reasoning: true}, want: []string{""}},
26+ {
27+ name: "per-model overrides",
28+ model: Model{
29+ Provider: "anthropic", Reasoning: true,
30+ ReasoningLevelMap: map[string]string{"minimum": "low", "high": "", "max": "max"},
31+ },
32+ want: []string{"", "low", "medium", "xhigh"},
33+ },
34+ }
35+ for _, tt := range tests {
36+ t.Run(tt.name, func(t *testing.T) {
37+ if got := AvailableReasoningLevels(tt.model); !slices.Equal(got, tt.want) {
38+ t.Fatalf("levels = %q, want %q", got, tt.want)
39+ }
40+ })
41+ }
42+}
43+
44+func TestClampReasoningForModel(t *testing.T) {
45+ tests := []struct {
46+ name string
47+ model Model
48+ level string
49+ want string
50+ }{
51+ {name: "unsupported", model: Model{}, level: "high", want: ""},
52+ {name: "generic max", model: Model{Reasoning: true}, level: "max", want: "high"},
53+ {name: "adaptive minimum", model: Model{Reasoning: true, AdaptiveThinking: true}, level: "minimum", want: "low"},
54+ {name: "gemini pro medium", model: Model{Provider: "google", ID: "gemini-3-pro", Reasoning: true}, level: "medium", want: "high"},
55+ {name: "native max", model: Model{Reasoning: true, API: APIResponses, ID: "gpt-5.6-sol"}, level: "max", want: "max"},
56+ {
57+ name: "explicit equivalent",
58+ model: Model{Reasoning: true, ReasoningLevelMap: map[string]string{"minimum": "high"}},
59+ level: "minimum",
60+ want: "high",
61+ },
62+ }
63+ for _, tt := range tests {
64+ t.Run(tt.name, func(t *testing.T) {
65+ if got := ClampReasoningForModel(tt.model, tt.level); got != tt.want {
66+ t.Fatalf("level = %q, want %q", got, tt.want)
67+ }
68+ })
69+ }
70+}
71+
72+func TestOpenAIRequestUsesReasoningLevelMap(t *testing.T) {
73+ SetLiveModels([]Model{{
74+ Provider: "openai",
75+ ID: "mapped-reasoning-model",
76+ Reasoning: true,
77+ ReasoningLevelMap: map[string]string{"high": "low"},
78+ }})
79+ t.Cleanup(func() { SetLiveModels(nil) })
80+
81+ client := NewOpenAI("test", "").(*openaiClient)
82+ request, err := client.buildRequest(Request{Model: "mapped-reasoning-model", Reasoning: "high"})
83+ if err != nil {
84+ t.Fatal(err)
85+ }
86+ if request.ReasoningEffort != "low" {
87+ t.Fatalf("reasoning effort = %q, want low", request.ReasoningEffort)
88+ }
89+}
490
591 func TestReasoningEffortMappings(t *testing.T) {
692 cases := []struct {
@@ -1,6 +1,92 @@
1 package provider1 package provider
2 2
3-import "testing"3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+func TestAvailableReasoningLevels(t *testing.T) {
9+ tests := []struct {
10+ name string
11+ model Model
12+ want []string
13+ }{
14+ {name: "unsupported", model: Model{}, want: []string{""}},
15+ {name: "generic openai", model: Model{Reasoning: true}, want: []string{"", "low", "medium", "high"}},
16+ {name: "responses", model: Model{Reasoning: true, API: APIResponses, ID: "gpt-5.5"}, want: []string{"", "low", "medium", "high", "xhigh"}},
17+ {name: "responses native max", model: Model{Reasoning: true, API: APIResponses, ID: "gpt-5.6-sol"}, want: []string{"", "low", "medium", "high", "xhigh", "max"}},
18+ {name: "adaptive", model: Model{Reasoning: true, AdaptiveThinking: true}, want: []string{"", "low", "medium", "high", "xhigh", "max"}},
19+ {name: "adaptive compatible", model: Model{Reasoning: true, AdaptiveThinkingCompat: true}, want: []string{"", "high"}},
20+ {name: "gemini pro", model: Model{Provider: "google", ID: "gemini-3-pro-preview", Reasoning: true}, want: []string{"", "low", "high"}},
21+ {name: "gemini flash", model: Model{Provider: "google", ID: "gemini-3-flash-preview", Reasoning: true}, want: []string{"", "minimum", "low", "medium", "high"}},
22+ {name: "gemini 2.5 budget", model: Model{Provider: "google", ID: "gemini-2.5-flash", Reasoning: true}, want: []string{"", "minimum", "low", "medium", "high", "xhigh"}},
23+ {name: "gemini alias without control", model: Model{Provider: "google", ID: "gemini-flash-latest", Reasoning: true}, want: []string{""}},
24+ {name: "anthropic budget", model: Model{Provider: "anthropic", Reasoning: true}, want: []string{"", "minimum", "low", "medium", "high", "xhigh"}},
25+ {name: "bedrock without effort control", model: Model{Provider: "amazon-bedrock", Reasoning: true}, want: []string{""}},
26+ {
27+ name: "per-model overrides",
28+ model: Model{
29+ Provider: "anthropic", Reasoning: true,
30+ ReasoningLevelMap: map[string]string{"minimum": "low", "high": "", "max": "max"},
31+ },
32+ want: []string{"", "low", "medium", "xhigh"},
33+ },
34+ }
35+ for _, tt := range tests {
36+ t.Run(tt.name, func(t *testing.T) {
37+ if got := AvailableReasoningLevels(tt.model); !slices.Equal(got, tt.want) {
38+ t.Fatalf("levels = %q, want %q", got, tt.want)
39+ }
40+ })
41+ }
42+}
43+
44+func TestClampReasoningForModel(t *testing.T) {
45+ tests := []struct {
46+ name string
47+ model Model
48+ level string
49+ want string
50+ }{
51+ {name: "unsupported", model: Model{}, level: "high", want: ""},
52+ {name: "generic max", model: Model{Reasoning: true}, level: "max", want: "high"},
53+ {name: "adaptive minimum", model: Model{Reasoning: true, AdaptiveThinking: true}, level: "minimum", want: "low"},
54+ {name: "gemini pro medium", model: Model{Provider: "google", ID: "gemini-3-pro", Reasoning: true}, level: "medium", want: "high"},
55+ {name: "native max", model: Model{Reasoning: true, API: APIResponses, ID: "gpt-5.6-sol"}, level: "max", want: "max"},
56+ {
57+ name: "explicit equivalent",
58+ model: Model{Reasoning: true, ReasoningLevelMap: map[string]string{"minimum": "high"}},
59+ level: "minimum",
60+ want: "high",
61+ },
62+ }
63+ for _, tt := range tests {
64+ t.Run(tt.name, func(t *testing.T) {
65+ if got := ClampReasoningForModel(tt.model, tt.level); got != tt.want {
66+ t.Fatalf("level = %q, want %q", got, tt.want)
67+ }
68+ })
69+ }
70+}
71+
72+func TestOpenAIRequestUsesReasoningLevelMap(t *testing.T) {
73+ SetLiveModels([]Model{{
74+ Provider: "openai",
75+ ID: "mapped-reasoning-model",
76+ Reasoning: true,
77+ ReasoningLevelMap: map[string]string{"high": "low"},
78+ }})
79+ t.Cleanup(func() { SetLiveModels(nil) })
80+
81+ client := NewOpenAI("test", "").(*openaiClient)
82+ request, err := client.buildRequest(Request{Model: "mapped-reasoning-model", Reasoning: "high"})
83+ if err != nil {
84+ t.Fatal(err)
85+ }
86+ if request.ReasoningEffort != "low" {
87+ t.Fatalf("reasoning effort = %q, want low", request.ReasoningEffort)
88+ }
89+}
4 90
5 func TestReasoningEffortMappings(t *testing.T) {91 func TestReasoningEffortMappings(t *testing.T) {
6 cases := []struct {92 cases := []struct {
M packages/provider/usermodels.go +72 -24
@@ -3,6 +3,7 @@ package provider
33 import (
44 "encoding/json"
55 "fmt"
6+ "maps"
67 "os"
78 "strings"
89 )
@@ -56,18 +57,46 @@ func CustomProviders() map[string]CustomProviderConfig { return customProviders
5657
5758 // UserModel is a single model entry in the user's models.json.
5859 type UserModel struct {
59- ID string `json:"id"`
60- Name string `json:"name"`
61- Reasoning bool `json:"reasoning"`
62- ContextWindow int `json:"contextWindow"`
63- MaxTokens int `json:"maxTokens"`
64- PriceInput float64 `json:"priceInput"`
65- PriceOutput float64 `json:"priceOutput"`
66- PriceCacheRead float64 `json:"priceCacheRead"`
67- PriceCacheWrite float64 `json:"priceCacheWrite"`
68- BaseURL string `json:"baseUrl,omitempty"`
69- Input []string `json:"input"` // informational only
70- API string `json:"api"` // informational only
60+ ID string `json:"id"`
61+ Name string `json:"name"`
62+ Reasoning bool `json:"reasoning"`
63+ ReasoningLevelMap map[string]string `json:"reasoningLevelMap,omitempty"`
64+ ContextWindow int `json:"contextWindow"`
65+ MaxTokens int `json:"maxTokens"`
66+ PriceInput float64 `json:"priceInput"`
67+ PriceOutput float64 `json:"priceOutput"`
68+ PriceCacheRead float64 `json:"priceCacheRead"`
69+ PriceCacheWrite float64 `json:"priceCacheWrite"`
70+ BaseURL string `json:"baseUrl,omitempty"`
71+ Input []string `json:"input"` // informational only
72+ API string `json:"api"` // informational only
73+}
74+
75+func normalizeReasoningLevelMap(levelMap map[string]string, modelRef string) (map[string]string, []string) {
76+ if levelMap == nil {
77+ return nil, nil
78+ }
79+ normalized := make(map[string]string, len(levelMap))
80+ var warnings []string
81+ for rawLevel, rawTarget := range levelMap {
82+ level := NormalizeReasoning(rawLevel)
83+ if reasoningLevelRank(level) == 0 {
84+ warnings = append(warnings, fmt.Sprintf("models.json: %s reasoningLevelMap has unknown level %q; entry ignored", modelRef, rawLevel))
85+ continue
86+ }
87+ target := NormalizeReasoning(rawTarget)
88+ if rawTarget != "" && target == "" {
89+ // Explicit off aliases remove the level just like an empty value.
90+ normalized[level] = ""
91+ continue
92+ }
93+ if target != "" && reasoningLevelRank(target) == 0 {
94+ warnings = append(warnings, fmt.Sprintf("models.json: %s reasoningLevelMap has unknown target %q; entry ignored", modelRef, rawTarget))
95+ continue
96+ }
97+ normalized[level] = target
98+ }
99+ return normalized, warnings
71100 }
72101
73102 // LoadUserModels reads a models.json file and returns the models
@@ -166,24 +195,37 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
166195 um.MaxTokens = 0
167196 }
168197 }
198+ levelMap, levelWarnings := normalizeReasoningLevelMap(um.ReasoningLevelMap, normalized+"/"+um.ID)
199+ warnings = append(warnings, levelWarnings...)
200+ if !um.Reasoning && len(levelMap) > 0 {
201+ warnings = append(warnings, fmt.Sprintf("models.json: %s reasoningLevelMap ignored because reasoning is false", normalized+"/"+um.ID))
202+ levelMap = nil
203+ }
204+
169205 // Propagate provider-level BaseURL to models without their own.
170206 modelBaseURL := um.BaseURL
171207 if modelBaseURL == "" {
172208 modelBaseURL = prov.BaseURL
173209 }
210+ modelAPI := ""
211+ if cfg, ok := customProviders[normalized]; ok {
212+ modelAPI = cfg.API
213+ }
174214 m := Model{
175- Provider: normalized,
176- ID: um.ID,
177- DisplayName: um.Name,
178- ContextWindow: um.ContextWindow,
179- MaxOutput: um.MaxTokens,
180- Reasoning: um.Reasoning,
181- PriceInput: um.PriceInput,
182- PriceOutput: um.PriceOutput,
183- PriceCacheRead: um.PriceCacheRead,
184- PriceCacheWrite: um.PriceCacheWrite,
185- BaseURL: modelBaseURL,
186- Source: "user",
215+ Provider: normalized,
216+ ID: um.ID,
217+ DisplayName: um.Name,
218+ API: modelAPI,
219+ ContextWindow: um.ContextWindow,
220+ MaxOutput: um.MaxTokens,
221+ Reasoning: um.Reasoning,
222+ ReasoningLevelMap: levelMap,
223+ PriceInput: um.PriceInput,
224+ PriceOutput: um.PriceOutput,
225+ PriceCacheRead: um.PriceCacheRead,
226+ PriceCacheWrite: um.PriceCacheWrite,
227+ BaseURL: modelBaseURL,
228+ Source: "user",
187229 }
188230 if m.DisplayName == "" {
189231 m.DisplayName = m.ID
@@ -247,6 +289,12 @@ func SetUserModels(models []Model) {
247289 existing.MaxOutput = um.MaxOutput
248290 }
249291 existing.Reasoning = um.Reasoning
292+ if um.ReasoningLevelMap != nil {
293+ existing.ReasoningLevelMap = maps.Clone(um.ReasoningLevelMap)
294+ }
295+ if um.API != "" {
296+ existing.API = um.API
297+ }
250298 if um.BaseURL != "" {
251299 existing.BaseURL = um.BaseURL
252300 }
@@ -3,6 +3,7 @@ package provider
3 import (3 import (
4 "encoding/json"4 "encoding/json"
5 "fmt"5 "fmt"
6+ "maps"
6 "os"7 "os"
7 "strings"8 "strings"
8 )9 )
@@ -56,18 +57,46 @@ func CustomProviders() map[string]CustomProviderConfig { return customProviders
56 57
57 // UserModel is a single model entry in the user's models.json.58 // UserModel is a single model entry in the user's models.json.
58 type UserModel struct {59 type UserModel struct {
59- ID string `json:"id"`60+ ID string `json:"id"`
60- Name string `json:"name"`61+ Name string `json:"name"`
61- Reasoning bool `json:"reasoning"`62+ Reasoning bool `json:"reasoning"`
62- ContextWindow int `json:"contextWindow"`63+ ReasoningLevelMap map[string]string `json:"reasoningLevelMap,omitempty"`
63- MaxTokens int `json:"maxTokens"`64+ ContextWindow int `json:"contextWindow"`
64- PriceInput float64 `json:"priceInput"`65+ MaxTokens int `json:"maxTokens"`
65- PriceOutput float64 `json:"priceOutput"`66+ PriceInput float64 `json:"priceInput"`
66- PriceCacheRead float64 `json:"priceCacheRead"`67+ PriceOutput float64 `json:"priceOutput"`
67- PriceCacheWrite float64 `json:"priceCacheWrite"`68+ PriceCacheRead float64 `json:"priceCacheRead"`
68- BaseURL string `json:"baseUrl,omitempty"`69+ PriceCacheWrite float64 `json:"priceCacheWrite"`
69- Input []string `json:"input"` // informational only70+ BaseURL string `json:"baseUrl,omitempty"`
70- API string `json:"api"` // informational only71+ Input []string `json:"input"` // informational only
72+ API string `json:"api"` // informational only
73+}
74+
75+func normalizeReasoningLevelMap(levelMap map[string]string, modelRef string) (map[string]string, []string) {
76+ if levelMap == nil {
77+ return nil, nil
78+ }
79+ normalized := make(map[string]string, len(levelMap))
80+ var warnings []string
81+ for rawLevel, rawTarget := range levelMap {
82+ level := NormalizeReasoning(rawLevel)
83+ if reasoningLevelRank(level) == 0 {
84+ warnings = append(warnings, fmt.Sprintf("models.json: %s reasoningLevelMap has unknown level %q; entry ignored", modelRef, rawLevel))
85+ continue
86+ }
87+ target := NormalizeReasoning(rawTarget)
88+ if rawTarget != "" && target == "" {
89+ // Explicit off aliases remove the level just like an empty value.
90+ normalized[level] = ""
91+ continue
92+ }
93+ if target != "" && reasoningLevelRank(target) == 0 {
94+ warnings = append(warnings, fmt.Sprintf("models.json: %s reasoningLevelMap has unknown target %q; entry ignored", modelRef, rawTarget))
95+ continue
96+ }
97+ normalized[level] = target
98+ }
99+ return normalized, warnings
71 }100 }
72 101
73 // LoadUserModels reads a models.json file and returns the models102 // LoadUserModels reads a models.json file and returns the models
@@ -166,24 +195,37 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) {
166 um.MaxTokens = 0195 um.MaxTokens = 0
167 }196 }
168 }197 }
198+ levelMap, levelWarnings := normalizeReasoningLevelMap(um.ReasoningLevelMap, normalized+"/"+um.ID)
199+ warnings = append(warnings, levelWarnings...)
200+ if !um.Reasoning && len(levelMap) > 0 {
201+ warnings = append(warnings, fmt.Sprintf("models.json: %s reasoningLevelMap ignored because reasoning is false", normalized+"/"+um.ID))
202+ levelMap = nil
203+ }
204+
169 // Propagate provider-level BaseURL to models without their own.205 // Propagate provider-level BaseURL to models without their own.
170 modelBaseURL := um.BaseURL206 modelBaseURL := um.BaseURL
171 if modelBaseURL == "" {207 if modelBaseURL == "" {
172 modelBaseURL = prov.BaseURL208 modelBaseURL = prov.BaseURL
173 }209 }
210+ modelAPI := ""
211+ if cfg, ok := customProviders[normalized]; ok {
212+ modelAPI = cfg.API
213+ }
174 m := Model{214 m := Model{
175- Provider: normalized,215+ Provider: normalized,
176- ID: um.ID,216+ ID: um.ID,
177- DisplayName: um.Name,217+ DisplayName: um.Name,
178- ContextWindow: um.ContextWindow,218+ API: modelAPI,
179- MaxOutput: um.MaxTokens,219+ ContextWindow: um.ContextWindow,
180- Reasoning: um.Reasoning,220+ MaxOutput: um.MaxTokens,
181- PriceInput: um.PriceInput,221+ Reasoning: um.Reasoning,
182- PriceOutput: um.PriceOutput,222+ ReasoningLevelMap: levelMap,
183- PriceCacheRead: um.PriceCacheRead,223+ PriceInput: um.PriceInput,
184- PriceCacheWrite: um.PriceCacheWrite,224+ PriceOutput: um.PriceOutput,
185- BaseURL: modelBaseURL,225+ PriceCacheRead: um.PriceCacheRead,
186- Source: "user",226+ PriceCacheWrite: um.PriceCacheWrite,
227+ BaseURL: modelBaseURL,
228+ Source: "user",
187 }229 }
188 if m.DisplayName == "" {230 if m.DisplayName == "" {
189 m.DisplayName = m.ID231 m.DisplayName = m.ID
@@ -247,6 +289,12 @@ func SetUserModels(models []Model) {
247 existing.MaxOutput = um.MaxOutput289 existing.MaxOutput = um.MaxOutput
248 }290 }
249 existing.Reasoning = um.Reasoning291 existing.Reasoning = um.Reasoning
292+ if um.ReasoningLevelMap != nil {
293+ existing.ReasoningLevelMap = maps.Clone(um.ReasoningLevelMap)
294+ }
295+ if um.API != "" {
296+ existing.API = um.API
297+ }
250 if um.BaseURL != "" {298 if um.BaseURL != "" {
251 existing.BaseURL = um.BaseURL299 existing.BaseURL = um.BaseURL
252 }300 }
M packages/provider/usermodels_test.go +64 -0
@@ -57,6 +57,70 @@ func TestLoadUserModelsRegistersModelLevelBaseURLCustomProvider(t *testing.T) {
5757 }
5858 }
5959
60+func TestLoadUserModelsReasoningLevelMap(t *testing.T) {
61+ dir := t.TempDir()
62+ path := filepath.Join(dir, "models.json")
63+ if err := os.WriteFile(path, []byte(`{
64+ "providers": {
65+ "company-proxy": {
66+ "baseUrl": "https://llm.example.com/v1",
67+ "api": "anthropic",
68+ "models": [{
69+ "id": "reasoning-model",
70+ "reasoning": true,
71+ "reasoningLevelMap": {"minimal": "low", "max": "off"}
72+ }]
73+ }
74+ }
75+ }`), 0o644); err != nil {
76+ t.Fatal(err)
77+ }
78+
79+ models, warnings := LoadUserModelsWithWarnings(path)
80+ if len(warnings) != 0 {
81+ t.Fatalf("warnings = %v, want none", warnings)
82+ }
83+ if len(models) != 1 {
84+ t.Fatalf("models = %d, want 1", len(models))
85+ }
86+ model := models[0]
87+ if model.API != "anthropic" {
88+ t.Fatalf("api = %q, want anthropic", model.API)
89+ }
90+ if model.ReasoningLevelMap["minimum"] != "low" {
91+ t.Fatalf("minimum mapping = %q, want low", model.ReasoningLevelMap["minimum"])
92+ }
93+ if mapped, ok := model.ReasoningLevelMap["max"]; !ok || mapped != "" {
94+ t.Fatalf("max mapping = %q, %v, want explicit removal", mapped, ok)
95+ }
96+}
97+
98+func TestLoadUserModelsWarnsOnInvalidReasoningLevelMap(t *testing.T) {
99+ dir := t.TempDir()
100+ path := filepath.Join(dir, "models.json")
101+ if err := os.WriteFile(path, []byte(`{
102+ "providers": {
103+ "custom": {
104+ "models": [{
105+ "id": "bad-map",
106+ "reasoning": true,
107+ "reasoningLevelMap": {"turbo": "high", "low": "extreme"}
108+ }]
109+ }
110+ }
111+ }`), 0o644); err != nil {
112+ t.Fatal(err)
113+ }
114+
115+ models, warnings := LoadUserModelsWithWarnings(path)
116+ if len(models) != 1 || len(models[0].ReasoningLevelMap) != 0 {
117+ t.Fatalf("invalid map was not discarded: %#v", models)
118+ }
119+ if len(warnings) != 2 || !strings.Contains(strings.Join(warnings, "\n"), "reasoningLevelMap") {
120+ t.Fatalf("warnings = %v, want two reasoningLevelMap warnings", warnings)
121+ }
122+}
123+
60124 func TestLoadUserModelsAcceptsOpenAIResponsesAPI(t *testing.T) {
61125 dir := t.TempDir()
62126 path := filepath.Join(dir, "models.json")
@@ -57,6 +57,70 @@ func TestLoadUserModelsRegistersModelLevelBaseURLCustomProvider(t *testing.T) {
57 }57 }
58 }58 }
59 59
60+func TestLoadUserModelsReasoningLevelMap(t *testing.T) {
61+ dir := t.TempDir()
62+ path := filepath.Join(dir, "models.json")
63+ if err := os.WriteFile(path, []byte(`{
64+ "providers": {
65+ "company-proxy": {
66+ "baseUrl": "https://llm.example.com/v1",
67+ "api": "anthropic",
68+ "models": [{
69+ "id": "reasoning-model",
70+ "reasoning": true,
71+ "reasoningLevelMap": {"minimal": "low", "max": "off"}
72+ }]
73+ }
74+ }
75+ }`), 0o644); err != nil {
76+ t.Fatal(err)
77+ }
78+
79+ models, warnings := LoadUserModelsWithWarnings(path)
80+ if len(warnings) != 0 {
81+ t.Fatalf("warnings = %v, want none", warnings)
82+ }
83+ if len(models) != 1 {
84+ t.Fatalf("models = %d, want 1", len(models))
85+ }
86+ model := models[0]
87+ if model.API != "anthropic" {
88+ t.Fatalf("api = %q, want anthropic", model.API)
89+ }
90+ if model.ReasoningLevelMap["minimum"] != "low" {
91+ t.Fatalf("minimum mapping = %q, want low", model.ReasoningLevelMap["minimum"])
92+ }
93+ if mapped, ok := model.ReasoningLevelMap["max"]; !ok || mapped != "" {
94+ t.Fatalf("max mapping = %q, %v, want explicit removal", mapped, ok)
95+ }
96+}
97+
98+func TestLoadUserModelsWarnsOnInvalidReasoningLevelMap(t *testing.T) {
99+ dir := t.TempDir()
100+ path := filepath.Join(dir, "models.json")
101+ if err := os.WriteFile(path, []byte(`{
102+ "providers": {
103+ "custom": {
104+ "models": [{
105+ "id": "bad-map",
106+ "reasoning": true,
107+ "reasoningLevelMap": {"turbo": "high", "low": "extreme"}
108+ }]
109+ }
110+ }
111+ }`), 0o644); err != nil {
112+ t.Fatal(err)
113+ }
114+
115+ models, warnings := LoadUserModelsWithWarnings(path)
116+ if len(models) != 1 || len(models[0].ReasoningLevelMap) != 0 {
117+ t.Fatalf("invalid map was not discarded: %#v", models)
118+ }
119+ if len(warnings) != 2 || !strings.Contains(strings.Join(warnings, "\n"), "reasoningLevelMap") {
120+ t.Fatalf("warnings = %v, want two reasoningLevelMap warnings", warnings)
121+ }
122+}
123+
60 func TestLoadUserModelsAcceptsOpenAIResponsesAPI(t *testing.T) {124 func TestLoadUserModelsAcceptsOpenAIResponsesAPI(t *testing.T) {
61 dir := t.TempDir()125 dir := t.TempDir()
62 path := filepath.Join(dir, "models.json")126 path := filepath.Join(dir, "models.json")
M packages/tui/statusbar_test.go +21 -21
@@ -57,7 +57,7 @@ func TestStatusBarNoCWD(t *testing.T) {
5757 }
5858 }
5959
60-func TestStatusBarThinkingLevelBetweenModelAndStats(t *testing.T) {
60+func TestStatusBarReasoningLevelBetweenModelAndStats(t *testing.T) {
6161 lines := StatusBar(StatusBarParams{
6262 Theme: Dark,
6363 Provider: "openai-codex",
@@ -75,17 +75,17 @@ func TestStatusBarThinkingLevelBetweenModelAndStats(t *testing.T) {
7575 }
7676 plain := stripANSI(lines[0])
7777 modelIdx := strings.Index(plain, "(openai-codex) gpt-5.5")
78- thinkingIdx := strings.Index(plain, "thinking: minimal")
78+ reasoningIdx := strings.Index(plain, "reasoning: minimal")
7979 statsIdx := strings.Index(plain, "↑4.3M")
80- if modelIdx < 0 || thinkingIdx < 0 || statsIdx < 0 {
81- t.Fatalf("line should contain model, thinking level, and stats, got %q", plain)
80+ if modelIdx < 0 || reasoningIdx < 0 || statsIdx < 0 {
81+ t.Fatalf("line should contain model, reasoning level, and stats, got %q", plain)
8282 }
83- if !(modelIdx < thinkingIdx && thinkingIdx < statsIdx) {
84- t.Fatalf("thinking level should sit between model and stats, got %q", plain)
83+ if !(modelIdx < reasoningIdx && reasoningIdx < statsIdx) {
84+ t.Fatalf("reasoning level should sit between model and stats, got %q", plain)
8585 }
8686 }
8787
88-func TestStatusBarUsesThinkingMaxThemeColor(t *testing.T) {
88+func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) {
8989 th := Dark
9090 th.ThinkingMax = 201
9191 lines := StatusBar(StatusBarParams{
@@ -94,12 +94,12 @@ func TestStatusBarUsesThinkingMaxThemeColor(t *testing.T) {
9494 if len(lines) != 1 {
9595 t.Fatalf("lines = %d, want 1", len(lines))
9696 }
97- if !strings.Contains(lines[0], "\x1b[38;5;201m") || !strings.Contains(stripANSI(lines[0]), "thinking: max") {
98- t.Fatalf("max thinking style missing: %q", lines[0])
97+ if !strings.Contains(lines[0], "\x1b[38;5;201m") || !strings.Contains(stripANSI(lines[0]), "reasoning: max") {
98+ t.Fatalf("max reasoning style missing: %q", lines[0])
9999 }
100100 }
101101
102-func TestStatusBarNarrowKeepsModelAndThinkingTogetherWhenTheyFit(t *testing.T) {
102+func TestStatusBarNarrowKeepsModelAndReasoningTogetherWhenTheyFit(t *testing.T) {
103103 lines := StatusBar(StatusBarParams{
104104 Theme: Dark,
105105 Provider: "openai-codex",
@@ -121,10 +121,10 @@ func TestStatusBarNarrowKeepsModelAndThinkingTogetherWhenTheyFit(t *testing.T) {
121121 for i, line := range lines {
122122 plain[i] = stripANSI(line)
123123 }
124- if !strings.Contains(plain[0], "(openai-codex) gpt-5.5 thinking: xhigh") {
125- t.Fatalf("line 1 should contain model and thinking level, got %q", plain[0])
124+ if !strings.Contains(plain[0], "(openai-codex) gpt-5.5 reasoning: xhigh") {
125+ t.Fatalf("line 1 should contain model and reasoning level, got %q", plain[0])
126126 }
127- if !strings.Contains(plain[1], "$0.000 (sub)") || strings.Contains(plain[1], "thinking level") {
127+ if !strings.Contains(plain[1], "$0.000 (sub)") || strings.Contains(plain[1], "reasoning level") {
128128 t.Fatalf("line 2 should contain only stats, got %q", plain[1])
129129 }
130130 if !strings.Contains(plain[2], "/tmp/x") {
@@ -132,7 +132,7 @@ func TestStatusBarNarrowKeepsModelAndThinkingTogetherWhenTheyFit(t *testing.T) {
132132 }
133133 }
134134
135-func TestStatusBarNarrowSplitsAfterThinkingLevel(t *testing.T) {
135+func TestStatusBarNarrowSplitsAfterReasoningLevel(t *testing.T) {
136136 lines := StatusBar(StatusBarParams{
137137 Theme: Dark,
138138 Provider: "openai-codex",
@@ -146,7 +146,7 @@ func TestStatusBarNarrowSplitsAfterThinkingLevel(t *testing.T) {
146146 Cols: 40,
147147 })
148148 if len(lines) != 4 {
149- t.Fatalf("narrow status with thinking: want 4 lines, got %d: %q", len(lines), lines)
149+ t.Fatalf("narrow status with reasoning: want 4 lines, got %d: %q", len(lines), lines)
150150 }
151151 plain := make([]string, len(lines))
152152 for i, line := range lines {
@@ -155,8 +155,8 @@ func TestStatusBarNarrowSplitsAfterThinkingLevel(t *testing.T) {
155155 if !strings.Contains(plain[0], "(openai-codex) gpt-5.5") {
156156 t.Fatalf("line 1 should contain model info, got %q", plain[0])
157157 }
158- if !strings.Contains(plain[1], "thinking: minimal") || strings.Contains(plain[1], "↑4.3M") {
159- t.Fatalf("line 2 should contain only thinking level, got %q", plain[1])
158+ if !strings.Contains(plain[1], "reasoning: minimal") || strings.Contains(plain[1], "↑4.3M") {
159+ t.Fatalf("line 2 should contain only reasoning level, got %q", plain[1])
160160 }
161161 if !strings.Contains(plain[2], "↑4.3M ↓2") {
162162 t.Fatalf("line 3 should contain stats, got %q", plain[2])
@@ -166,7 +166,7 @@ func TestStatusBarNarrowSplitsAfterThinkingLevel(t *testing.T) {
166166 }
167167 }
168168
169-func TestStatusBarVeryNarrowSplitsAfterThinkingLevel(t *testing.T) {
169+func TestStatusBarVeryNarrowSplitsAfterReasoningLevel(t *testing.T) {
170170 lines := StatusBar(StatusBarParams{
171171 Theme: Dark,
172172 Provider: "openai-codex",
@@ -180,7 +180,7 @@ func TestStatusBarVeryNarrowSplitsAfterThinkingLevel(t *testing.T) {
180180 Cols: 32,
181181 })
182182 if len(lines) != 4 {
183- t.Fatalf("narrow status with thinking: want 4 lines, got %d: %q", len(lines), lines)
183+ t.Fatalf("narrow status with reasoning: want 4 lines, got %d: %q", len(lines), lines)
184184 }
185185 plain := make([]string, len(lines))
186186 for i, line := range lines {
@@ -189,8 +189,8 @@ func TestStatusBarVeryNarrowSplitsAfterThinkingLevel(t *testing.T) {
189189 if !strings.Contains(plain[0], "(openai-codex) gpt-5.5") {
190190 t.Fatalf("line 1 should contain model info, got %q", plain[0])
191191 }
192- if !strings.Contains(plain[1], "thinking: minimal") {
193- t.Fatalf("line 2 should contain thinking level, got %q", plain[1])
192+ if !strings.Contains(plain[1], "reasoning: minimal") {
193+ t.Fatalf("line 2 should contain reasoning level, got %q", plain[1])
194194 }
195195 if !strings.Contains(plain[2], "↑4.3M ↓2") {
196196 t.Fatalf("line 3 should contain stats, got %q", plain[2])
@@ -57,7 +57,7 @@ func TestStatusBarNoCWD(t *testing.T) {
57 }57 }
58 }58 }
59 59
60-func TestStatusBarThinkingLevelBetweenModelAndStats(t *testing.T) {60+func TestStatusBarReasoningLevelBetweenModelAndStats(t *testing.T) {
61 lines := StatusBar(StatusBarParams{61 lines := StatusBar(StatusBarParams{
62 Theme: Dark,62 Theme: Dark,
63 Provider: "openai-codex",63 Provider: "openai-codex",
@@ -75,17 +75,17 @@ func TestStatusBarThinkingLevelBetweenModelAndStats(t *testing.T) {
75 }75 }
76 plain := stripANSI(lines[0])76 plain := stripANSI(lines[0])
77 modelIdx := strings.Index(plain, "(openai-codex) gpt-5.5")77 modelIdx := strings.Index(plain, "(openai-codex) gpt-5.5")
78- thinkingIdx := strings.Index(plain, "thinking: minimal")78+ reasoningIdx := strings.Index(plain, "reasoning: minimal")
79 statsIdx := strings.Index(plain, "↑4.3M")79 statsIdx := strings.Index(plain, "↑4.3M")
80- if modelIdx < 0 || thinkingIdx < 0 || statsIdx < 0 {80+ if modelIdx < 0 || reasoningIdx < 0 || statsIdx < 0 {
81- t.Fatalf("line should contain model, thinking level, and stats, got %q", plain)81+ t.Fatalf("line should contain model, reasoning level, and stats, got %q", plain)
82 }82 }
83- if !(modelIdx < thinkingIdx && thinkingIdx < statsIdx) {83+ if !(modelIdx < reasoningIdx && reasoningIdx < statsIdx) {
84- t.Fatalf("thinking level should sit between model and stats, got %q", plain)84+ t.Fatalf("reasoning level should sit between model and stats, got %q", plain)
85 }85 }
86 }86 }
87 87
88-func TestStatusBarUsesThinkingMaxThemeColor(t *testing.T) {88+func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) {
89 th := Dark89 th := Dark
90 th.ThinkingMax = 20190 th.ThinkingMax = 201
91 lines := StatusBar(StatusBarParams{91 lines := StatusBar(StatusBarParams{
@@ -94,12 +94,12 @@ func TestStatusBarUsesThinkingMaxThemeColor(t *testing.T) {
94 if len(lines) != 1 {94 if len(lines) != 1 {
95 t.Fatalf("lines = %d, want 1", len(lines))95 t.Fatalf("lines = %d, want 1", len(lines))
96 }96 }
97- if !strings.Contains(lines[0], "\x1b[38;5;201m") || !strings.Contains(stripANSI(lines[0]), "thinking: max") {97+ if !strings.Contains(lines[0], "\x1b[38;5;201m") || !strings.Contains(stripANSI(lines[0]), "reasoning: max") {
98- t.Fatalf("max thinking style missing: %q", lines[0])98+ t.Fatalf("max reasoning style missing: %q", lines[0])
99 }99 }
100 }100 }
101 101
102-func TestStatusBarNarrowKeepsModelAndThinkingTogetherWhenTheyFit(t *testing.T) {102+func TestStatusBarNarrowKeepsModelAndReasoningTogetherWhenTheyFit(t *testing.T) {
103 lines := StatusBar(StatusBarParams{103 lines := StatusBar(StatusBarParams{
104 Theme: Dark,104 Theme: Dark,
105 Provider: "openai-codex",105 Provider: "openai-codex",
@@ -121,10 +121,10 @@ func TestStatusBarNarrowKeepsModelAndThinkingTogetherWhenTheyFit(t *testing.T) {
121 for i, line := range lines {121 for i, line := range lines {
122 plain[i] = stripANSI(line)122 plain[i] = stripANSI(line)
123 }123 }
124- if !strings.Contains(plain[0], "(openai-codex) gpt-5.5 thinking: xhigh") {124+ if !strings.Contains(plain[0], "(openai-codex) gpt-5.5 reasoning: xhigh") {
125- t.Fatalf("line 1 should contain model and thinking level, got %q", plain[0])125+ t.Fatalf("line 1 should contain model and reasoning level, got %q", plain[0])
126 }126 }
127- if !strings.Contains(plain[1], "$0.000 (sub)") || strings.Contains(plain[1], "thinking level") {127+ if !strings.Contains(plain[1], "$0.000 (sub)") || strings.Contains(plain[1], "reasoning level") {
128 t.Fatalf("line 2 should contain only stats, got %q", plain[1])128 t.Fatalf("line 2 should contain only stats, got %q", plain[1])
129 }129 }
130 if !strings.Contains(plain[2], "/tmp/x") {130 if !strings.Contains(plain[2], "/tmp/x") {
@@ -132,7 +132,7 @@ func TestStatusBarNarrowKeepsModelAndThinkingTogetherWhenTheyFit(t *testing.T) {
132 }132 }
133 }133 }
134 134
135-func TestStatusBarNarrowSplitsAfterThinkingLevel(t *testing.T) {135+func TestStatusBarNarrowSplitsAfterReasoningLevel(t *testing.T) {
136 lines := StatusBar(StatusBarParams{136 lines := StatusBar(StatusBarParams{
137 Theme: Dark,137 Theme: Dark,
138 Provider: "openai-codex",138 Provider: "openai-codex",
@@ -146,7 +146,7 @@ func TestStatusBarNarrowSplitsAfterThinkingLevel(t *testing.T) {
146 Cols: 40,146 Cols: 40,
147 })147 })
148 if len(lines) != 4 {148 if len(lines) != 4 {
149- t.Fatalf("narrow status with thinking: want 4 lines, got %d: %q", len(lines), lines)149+ t.Fatalf("narrow status with reasoning: want 4 lines, got %d: %q", len(lines), lines)
150 }150 }
151 plain := make([]string, len(lines))151 plain := make([]string, len(lines))
152 for i, line := range lines {152 for i, line := range lines {
@@ -155,8 +155,8 @@ func TestStatusBarNarrowSplitsAfterThinkingLevel(t *testing.T) {
155 if !strings.Contains(plain[0], "(openai-codex) gpt-5.5") {155 if !strings.Contains(plain[0], "(openai-codex) gpt-5.5") {
156 t.Fatalf("line 1 should contain model info, got %q", plain[0])156 t.Fatalf("line 1 should contain model info, got %q", plain[0])
157 }157 }
158- if !strings.Contains(plain[1], "thinking: minimal") || strings.Contains(plain[1], "↑4.3M") {158+ if !strings.Contains(plain[1], "reasoning: minimal") || strings.Contains(plain[1], "↑4.3M") {
159- t.Fatalf("line 2 should contain only thinking level, got %q", plain[1])159+ t.Fatalf("line 2 should contain only reasoning level, got %q", plain[1])
160 }160 }
161 if !strings.Contains(plain[2], "↑4.3M ↓2") {161 if !strings.Contains(plain[2], "↑4.3M ↓2") {
162 t.Fatalf("line 3 should contain stats, got %q", plain[2])162 t.Fatalf("line 3 should contain stats, got %q", plain[2])
@@ -166,7 +166,7 @@ func TestStatusBarNarrowSplitsAfterThinkingLevel(t *testing.T) {
166 }166 }
167 }167 }
168 168
169-func TestStatusBarVeryNarrowSplitsAfterThinkingLevel(t *testing.T) {169+func TestStatusBarVeryNarrowSplitsAfterReasoningLevel(t *testing.T) {
170 lines := StatusBar(StatusBarParams{170 lines := StatusBar(StatusBarParams{
171 Theme: Dark,171 Theme: Dark,
172 Provider: "openai-codex",172 Provider: "openai-codex",
@@ -180,7 +180,7 @@ func TestStatusBarVeryNarrowSplitsAfterThinkingLevel(t *testing.T) {
180 Cols: 32,180 Cols: 32,
181 })181 })
182 if len(lines) != 4 {182 if len(lines) != 4 {
183- t.Fatalf("narrow status with thinking: want 4 lines, got %d: %q", len(lines), lines)183+ t.Fatalf("narrow status with reasoning: want 4 lines, got %d: %q", len(lines), lines)
184 }184 }
185 plain := make([]string, len(lines))185 plain := make([]string, len(lines))
186 for i, line := range lines {186 for i, line := range lines {
@@ -189,8 +189,8 @@ func TestStatusBarVeryNarrowSplitsAfterThinkingLevel(t *testing.T) {
189 if !strings.Contains(plain[0], "(openai-codex) gpt-5.5") {189 if !strings.Contains(plain[0], "(openai-codex) gpt-5.5") {
190 t.Fatalf("line 1 should contain model info, got %q", plain[0])190 t.Fatalf("line 1 should contain model info, got %q", plain[0])
191 }191 }
192- if !strings.Contains(plain[1], "thinking: minimal") {192+ if !strings.Contains(plain[1], "reasoning: minimal") {
193- t.Fatalf("line 2 should contain thinking level, got %q", plain[1])193+ t.Fatalf("line 2 should contain reasoning level, got %q", plain[1])
194 }194 }
195 if !strings.Contains(plain[2], "↑4.3M ↓2") {195 if !strings.Contains(plain[2], "↑4.3M ↓2") {
196 t.Fatalf("line 3 should contain stats, got %q", plain[2])196 t.Fatalf("line 3 should contain stats, got %q", plain[2])
M packages/tui/theme.go +1 -1
@@ -49,7 +49,7 @@ type Theme struct {
4949 Error int
5050 Warning int
5151 Spinner int // spinner + funny working line
52- ThinkingMax int // status color for the opt-in max thinking level
52+ ThinkingMax int // status color for the opt-in max reasoning level
5353 SelectionBG int // background for highlighted rows
5454 SelectionFG int // foreground for highlighted rows
5555
@@ -49,7 +49,7 @@ type Theme struct {
49 Error int49 Error int
50 Warning int50 Warning int
51 Spinner int // spinner + funny working line51 Spinner int // spinner + funny working line
52- ThinkingMax int // status color for the opt-in max thinking level52+ ThinkingMax int // status color for the opt-in max reasoning level
53 SelectionBG int // background for highlighted rows53 SelectionBG int // background for highlighted rows
54 SelectionFG int // foreground for highlighted rows54 SelectionFG int // foreground for highlighted rows
55 55
M packages/tui/view.go +20 -20
@@ -2504,15 +2504,15 @@ func StatusBar(p StatusBarParams) []string {
25042504 const pad = " " // 2 spaces
25052505
25062506 left := fmt.Sprintf("(%s) %s", p.Provider, p.Model)
2507- thinking := thinkingLevelLabel(p.Reasoning)
2508- thinkingText := ""
2509- if thinking != "" {
2510- thinkingText = "thinking: " + thinking
2507+ reasoning := reasoningLevelLabel(p.Reasoning)
2508+ reasoningText := ""
2509+ if reasoning != "" {
2510+ reasoningText = "reasoning: " + reasoning
25112511 }
25122512 statsText := strings.Join(stats, " ")
25132513 middleParts := make([]string, 0, 2)
2514- if thinkingText != "" {
2515- middleParts = append(middleParts, thinkingText)
2514+ if reasoningText != "" {
2515+ middleParts = append(middleParts, reasoningText)
25162516 }
25172517 if statsText != "" {
25182518 middleParts = append(middleParts, statsText)
@@ -2541,7 +2541,7 @@ func StatusBar(p StatusBarParams) []string {
25412541 if middle != "" {
25422542 leftBuilder.WriteString(pad)
25432543 // Highlight the opt-in max tier; other status information stays muted.
2544- leftBuilder.WriteString(th.FG256(thinkingStatusColor(th, thinkingText), middle))
2544+ leftBuilder.WriteString(th.FG256(reasoningStatusColor(th, reasoningText), middle))
25452545 }
25462546
25472547 cwd := shortenHome(p.CWD)
@@ -2570,13 +2570,13 @@ func StatusBar(p StatusBarParams) []string {
25702570 modelLine := pad + th.FG256(th.Muted, left)
25712571 lines := []string{busyLine}
25722572 if middle != "" && visibleWidth(modelLine+pad+th.FG256(th.Muted, middle)) > p.Cols {
2573- lines = appendWrappedStatusLines(lines, th, pad, left, thinkingText, statsText, p.Cols)
2573+ lines = appendWrappedStatusLines(lines, th, pad, left, reasoningText, statsText, p.Cols)
25742574 } else {
25752575 var infoBuilder strings.Builder
25762576 infoBuilder.WriteString(modelLine)
25772577 if middle != "" {
25782578 infoBuilder.WriteString(pad)
2579- infoBuilder.WriteString(th.FG256(thinkingStatusColor(th, thinkingText), middle))
2579+ infoBuilder.WriteString(th.FG256(reasoningStatusColor(th, reasoningText), middle))
25802580 }
25812581 lines = append(lines, infoBuilder.String())
25822582 }
@@ -2592,7 +2592,7 @@ func StatusBar(p StatusBarParams) []string {
25922592 // into an awkward position on small widths.
25932593 if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols {
25942594 var lines []string
2595- lines = appendWrappedStatusLines(lines, th, pad, left, thinkingText, statsText, p.Cols)
2595+ lines = appendWrappedStatusLines(lines, th, pad, left, reasoningText, statsText, p.Cols)
25962596 if cwd != "" {
25972597 lines = append(lines, pad+th.FG256(th.Muted, cwd))
25982598 }
@@ -2609,9 +2609,9 @@ func StatusBar(p StatusBarParams) []string {
26092609 return []string{primary, cwdRendered}
26102610 }
26112611
2612-func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, thinkingText, statsText string, cols int) []string {
2612+func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, reasoningText, statsText string, cols int) []string {
26132613 modelLine := pad + th.FG256(th.Muted, modelText)
2614- if thinkingText == "" {
2614+ if reasoningText == "" {
26152615 lines = append(lines, modelLine)
26162616 if statsText != "" {
26172617 lines = append(lines, pad+th.FG256(th.Muted, statsText))
@@ -2619,13 +2619,13 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, thinking
26192619 return lines
26202620 }
26212621
2622- modelThinkingPlain := pad + modelText + pad + thinkingText
2623- thinkingColor := thinkingStatusColor(th, thinkingText)
2624- if cols <= 0 || visibleWidth(modelThinkingPlain) <= cols {
2625- lines = append(lines, pad+th.FG256(thinkingColor, modelText+pad+thinkingText))
2622+ modelReasoningPlain := pad + modelText + pad + reasoningText
2623+ reasoningColor := reasoningStatusColor(th, reasoningText)
2624+ if cols <= 0 || visibleWidth(modelReasoningPlain) <= cols {
2625+ lines = append(lines, pad+th.FG256(reasoningColor, modelText+pad+reasoningText))
26262626 } else {
26272627 lines = append(lines, modelLine)
2628- lines = append(lines, pad+th.FG256(thinkingColor, thinkingText))
2628+ lines = append(lines, pad+th.FG256(reasoningColor, reasoningText))
26292629 }
26302630 if statsText != "" {
26312631 lines = append(lines, pad+th.FG256(th.Muted, statsText))
@@ -2633,14 +2633,14 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, thinking
26332633 return lines
26342634 }
26352635
2636-func thinkingStatusColor(th Theme, thinkingText string) int {
2637- if strings.HasSuffix(thinkingText, ": max") {
2636+func reasoningStatusColor(th Theme, reasoningText string) int {
2637+ if strings.HasSuffix(reasoningText, ": max") {
26382638 return th.ThinkingMax
26392639 }
26402640 return th.Muted
26412641 }
26422642
2643-func thinkingLevelLabel(level string) string {
2643+func reasoningLevelLabel(level string) string {
26442644 switch strings.ToLower(strings.TrimSpace(level)) {
26452645 case "", "off", "none", "no", "false", "disabled":
26462646 return ""
@@ -2504,15 +2504,15 @@ func StatusBar(p StatusBarParams) []string {
2504 const pad = " " // 2 spaces2504 const pad = " " // 2 spaces
2505 2505
2506 left := fmt.Sprintf("(%s) %s", p.Provider, p.Model)2506 left := fmt.Sprintf("(%s) %s", p.Provider, p.Model)
2507- thinking := thinkingLevelLabel(p.Reasoning)2507+ reasoning := reasoningLevelLabel(p.Reasoning)
2508- thinkingText := ""2508+ reasoningText := ""
2509- if thinking != "" {2509+ if reasoning != "" {
2510- thinkingText = "thinking: " + thinking2510+ reasoningText = "reasoning: " + reasoning
2511 }2511 }
2512 statsText := strings.Join(stats, " ")2512 statsText := strings.Join(stats, " ")
2513 middleParts := make([]string, 0, 2)2513 middleParts := make([]string, 0, 2)
2514- if thinkingText != "" {2514+ if reasoningText != "" {
2515- middleParts = append(middleParts, thinkingText)2515+ middleParts = append(middleParts, reasoningText)
2516 }2516 }
2517 if statsText != "" {2517 if statsText != "" {
2518 middleParts = append(middleParts, statsText)2518 middleParts = append(middleParts, statsText)
@@ -2541,7 +2541,7 @@ func StatusBar(p StatusBarParams) []string {
2541 if middle != "" {2541 if middle != "" {
2542 leftBuilder.WriteString(pad)2542 leftBuilder.WriteString(pad)
2543 // Highlight the opt-in max tier; other status information stays muted.2543 // Highlight the opt-in max tier; other status information stays muted.
2544- leftBuilder.WriteString(th.FG256(thinkingStatusColor(th, thinkingText), middle))2544+ leftBuilder.WriteString(th.FG256(reasoningStatusColor(th, reasoningText), middle))
2545 }2545 }
2546 2546
2547 cwd := shortenHome(p.CWD)2547 cwd := shortenHome(p.CWD)
@@ -2570,13 +2570,13 @@ func StatusBar(p StatusBarParams) []string {
2570 modelLine := pad + th.FG256(th.Muted, left)2570 modelLine := pad + th.FG256(th.Muted, left)
2571 lines := []string{busyLine}2571 lines := []string{busyLine}
2572 if middle != "" && visibleWidth(modelLine+pad+th.FG256(th.Muted, middle)) > p.Cols {2572 if middle != "" && visibleWidth(modelLine+pad+th.FG256(th.Muted, middle)) > p.Cols {
2573- lines = appendWrappedStatusLines(lines, th, pad, left, thinkingText, statsText, p.Cols)2573+ lines = appendWrappedStatusLines(lines, th, pad, left, reasoningText, statsText, p.Cols)
2574 } else {2574 } else {
2575 var infoBuilder strings.Builder2575 var infoBuilder strings.Builder
2576 infoBuilder.WriteString(modelLine)2576 infoBuilder.WriteString(modelLine)
2577 if middle != "" {2577 if middle != "" {
2578 infoBuilder.WriteString(pad)2578 infoBuilder.WriteString(pad)
2579- infoBuilder.WriteString(th.FG256(thinkingStatusColor(th, thinkingText), middle))2579+ infoBuilder.WriteString(th.FG256(reasoningStatusColor(th, reasoningText), middle))
2580 }2580 }
2581 lines = append(lines, infoBuilder.String())2581 lines = append(lines, infoBuilder.String())
2582 }2582 }
@@ -2592,7 +2592,7 @@ func StatusBar(p StatusBarParams) []string {
2592 // into an awkward position on small widths.2592 // into an awkward position on small widths.
2593 if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols {2593 if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols {
2594 var lines []string2594 var lines []string
2595- lines = appendWrappedStatusLines(lines, th, pad, left, thinkingText, statsText, p.Cols)2595+ lines = appendWrappedStatusLines(lines, th, pad, left, reasoningText, statsText, p.Cols)
2596 if cwd != "" {2596 if cwd != "" {
2597 lines = append(lines, pad+th.FG256(th.Muted, cwd))2597 lines = append(lines, pad+th.FG256(th.Muted, cwd))
2598 }2598 }
@@ -2609,9 +2609,9 @@ func StatusBar(p StatusBarParams) []string {
2609 return []string{primary, cwdRendered}2609 return []string{primary, cwdRendered}
2610 }2610 }
2611 2611
2612-func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, thinkingText, statsText string, cols int) []string {2612+func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, reasoningText, statsText string, cols int) []string {
2613 modelLine := pad + th.FG256(th.Muted, modelText)2613 modelLine := pad + th.FG256(th.Muted, modelText)
2614- if thinkingText == "" {2614+ if reasoningText == "" {
2615 lines = append(lines, modelLine)2615 lines = append(lines, modelLine)
2616 if statsText != "" {2616 if statsText != "" {
2617 lines = append(lines, pad+th.FG256(th.Muted, statsText))2617 lines = append(lines, pad+th.FG256(th.Muted, statsText))
@@ -2619,13 +2619,13 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, thinking
2619 return lines2619 return lines
2620 }2620 }
2621 2621
2622- modelThinkingPlain := pad + modelText + pad + thinkingText2622+ modelReasoningPlain := pad + modelText + pad + reasoningText
2623- thinkingColor := thinkingStatusColor(th, thinkingText)2623+ reasoningColor := reasoningStatusColor(th, reasoningText)
2624- if cols <= 0 || visibleWidth(modelThinkingPlain) <= cols {2624+ if cols <= 0 || visibleWidth(modelReasoningPlain) <= cols {
2625- lines = append(lines, pad+th.FG256(thinkingColor, modelText+pad+thinkingText))2625+ lines = append(lines, pad+th.FG256(reasoningColor, modelText+pad+reasoningText))
2626 } else {2626 } else {
2627 lines = append(lines, modelLine)2627 lines = append(lines, modelLine)
2628- lines = append(lines, pad+th.FG256(thinkingColor, thinkingText))2628+ lines = append(lines, pad+th.FG256(reasoningColor, reasoningText))
2629 }2629 }
2630 if statsText != "" {2630 if statsText != "" {
2631 lines = append(lines, pad+th.FG256(th.Muted, statsText))2631 lines = append(lines, pad+th.FG256(th.Muted, statsText))
@@ -2633,14 +2633,14 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, thinking
2633 return lines2633 return lines
2634 }2634 }
2635 2635
2636-func thinkingStatusColor(th Theme, thinkingText string) int {2636+func reasoningStatusColor(th Theme, reasoningText string) int {
2637- if strings.HasSuffix(thinkingText, ": max") {2637+ if strings.HasSuffix(reasoningText, ": max") {
2638 return th.ThinkingMax2638 return th.ThinkingMax
2639 }2639 }
2640 return th.Muted2640 return th.Muted
2641 }2641 }
2642 2642
2643-func thinkingLevelLabel(level string) string {2643+func reasoningLevelLabel(level string) string {
2644 switch strings.ToLower(strings.TrimSpace(level)) {2644 switch strings.ToLower(strings.TrimSpace(level)) {
2645 case "", "off", "none", "no", "false", "disabled":2645 case "", "off", "none", "no", "false", "disabled":
2646 return ""2646 return ""

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