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 | |||
| 210 | 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 | 211 | | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). | | |
| 212 | 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 | 214 | | `--stats <path>` | With `-p`/`--print`, write generation statistics as JSON. | | |
| 215 | 215 | | `-c`, `--continue` | Resume the latest session for this cwd. | | |
| 216 | 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 | 274 | | `/login` | Log in via API key or subscription (opens a dialog). | | |
| 275 | 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 | 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 | 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 | 279 | | `/sessions` | Resume a previous session for this directory. | | |
| 279 | 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 | 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 | 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 | 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 | 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 | 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 | 459 | "id": "gpt-5.5", | |
| 459 | 460 | "name": "GPT-5.5", | |
| 460 | 461 | "reasoning": true, | |
| 462 | + | "reasoningLevelMap": {"minimum": "low", "max": ""}, | |
| 461 | 463 | "contextWindow": 400000, | |
| 462 | 464 | "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 | 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 | 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 | 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 | 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 | 846 | ## 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": 128000 | 464 | "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 | |||
| 211 | 211 | throughput, such as `us.` or `eu.` prefixed model IDs. zot rewrites known | |
| 212 | 212 | families automatically where possible. Claude Opus 5 uses | |
| 213 | 213 | `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 | |
| 215 | 215 | and ARNs are left unchanged. | |
| 216 | 216 | ||
| 217 | 217 | ### 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 known | 211 | throughput, such as `us.` or `eu.` prefixed model IDs. zot rewrites known | ||
| 212 | families automatically where possible. Claude Opus 5 uses | 212 | families automatically where possible. Claude Opus 5 uses | ||
| 213 | `global.anthropic.claude-opus-5`; zot maps the bare foundation-model ID to that | 213 | `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 |
| 215 | and ARNs are left unchanged. | 215 | and ARNs are left unchanged. | ||
| 216 | 216 | ||||
| 217 | ### Google Vertex AI | 217 | ### Google Vertex AI | ||
M docs/rpc.md +1 -1
| @@ -155,7 +155,7 @@ Cross-provider swaps require relaunching `zot rpc` with the new `--provider`. | |||
| 155 | 155 | ||
| 156 | 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 | 160 | ```json | |
| 161 | 161 | {"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 | ```json | 160 | ```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`). | |||
| 191 | 191 | - `error` — errors, refused calls, diff deletions. | |
| 192 | 192 | - `warning` — warnings and high context-usage state. | |
| 193 | 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 | 195 | - `selection_bg` — highlighted row background. | |
| 196 | 196 | - `selection_fg` — highlighted row foreground. | |
| 197 | 197 | ||
| @@ -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) { | |||
| 418 | 418 | row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, | |
| 419 | 419 | row{"--base-url URL", "override provider api base url"}, | |
| 420 | 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 | 422 | row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"}, | |
| 423 | 423 | ) | |
| 424 | 424 | 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() { | |||
| 3239 | 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 | 3304 | func (i *Interactive) openSettingsDialog() { | |
| 3243 | 3305 | detected := tui.DetectImageProtocol() | |
| 3244 | 3306 | imgEnabled := detected != tui.ImageProtocolNone | |
| @@ -3291,27 +3353,7 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3291 | 3353 | } | |
| 3292 | 3354 | } | |
| 3293 | 3355 | ||
| 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() | |
| 3315 | 3357 | ||
| 3316 | 3358 | themeName := i.cfg.ThemeName | |
| 3317 | 3359 | if themeName == "" { | |
| @@ -3456,14 +3498,7 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3456 | 3498 | }, | |
| 3457 | 3499 | }, | |
| 3458 | 3500 | }, | |
| 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, | |
| 3467 | 3502 | { | |
| 3468 | 3503 | key: "theme", | |
| 3469 | 3504 | label: "color theme", | |
| @@ -3600,7 +3635,7 @@ func (i *Interactive) openQuickModelPicker(slot int) { | |||
| 3600 | 3635 | if i.cfg.LoggedInProviders != nil { | |
| 3601 | 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 | 3641 | func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) { | |
| @@ -4010,7 +4045,7 @@ func (i *Interactive) applyReasoningSetting(level string) { | |||
| 4010 | 4045 | if label == "" { | |
| 4011 | 4046 | label = "off" | |
| 4012 | 4047 | } | |
| 4013 | - | i.statusOK = "thinking level " + label | |
| 4048 | + | i.statusOK = "reasoning level " + label | |
| 4014 | 4049 | i.statusErr = "" | |
| 4015 | 4050 | i.mu.Unlock() | |
| 4016 | 4051 | } | |
| @@ -4349,6 +4384,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 4349 | 4384 | i.statusOK = "" | |
| 4350 | 4385 | i.mu.Unlock() | |
| 4351 | 4386 | } | |
| 4387 | + | case "/reasoning": | |
| 4388 | + | i.openReasoningDialog() | |
| 4352 | 4389 | case "/settings": | |
| 4353 | 4390 | i.openSettingsDialog() | |
| 4354 | 4391 | case "/sessions": | |
| @@ -4508,7 +4545,7 @@ func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) { | |||
| 4508 | 4545 | if i.cfg.LoggedInProviders != nil { | |
| 4509 | 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 | 4549 | i.mu.Lock() | |
| 4513 | 4550 | if refreshErr != nil { | |
| 4514 | 4551 | 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.ImageProtocolNone | 3306 | 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.ThemeName | 3358 | 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 " + label | 4048 | + | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "slices" | |
| 6 | + | "strings" | |
| 5 | 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 | 94 | func TestModelPickerSkipsLlamaRefreshWhenRouterIsNotConfigured(t *testing.T) { | |
| 9 | 95 | i := &Interactive{ | |
| 10 | 96 | 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 ( | |||
| 17 | 17 | // Typing characters narrows the list via a fuzzy substring match that | |
| 18 | 18 | // ignores punctuation (e.g. "opus46" matches "claude-opus-4-6"). | |
| 19 | 19 | 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 | |
| 26 | 28 | ||
| 27 | 29 | // Column widths are computed once on Open() across the entire | |
| 28 | 30 | // catalog so the layout stays stable while the user scrolls or | |
| @@ -46,7 +48,7 @@ func newModelDialog() *modelDialog { | |||
| 46 | 48 | ||
| 47 | 49 | // Open shows the dialog. current is the currently active model id so | |
| 48 | 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 | 52 | d.active = true | |
| 51 | 53 | // Only surface models the user can actually reach: a provider is | |
| 52 | 54 | // 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 | 68 | d.all = sortedModels(filtered) | |
| 67 | 69 | 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 | 75 | d.query = "" | |
| 69 | 76 | d.provW, d.idW = columnWidths(d.all) | |
| 70 | 77 | d.refilter() | |
| @@ -147,6 +154,13 @@ func (d *modelDialog) Render(th tui.Theme, width int) []string { | |||
| 147 | 154 | hint += " - type to filter" | |
| 148 | 155 | } | |
| 149 | 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 | 165 | if len(d.view) == 0 { | |
| 152 | 166 | 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 that | 17 | // 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 bool | 20 | + | active bool |
| 21 | - | all []provider.Model // full catalog, sorted | 21 | + | all []provider.Model // full catalog, sorted |
| 22 | - | view []provider.Model // filtered view shown to the user | 22 | + | view []provider.Model // filtered view shown to the user |
| 23 | - | cursor int | 23 | + | 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 user | 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 | |||
| 26 | 28 | ||||
| 27 | // Column widths are computed once on Open() across the entire | 29 | // Column widths are computed once on Open() across the entire | ||
| 28 | // catalog so the layout stays stable while the user scrolls or | 30 | // 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 so | 49 | // 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 = true | 52 | d.active = true | ||
| 51 | // Only surface models the user can actually reach: a provider is | 53 | // Only surface models the user can actually reach: a provider is | ||
| 52 | // shown only when it has a resolvable credential (api key, oauth | 54 | // 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 = current | 69 | 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 { | |||
| 14 | 14 | items []settingsItem | |
| 15 | 15 | cursor int | |
| 16 | 16 | selecting bool | |
| 17 | + | direct bool | |
| 17 | 18 | optionCursor int | |
| 18 | 19 | parentItems []settingsItem | |
| 19 | 20 | parentCursor int | |
| @@ -57,6 +58,7 @@ func (d *settingsDialog) Open(items []settingsItem) bool { | |||
| 57 | 58 | d.items = items | |
| 58 | 59 | d.cursor = 0 | |
| 59 | 60 | d.selecting = false | |
| 61 | + | d.direct = false | |
| 60 | 62 | d.optionCursor = 0 | |
| 61 | 63 | d.parentItems = nil | |
| 62 | 64 | d.parentCursor = 0 | |
| @@ -64,9 +66,29 @@ func (d *settingsDialog) Open(items []settingsItem) bool { | |||
| 64 | 66 | 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 | 88 | func (d *settingsDialog) Close() { | |
| 68 | 89 | d.active = false | |
| 69 | 90 | d.selecting = false | |
| 91 | + | d.direct = false | |
| 70 | 92 | d.parentItems = nil | |
| 71 | 93 | } | |
| 72 | 94 | func (d *settingsDialog) Active() bool { return d != nil && d.active } | |
| @@ -124,6 +146,10 @@ func (d *settingsDialog) handleOptionKey(k tui.Key) settingsAction { | |||
| 124 | 146 | d.optionCursor++ | |
| 125 | 147 | } | |
| 126 | 148 | case tui.KeyEsc: | |
| 149 | + | if d.direct { | |
| 150 | + | d.Close() | |
| 151 | + | return settingsAction{Close: true} | |
| 152 | + | } | |
| 127 | 153 | d.selecting = false | |
| 128 | 154 | case tui.KeyEnter: | |
| 129 | 155 | return d.selectCurrentOption() | |
| @@ -194,7 +220,12 @@ func (d *settingsDialog) selectCurrentOption() settingsAction { | |||
| 194 | 220 | it.choice = d.optionCursor | |
| 195 | 221 | d.items[d.cursor] = it | |
| 196 | 222 | 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 | 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 | 284 | return d.Render(th, width) | |
| 254 | 285 | } | |
| 255 | 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 | 292 | if it.desc != "" { | |
| 258 | 293 | lines = append(lines, th.FG256(th.Muted, it.desc)) | |
| 259 | 294 | } | |
| @@ -14,6 +14,7 @@ type settingsDialog struct { | |||||
| 14 | items []settingsItem | 14 | items []settingsItem | ||
| 15 | cursor int | 15 | cursor int | ||
| 16 | selecting bool | 16 | selecting bool | ||
| 17 | + | direct bool | |||
| 17 | optionCursor int | 18 | optionCursor int | ||
| 18 | parentItems []settingsItem | 19 | parentItems []settingsItem | ||
| 19 | parentCursor int | 20 | parentCursor int | ||
| @@ -57,6 +58,7 @@ func (d *settingsDialog) Open(items []settingsItem) bool { | |||||
| 57 | d.items = items | 58 | d.items = items | ||
| 58 | d.cursor = 0 | 59 | d.cursor = 0 | ||
| 59 | d.selecting = false | 60 | d.selecting = false | ||
| 61 | + | d.direct = false | |||
| 60 | d.optionCursor = 0 | 62 | d.optionCursor = 0 | ||
| 61 | d.parentItems = nil | 63 | d.parentItems = nil | ||
| 62 | d.parentCursor = 0 | 64 | d.parentCursor = 0 | ||
| @@ -64,9 +66,29 @@ func (d *settingsDialog) Open(items []settingsItem) bool { | |||||
| 64 | return true | 66 | 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 = false | 89 | d.active = false | ||
| 69 | d.selecting = false | 90 | d.selecting = false | ||
| 91 | + | d.direct = false | |||
| 70 | d.parentItems = nil | 92 | 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 = false | 153 | 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.optionCursor | 220 | it.choice = d.optionCursor | ||
| 195 | d.items[d.cursor] = it | 221 | d.items[d.cursor] = it | ||
| 196 | d.selecting = false | 222 | 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{ | |||
| 38 | 38 | {Name: "/login", Desc: "log in via api key or subscription"}, | |
| 39 | 39 | {Name: "/logout", Desc: "clear a provider's credentials"}, | |
| 40 | 40 | {Name: "/model", Desc: "pick a model (or /model <id>)"}, | |
| 41 | + | {Name: "/reasoning", Desc: "set the reasoning level"}, | |
| 41 | 42 | {Name: "/llama", Desc: "manage llama.cpp router models"}, | |
| 42 | 43 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | |
| 43 | 44 | {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 { | |||
| 297 | 297 | 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 | 301 | func (r *Runtime) SetReasoning(level string) error { | |
| 302 | 302 | r.mu.Lock() | |
| 303 | 303 | defer r.mu.Unlock() | |
| @@ -297,7 +297,7 @@ func (r *Runtime) SetModel(model string) error { | |||||
| 297 | return nil | 297 | 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) { | |||
| 330 | 330 | if catalogModel, err := FindModel("amazon-bedrock", resolvedModel); err == nil { | |
| 331 | 331 | model = catalogModel | |
| 332 | 332 | } | |
| 333 | + | reasoning := ClampReasoningForModel(model, req.Reasoning) | |
| 333 | 334 | adaptive := usesAdaptiveThinking(model) | |
| 334 | 335 | ||
| 335 | 336 | if req.System != "" { | |
| @@ -345,10 +346,10 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) { | |||
| 345 | 346 | if !adaptive { | |
| 346 | 347 | out.InferenceConfig.Temperature = req.Temperature | |
| 347 | 348 | } | |
| 348 | - | if adaptive && req.Reasoning != "" { | |
| 349 | + | if adaptive && reasoning != "" { | |
| 349 | 350 | out.AdditionalModelRequestFields = map[string]interface{}{ | |
| 350 | 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 | 355 | 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 = catalogModel | 331 | 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.Temperature | 347 | 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.MaxTokens | 355 | out.InferenceConfig.MaxTokens = req.MaxTokens | ||
M packages/provider/anthropic.go +4 -3
| @@ -230,6 +230,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) { | |||
| 230 | 230 | maxTok = m.MaxOutput | |
| 231 | 231 | } | |
| 232 | 232 | ||
| 233 | + | reasoning := ClampReasoningForModel(m, req.Reasoning) | |
| 233 | 234 | adaptive := usesAdaptiveThinking(m) | |
| 234 | 235 | 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 | 289 | if adaptive { | |
| 289 | 290 | // Adaptive-thinking models decide when and how much to think and | |
| 290 | 291 | // reject explicit budgets. Anthropic models also accept the | |
| 291 | 292 | // output_config.effort extension; compatible implementations may not. | |
| 292 | 293 | out.Thinking = &anthThinking{Type: "adaptive"} | |
| 293 | 294 | if adaptiveEffort { | |
| 294 | - | if effort := AnthropicAdaptiveEffort(req.Reasoning); effort != "" { | |
| 295 | + | if effort := AnthropicAdaptiveEffort(reasoning); effort != "" { | |
| 295 | 296 | out.OutputConfig = &anthOutputConfig{Effort: effort} | |
| 296 | 297 | } | |
| 297 | 298 | } | |
| 298 | 299 | } else { | |
| 299 | - | budget := anthropicReasoningBudget(req.Reasoning) | |
| 300 | + | budget := anthropicReasoningBudget(reasoning) | |
| 300 | 301 | if budget > 0 { | |
| 301 | 302 | // Reasoning requires max_tokens > budget. Keep at least a small | |
| 302 | 303 | // 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.MaxOutput | 230 | 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.AdaptiveThinkingCompat | 235 | 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 and | 290 | // Adaptive-thinking models decide when and how much to think and | ||
| 290 | // reject explicit budgets. Anthropic models also accept the | 291 | // 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 small | 302 | // 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) { | |||
| 141 | 141 | } | |
| 142 | 142 | } | |
| 143 | 143 | ||
| 144 | + | reasoning := ClampReasoningForModel(m, req.Reasoning) | |
| 144 | 145 | out := &gemRequest{} | |
| 145 | 146 | ||
| 146 | 147 | // System prompt → systemInstruction.parts[0].text. | |
| @@ -178,8 +179,8 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) { | |||
| 178 | 179 | if maxTok > 0 { | |
| 179 | 180 | gc.MaxOutputTokens = &maxTok | |
| 180 | 181 | } | |
| 181 | - | if req.Reasoning != "" && m.Reasoning { | |
| 182 | - | tc := geminiThinkingConfig(m.ID, req.Reasoning) | |
| 182 | + | if reasoning != "" && m.Reasoning { | |
| 183 | + | tc := geminiThinkingConfig(m.ID, reasoning) | |
| 183 | 184 | if tc != nil { | |
| 184 | 185 | gc.ThinkingConfig = tc | |
| 185 | 186 | } | |
| @@ -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 = &maxTok | 180 | 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 = tc | 185 | gc.ThinkingConfig = tc | ||
| 185 | } | 186 | } | ||
M packages/provider/models.go +13 -7
| @@ -2,18 +2,20 @@ package provider | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "maps" | |
| 5 | 6 | "sync" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| 8 | 9 | // Model describes a single LLM we know about. | |
| 9 | 10 | 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 | |
| 17 | 19 | ||
| 18 | 20 | // AdaptiveThinking marks Anthropic models that only support the | |
| 19 | 21 | // adaptive thinking mode (Opus 4.7+). These reject explicit | |
| @@ -448,6 +450,9 @@ func Active() []Model { | |||
| 448 | 450 | } | |
| 449 | 451 | out := make([]Model, len(src)) | |
| 450 | 452 | copy(out, src) | |
| 453 | + | for i := range out { | |
| 454 | + | out[i].ReasoningLevelMap = maps.Clone(out[i].ReasoningLevelMap) | |
| 455 | + | } | |
| 451 | 456 | if len(managedModels) == 0 { | |
| 452 | 457 | return out | |
| 453 | 458 | } | |
| @@ -456,6 +461,7 @@ func Active() []Model { | |||
| 456 | 461 | index[model.Provider+"\x00"+model.ID] = i | |
| 457 | 462 | } | |
| 458 | 463 | for _, model := range managedModels { | |
| 464 | + | model.ReasoningLevelMap = maps.Clone(model.ReasoningLevelMap) | |
| 459 | 465 | key := model.Provider + "\x00" + model.ID | |
| 460 | 466 | if i, ok := index[key]; ok { | |
| 461 | 467 | 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 id | 12 | + | ID string // API id |
| 12 | - | DisplayName string | 13 | + | DisplayName string |
| 13 | - | API string // wire API override for providers that support multiple protocols | 14 | + | API string // wire API override for providers that support multiple protocols |
| 14 | - | ContextWindow int | 15 | + | ContextWindow int |
| 15 | - | MaxOutput int | 16 | + | MaxOutput int |
| 16 | - | Reasoning bool // supports reasoning/thinking | 17 | + | 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 the | 20 | // AdaptiveThinking marks Anthropic models that only support the | ||
| 19 | // adaptive thinking mode (Opus 4.7+). These reject explicit | 21 | // 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 out | 457 | return out | ||
| 453 | } | 458 | } | ||
| @@ -456,6 +461,7 @@ func Active() []Model { | |||||
| 456 | index[model.Provider+"\x00"+model.ID] = i | 461 | 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.ID | 465 | key := model.Provider + "\x00" + model.ID | ||
| 460 | if i, ok := index[key]; ok { | 466 | if i, ok := index[key]; ok { | ||
| 461 | out[i] = model | 467 | out[i] = model | ||
M packages/provider/openai.go +9 -6
| @@ -181,10 +181,12 @@ type oaiRequest struct { | |||
| 181 | 181 | // ---- request building ---- | |
| 182 | 182 | ||
| 183 | 183 | 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 | + | } | |
| 188 | 190 | if err != nil { | |
| 189 | 191 | // Unknown model: use sensible defaults so local/custom | |
| 190 | 192 | // models still work without a catalog entry. | |
| @@ -194,6 +196,7 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 194 | 196 | MaxOutput: 8192, | |
| 195 | 197 | } | |
| 196 | 198 | } | |
| 199 | + | reasoning := ClampReasoningForModel(m, req.Reasoning) | |
| 197 | 200 | out := &oaiRequest{ | |
| 198 | 201 | Model: req.Model, | |
| 199 | 202 | Stream: true, | |
| @@ -246,13 +249,13 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 246 | 249 | if maxTok > 0 { | |
| 247 | 250 | out.MaxCompletionTok = &maxTok | |
| 248 | 251 | } | |
| 249 | - | effort := OpenAIReasoningEffort(req.Reasoning) | |
| 252 | + | effort := OpenAIReasoningEffort(reasoning) | |
| 250 | 253 | if usesAdaptiveThinking(m) { | |
| 251 | 254 | // Some gateways expose adaptive-thinking Anthropic models through | |
| 252 | 255 | // the OpenAI-compatible chat-completions wire. They accept the | |
| 253 | 256 | // same reasoning_effort knob, including the top "xhigh" tier; | |
| 254 | 257 | // don't clamp zot's "maximum" to "high" for those models. | |
| 255 | - | effort = OpenAICompatAnthropicEffort(req.Reasoning) | |
| 258 | + | effort = OpenAICompatAnthropicEffort(reasoning) | |
| 256 | 259 | } | |
| 257 | 260 | if effort != "" { | |
| 258 | 261 | 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 other | 185 | + | // 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/custom | 191 | // 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 = &maxTok | 250 | 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 through | 254 | // Some gateways expose adaptive-thinking Anthropic models through | ||
| 252 | // the OpenAI-compatible chat-completions wire. They accept the | 255 | // 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 = effort | 261 | out.ReasoningEffort = effort | ||
M packages/provider/openai_codex.go +2 -2
| @@ -184,7 +184,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||
| 184 | 184 | if err != nil { | |
| 185 | 185 | return nil, err | |
| 186 | 186 | } | |
| 187 | - | _ = m | |
| 187 | + | reasoning := ClampReasoningForModel(m, req.Reasoning) | |
| 188 | 188 | ||
| 189 | 189 | body := &codexRequest{ | |
| 190 | 190 | Model: req.Model, | |
| @@ -195,7 +195,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||
| 195 | 195 | Include: []string{"reasoning.encrypted_content"}, | |
| 196 | 196 | } | |
| 197 | 197 | if m.Reasoning { | |
| 198 | - | if effort := OpenAICodexReasoningEffort(req.Reasoning, req.Model); effort != "" { | |
| 198 | + | if effort := OpenAICodexReasoningEffort(reasoning, req.Model); effort != "" { | |
| 199 | 199 | body.Reasoning = &codexReasoningConfig{Effort: effort} | |
| 200 | 200 | } | |
| 201 | 201 | } | |
| @@ -184,7 +184,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||||
| 184 | if err != nil { | 184 | if err != nil { | ||
| 185 | return nil, err | 185 | return nil, err | ||
| 186 | } | 186 | } | ||
| 187 | - | _ = m | 187 | + | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import "strings" | |
| 4 | 4 | ||
| 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 | |
| 7 | 152 | // an alias for xhigh; "max" is the separate opt-in tier above it. | |
| 8 | 153 | func NormalizeReasoning(level string) string { | |
| 9 | 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-capable | |
| 174 | + | // ReasoningBudget returns zot's approximate token budget for reasoning-capable | |
| 30 | 175 | // providers that accept explicit budgets. | |
| 31 | 176 | func ReasoningBudget(level string) int { | |
| 32 | 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 the | |
| 193 | + | // AnthropicAdaptiveEffort maps zot's user-facing reasoning levels onto the | |
| 49 | 194 | // effort enum used by adaptive-thinking models. These models reject explicit | |
| 50 | 195 | // thinking budgets; reasoning depth is controlled by output_config.effort. | |
| 51 | 196 | 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" remains | 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 | |||
| 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-capable | 174 | + | // 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 the | 193 | + | // AnthropicAdaptiveEffort maps zot's user-facing reasoning levels onto the |
| 49 | // effort enum used by adaptive-thinking models. These models reject explicit | 194 | // 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 @@ | |||
| 1 | 1 | 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 | 91 | func TestReasoningEffortMappings(t *testing.T) { | |
| 6 | 92 | cases := []struct { | |
| @@ -1,6 +1,92 @@ | |||||
| 1 | package provider | 1 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/json" | |
| 5 | 5 | "fmt" | |
| 6 | + | "maps" | |
| 6 | 7 | "os" | |
| 7 | 8 | "strings" | |
| 8 | 9 | ) | |
| @@ -56,18 +57,46 @@ func CustomProviders() map[string]CustomProviderConfig { return customProviders | |||
| 56 | 57 | ||
| 57 | 58 | // UserModel is a single model entry in the user's models.json. | |
| 58 | 59 | 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 | |
| 71 | 100 | } | |
| 72 | 101 | ||
| 73 | 102 | // LoadUserModels reads a models.json file and returns the models | |
| @@ -166,24 +195,37 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) { | |||
| 166 | 195 | 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 | 205 | // Propagate provider-level BaseURL to models without their own. | |
| 170 | 206 | modelBaseURL := um.BaseURL | |
| 171 | 207 | if modelBaseURL == "" { | |
| 172 | 208 | modelBaseURL = prov.BaseURL | |
| 173 | 209 | } | |
| 210 | + | modelAPI := "" | |
| 211 | + | if cfg, ok := customProviders[normalized]; ok { | |
| 212 | + | modelAPI = cfg.API | |
| 213 | + | } | |
| 174 | 214 | 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", | |
| 187 | 229 | } | |
| 188 | 230 | if m.DisplayName == "" { | |
| 189 | 231 | m.DisplayName = m.ID | |
| @@ -247,6 +289,12 @@ func SetUserModels(models []Model) { | |||
| 247 | 289 | existing.MaxOutput = um.MaxOutput | |
| 248 | 290 | } | |
| 249 | 291 | 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 | 298 | if um.BaseURL != "" { | |
| 251 | 299 | existing.BaseURL = um.BaseURL | |
| 252 | 300 | } | |
| @@ -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 only | 70 | + | BaseURL string `json:"baseUrl,omitempty"` |
| 70 | - | API string `json:"api"` // informational only | 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 | |||
| 71 | } | 100 | } | ||
| 72 | 101 | ||||
| 73 | // LoadUserModels reads a models.json file and returns the models | 102 | // LoadUserModels reads a models.json file and returns the models | ||
| @@ -166,24 +195,37 @@ func LoadUserModelsWithWarnings(path string) ([]Model, []string) { | |||||
| 166 | um.MaxTokens = 0 | 195 | 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.BaseURL | 206 | modelBaseURL := um.BaseURL | ||
| 171 | if modelBaseURL == "" { | 207 | if modelBaseURL == "" { | ||
| 172 | modelBaseURL = prov.BaseURL | 208 | 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.ID | 231 | m.DisplayName = m.ID | ||
| @@ -247,6 +289,12 @@ func SetUserModels(models []Model) { | |||||
| 247 | existing.MaxOutput = um.MaxOutput | 289 | existing.MaxOutput = um.MaxOutput | ||
| 248 | } | 290 | } | ||
| 249 | existing.Reasoning = um.Reasoning | 291 | 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.BaseURL | 299 | existing.BaseURL = um.BaseURL | ||
| 252 | } | 300 | } | ||
M packages/provider/usermodels_test.go +64 -0
| @@ -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 | 124 | func TestLoadUserModelsAcceptsOpenAIResponsesAPI(t *testing.T) { | |
| 61 | 125 | dir := t.TempDir() | |
| 62 | 126 | 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) { | |||
| 57 | 57 | } | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | - | func TestStatusBarThinkingLevelBetweenModelAndStats(t *testing.T) { | |
| 60 | + | func TestStatusBarReasoningLevelBetweenModelAndStats(t *testing.T) { | |
| 61 | 61 | lines := StatusBar(StatusBarParams{ | |
| 62 | 62 | Theme: Dark, | |
| 63 | 63 | Provider: "openai-codex", | |
| @@ -75,17 +75,17 @@ func TestStatusBarThinkingLevelBetweenModelAndStats(t *testing.T) { | |||
| 75 | 75 | } | |
| 76 | 76 | plain := stripANSI(lines[0]) | |
| 77 | 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 | 79 | 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) | |
| 82 | 82 | } | |
| 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) | |
| 85 | 85 | } | |
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | - | func TestStatusBarUsesThinkingMaxThemeColor(t *testing.T) { | |
| 88 | + | func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) { | |
| 89 | 89 | th := Dark | |
| 90 | 90 | th.ThinkingMax = 201 | |
| 91 | 91 | lines := StatusBar(StatusBarParams{ | |
| @@ -94,12 +94,12 @@ func TestStatusBarUsesThinkingMaxThemeColor(t *testing.T) { | |||
| 94 | 94 | if len(lines) != 1 { | |
| 95 | 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") { | |
| 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]) | |
| 99 | 99 | } | |
| 100 | 100 | } | |
| 101 | 101 | ||
| 102 | - | func TestStatusBarNarrowKeepsModelAndThinkingTogetherWhenTheyFit(t *testing.T) { | |
| 102 | + | func TestStatusBarNarrowKeepsModelAndReasoningTogetherWhenTheyFit(t *testing.T) { | |
| 103 | 103 | lines := StatusBar(StatusBarParams{ | |
| 104 | 104 | Theme: Dark, | |
| 105 | 105 | Provider: "openai-codex", | |
| @@ -121,10 +121,10 @@ func TestStatusBarNarrowKeepsModelAndThinkingTogetherWhenTheyFit(t *testing.T) { | |||
| 121 | 121 | for i, line := range lines { | |
| 122 | 122 | plain[i] = stripANSI(line) | |
| 123 | 123 | } | |
| 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]) | |
| 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 | 128 | t.Fatalf("line 2 should contain only stats, got %q", plain[1]) | |
| 129 | 129 | } | |
| 130 | 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 | 136 | lines := StatusBar(StatusBarParams{ | |
| 137 | 137 | Theme: Dark, | |
| 138 | 138 | Provider: "openai-codex", | |
| @@ -146,7 +146,7 @@ func TestStatusBarNarrowSplitsAfterThinkingLevel(t *testing.T) { | |||
| 146 | 146 | Cols: 40, | |
| 147 | 147 | }) | |
| 148 | 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 | 151 | plain := make([]string, len(lines)) | |
| 152 | 152 | for i, line := range lines { | |
| @@ -155,8 +155,8 @@ func TestStatusBarNarrowSplitsAfterThinkingLevel(t *testing.T) { | |||
| 155 | 155 | if !strings.Contains(plain[0], "(openai-codex) gpt-5.5") { | |
| 156 | 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") { | |
| 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]) | |
| 160 | 160 | } | |
| 161 | 161 | if !strings.Contains(plain[2], "↑4.3M ↓2") { | |
| 162 | 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 | 170 | lines := StatusBar(StatusBarParams{ | |
| 171 | 171 | Theme: Dark, | |
| 172 | 172 | Provider: "openai-codex", | |
| @@ -180,7 +180,7 @@ func TestStatusBarVeryNarrowSplitsAfterThinkingLevel(t *testing.T) { | |||
| 180 | 180 | Cols: 32, | |
| 181 | 181 | }) | |
| 182 | 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 | 185 | plain := make([]string, len(lines)) | |
| 186 | 186 | for i, line := range lines { | |
| @@ -189,8 +189,8 @@ func TestStatusBarVeryNarrowSplitsAfterThinkingLevel(t *testing.T) { | |||
| 189 | 189 | if !strings.Contains(plain[0], "(openai-codex) gpt-5.5") { | |
| 190 | 190 | t.Fatalf("line 1 should contain model info, got %q", plain[0]) | |
| 191 | 191 | } | |
| 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]) | |
| 194 | 194 | } | |
| 195 | 195 | if !strings.Contains(plain[2], "↑4.3M ↓2") { | |
| 196 | 196 | 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 := Dark | 89 | th := Dark | ||
| 90 | th.ThinkingMax = 201 | 90 | 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 { | |||
| 49 | 49 | Error int | |
| 50 | 50 | Warning int | |
| 51 | 51 | 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 | |
| 53 | 53 | SelectionBG int // background for highlighted rows | |
| 54 | 54 | SelectionFG int // foreground for highlighted rows | |
| 55 | 55 | ||
| @@ -49,7 +49,7 @@ type Theme struct { | |||||
| 49 | Error int | 49 | Error int | ||
| 50 | Warning int | 50 | Warning int | ||
| 51 | Spinner int // spinner + funny working line | 51 | 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 |
| 53 | SelectionBG int // background for highlighted rows | 53 | SelectionBG int // background for highlighted rows | ||
| 54 | SelectionFG int // foreground for highlighted rows | 54 | SelectionFG int // foreground for highlighted rows | ||
| 55 | 55 | ||||
M packages/tui/view.go +20 -20
| @@ -2504,15 +2504,15 @@ func StatusBar(p StatusBarParams) []string { | |||
| 2504 | 2504 | const pad = " " // 2 spaces | |
| 2505 | 2505 | ||
| 2506 | 2506 | 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 | |
| 2511 | 2511 | } | |
| 2512 | 2512 | statsText := strings.Join(stats, " ") | |
| 2513 | 2513 | middleParts := make([]string, 0, 2) | |
| 2514 | - | if thinkingText != "" { | |
| 2515 | - | middleParts = append(middleParts, thinkingText) | |
| 2514 | + | if reasoningText != "" { | |
| 2515 | + | middleParts = append(middleParts, reasoningText) | |
| 2516 | 2516 | } | |
| 2517 | 2517 | if statsText != "" { | |
| 2518 | 2518 | middleParts = append(middleParts, statsText) | |
| @@ -2541,7 +2541,7 @@ func StatusBar(p StatusBarParams) []string { | |||
| 2541 | 2541 | if middle != "" { | |
| 2542 | 2542 | leftBuilder.WriteString(pad) | |
| 2543 | 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 | 2547 | cwd := shortenHome(p.CWD) | |
| @@ -2570,13 +2570,13 @@ func StatusBar(p StatusBarParams) []string { | |||
| 2570 | 2570 | modelLine := pad + th.FG256(th.Muted, left) | |
| 2571 | 2571 | lines := []string{busyLine} | |
| 2572 | 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 | 2574 | } else { | |
| 2575 | 2575 | var infoBuilder strings.Builder | |
| 2576 | 2576 | infoBuilder.WriteString(modelLine) | |
| 2577 | 2577 | if middle != "" { | |
| 2578 | 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 | 2581 | lines = append(lines, infoBuilder.String()) | |
| 2582 | 2582 | } | |
| @@ -2592,7 +2592,7 @@ func StatusBar(p StatusBarParams) []string { | |||
| 2592 | 2592 | // into an awkward position on small widths. | |
| 2593 | 2593 | if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols { | |
| 2594 | 2594 | 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 | 2596 | if cwd != "" { | |
| 2597 | 2597 | lines = append(lines, pad+th.FG256(th.Muted, cwd)) | |
| 2598 | 2598 | } | |
| @@ -2609,9 +2609,9 @@ func StatusBar(p StatusBarParams) []string { | |||
| 2609 | 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 | 2613 | modelLine := pad + th.FG256(th.Muted, modelText) | |
| 2614 | - | if thinkingText == "" { | |
| 2614 | + | if reasoningText == "" { | |
| 2615 | 2615 | lines = append(lines, modelLine) | |
| 2616 | 2616 | if statsText != "" { | |
| 2617 | 2617 | lines = append(lines, pad+th.FG256(th.Muted, statsText)) | |
| @@ -2619,13 +2619,13 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, thinking | |||
| 2619 | 2619 | return lines | |
| 2620 | 2620 | } | |
| 2621 | 2621 | ||
| 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)) | |
| 2626 | 2626 | } else { | |
| 2627 | 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 | 2630 | if statsText != "" { | |
| 2631 | 2631 | lines = append(lines, pad+th.FG256(th.Muted, statsText)) | |
| @@ -2633,14 +2633,14 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, thinking | |||
| 2633 | 2633 | return lines | |
| 2634 | 2634 | } | |
| 2635 | 2635 | ||
| 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") { | |
| 2638 | 2638 | return th.ThinkingMax | |
| 2639 | 2639 | } | |
| 2640 | 2640 | return th.Muted | |
| 2641 | 2641 | } | |
| 2642 | 2642 | ||
| 2643 | - | func thinkingLevelLabel(level string) string { | |
| 2643 | + | func reasoningLevelLabel(level string) string { | |
| 2644 | 2644 | switch strings.ToLower(strings.TrimSpace(level)) { | |
| 2645 | 2645 | case "", "off", "none", "no", "false", "disabled": | |
| 2646 | 2646 | return "" | |
| @@ -2504,15 +2504,15 @@ func StatusBar(p StatusBarParams) []string { | |||||
| 2504 | const pad = " " // 2 spaces | 2504 | 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: " + thinking | 2510 | + | 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.Builder | 2575 | 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 []string | 2594 | 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 lines | 2619 | return lines | ||
| 2620 | } | 2620 | } | ||
| 2621 | 2621 | ||||
| 2622 | - | modelThinkingPlain := pad + modelText + pad + thinkingText | 2622 | + | 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 lines | 2633 | 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.ThinkingMax | 2638 | return th.ThinkingMax | ||
| 2639 | } | 2639 | } | ||
| 2640 | return th.Muted | 2640 | 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 "" | ||