Merge branch 'main' into feature/zotfile-local-runtime
43 files · +1439 -247
M README.md +14 -7
| @@ -3,6 +3,12 @@ | |||
| 3 | 3 | <img src="packages/provider/auth/assets/zot-logo.png" alt="zot coding agent harness" width="130" height="130" /> | |
| 4 | 4 | </a> | |
| 5 | 5 | </div> | |
| 6 | + | <br> | |
| 7 | + | <p align="center"> | |
| 8 | + | <a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a> | |
| 9 | + | <img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white"> | |
| 10 | + | <img alt="30+ providers" src="https://img.shields.io/badge/providers-30+-34E2EA"> | |
| 11 | + | </p> | |
| 6 | 12 | <p align="center"> | |
| 7 | 13 | <a href="https://www.zot.sh">zot.sh</a> | |
| 8 | 14 | </p> | |
| @@ -73,9 +79,10 @@ The easiest way is to just run `zot` and type `/login`. The TUI opens even witho | |||
| 73 | 79 | 3. `$ZOT_HOME/auth.json` (API key or OAuth token; mode 0600) | |
| 74 | 80 | ||
| 75 | 81 | `$ZOT_HOME` defaults to: | |
| 76 | - | - macOS: `~/Library/Application Support/zot` | |
| 77 | - | - Linux: `$XDG_STATE_HOME/zot` or `~/.local/state/zot` | |
| 78 | - | - Windows: `%LOCALAPPDATA%\zot` | |
| 82 | + | - All platforms: `$XDG_STATE_HOME/zot` when `XDG_STATE_HOME` is set | |
| 83 | + | - macOS fallback: `~/Library/Application Support/zot` | |
| 84 | + | - Linux fallback: `~/.local/state/zot` | |
| 85 | + | - Windows fallback: `%LOCALAPPDATA%\zot` | |
| 79 | 86 | ||
| 80 | 87 | ### `/login` flow | |
| 81 | 88 | ||
| @@ -174,7 +181,7 @@ zot --help | |||
| 174 | 181 | | `--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. | | |
| 175 | 182 | | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). | | |
| 176 | 183 | | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). | | |
| 177 | - | | `--reasoning off\|minimum\|low\|medium\|high\|maximum` | Set thinking level on supported models (default: off). | | |
| 184 | + | | `--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`. | | |
| 178 | 185 | | `-c`, `--continue` | Resume the latest session for this cwd. | | |
| 179 | 186 | | `-r`, `--resume` | Pick a session to resume. | | |
| 180 | 187 | | `--session <path>` | Resume a specific session file. | | |
| @@ -333,7 +340,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 333 | 340 | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents. | |
| 334 | 341 | - **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`. | |
| 335 | 342 | - **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). | |
| 336 | - | - **thinking level** — choose reasoning for supported models: off (default; no reasoning), minimum (~1k tokens), low (~2k), medium (~8k), high (~16k), maximum (~32k). The change is persisted to `config.json` and applied to the running agent's next model call. | |
| 343 | + | - **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. | |
| 337 | 344 | - **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). | |
| 338 | 345 | - **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. | |
| 339 | 346 | ||
| @@ -392,7 +399,7 @@ No configuration is required — the candidate list is built dynamically from yo | |||
| 392 | 399 | ||
| 393 | 400 | ### Custom models | |
| 394 | 401 | ||
| 395 | - | Place a `models.json` in `$ZOT_HOME` (macOS: `~/Library/Application Support/zot/`, Linux: `~/.local/state/zot/`) to add models that aren't in the baked-in catalog or to override existing entries: | |
| 402 | + | Place a `models.json` in `$ZOT_HOME` (`$XDG_STATE_HOME/zot/` when set, otherwise the platform default above) to add models that aren't in the baked-in catalog or to override existing entries: | |
| 396 | 403 | ||
| 397 | 404 | ```json | |
| 398 | 405 | { | |
| @@ -530,7 +537,7 @@ Use `/login` and pick **api key** to paste an AI Studio key. zot probes `/v1beta | |||
| 530 | 537 | ||
| 531 | 538 | > **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). | |
| 532 | 539 | ||
| 533 | - | Reasoning levels (`--reasoning off|minimum|low|medium|high|maximum`, also configurable in `/settings` as **thinking level**) map differently per generation. Budget-based providers use roughly 1k/2k/8k/16k/32k thinking tokens for minimum/low/medium/high/maximum, with provider/model caps applied (Gemini 2.5 Pro caps at 32k; Flash at 24k). Gemini 3.x uses the `thinkingLevel` enum (`MINIMAL`/`LOW`/`MEDIUM`/`HIGH`), with Gemini-3-Pro pinned to `LOW` minimum and `HIGH` for any "medium" or higher request. Effort-based OpenAI-compatible chat providers map minimum to `low`, low/medium directly, and high/maximum to `high`; the Codex/Responses backend maps maximum to `xhigh` where supported. `off` sends no reasoning config. 2.0-family Gemini models have no thinking config at all. | |
| 540 | + | 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. | |
| 534 | 541 | ||
| 535 | 542 | You can add additional Gemini model IDs to `models.json` under the `google` provider. | |
| 536 | 543 | ||
| @@ -3,6 +3,12 @@ | |||||
| 3 | <img src="packages/provider/auth/assets/zot-logo.png" alt="zot coding agent harness" width="130" height="130" /> | 3 | <img src="packages/provider/auth/assets/zot-logo.png" alt="zot coding agent harness" width="130" height="130" /> | ||
| 4 | </a> | 4 | </a> | ||
| 5 | </div> | 5 | </div> | ||
| 6 | + | <br> | |||
| 7 | + | <p align="center"> | |||
| 8 | + | <a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a> | |||
| 9 | + | <img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white"> | |||
| 10 | + | <img alt="30+ providers" src="https://img.shields.io/badge/providers-30+-34E2EA"> | |||
| 11 | + | </p> | |||
| 6 | <p align="center"> | 12 | <p align="center"> | ||
| 7 | <a href="https://www.zot.sh">zot.sh</a> | 13 | <a href="https://www.zot.sh">zot.sh</a> | ||
| 8 | </p> | 14 | </p> | ||
| @@ -73,9 +79,10 @@ The easiest way is to just run `zot` and type `/login`. The TUI opens even witho | |||||
| 73 | 3. `$ZOT_HOME/auth.json` (API key or OAuth token; mode 0600) | 79 | 3. `$ZOT_HOME/auth.json` (API key or OAuth token; mode 0600) | ||
| 74 | 80 | ||||
| 75 | `$ZOT_HOME` defaults to: | 81 | `$ZOT_HOME` defaults to: | ||
| 76 | - | - macOS: `~/Library/Application Support/zot` | 82 | + | - All platforms: `$XDG_STATE_HOME/zot` when `XDG_STATE_HOME` is set |
| 77 | - | - Linux: `$XDG_STATE_HOME/zot` or `~/.local/state/zot` | 83 | + | - macOS fallback: `~/Library/Application Support/zot` |
| 78 | - | - Windows: `%LOCALAPPDATA%\zot` | 84 | + | - Linux fallback: `~/.local/state/zot` |
| 85 | + | - Windows fallback: `%LOCALAPPDATA%\zot` | |||
| 79 | 86 | ||||
| 80 | ### `/login` flow | 87 | ### `/login` flow | ||
| 81 | 88 | ||||
| @@ -174,7 +181,7 @@ zot --help | |||||
| 174 | | `--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. | | 181 | | `--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. | | ||
| 175 | | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). | | 182 | | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). | | ||
| 176 | | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). | | 183 | | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). | | ||
| 177 | - | | `--reasoning off\|minimum\|low\|medium\|high\|maximum` | Set thinking level on supported models (default: off). | | 184 | + | | `--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`. | |
| 178 | | `-c`, `--continue` | Resume the latest session for this cwd. | | 185 | | `-c`, `--continue` | Resume the latest session for this cwd. | | ||
| 179 | | `-r`, `--resume` | Pick a session to resume. | | 186 | | `-r`, `--resume` | Pick a session to resume. | | ||
| 180 | | `--session <path>` | Resume a specific session file. | | 187 | | `--session <path>` | Resume a specific session file. | | ||
| @@ -333,7 +340,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||||
| 333 | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents. | 340 | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents. | ||
| 334 | - **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`. | 341 | - **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`. | ||
| 335 | - **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). | 342 | - **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). | ||
| 336 | - | - **thinking level** — choose reasoning for supported models: off (default; no reasoning), minimum (~1k tokens), low (~2k), medium (~8k), high (~16k), maximum (~32k). The change is persisted to `config.json` and applied to the running agent's next model call. | 343 | + | - **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. |
| 337 | - **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). | 344 | - **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). | ||
| 338 | - **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. | 345 | - **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. | ||
| 339 | 346 | ||||
| @@ -392,7 +399,7 @@ No configuration is required — the candidate list is built dynamically from yo | |||||
| 392 | 399 | ||||
| 393 | ### Custom models | 400 | ### Custom models | ||
| 394 | 401 | ||||
| 395 | - | Place a `models.json` in `$ZOT_HOME` (macOS: `~/Library/Application Support/zot/`, Linux: `~/.local/state/zot/`) to add models that aren't in the baked-in catalog or to override existing entries: | 402 | + | Place a `models.json` in `$ZOT_HOME` (`$XDG_STATE_HOME/zot/` when set, otherwise the platform default above) to add models that aren't in the baked-in catalog or to override existing entries: |
| 396 | 403 | ||||
| 397 | ```json | 404 | ```json | ||
| 398 | { | 405 | { | ||
| @@ -530,7 +537,7 @@ Use `/login` and pick **api key** to paste an AI Studio key. zot probes `/v1beta | |||||
| 530 | 537 | ||||
| 531 | > **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). | 538 | > **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). | ||
| 532 | 539 | ||||
| 533 | - | Reasoning levels (`--reasoning off|minimum|low|medium|high|maximum`, also configurable in `/settings` as **thinking level**) map differently per generation. Budget-based providers use roughly 1k/2k/8k/16k/32k thinking tokens for minimum/low/medium/high/maximum, with provider/model caps applied (Gemini 2.5 Pro caps at 32k; Flash at 24k). Gemini 3.x uses the `thinkingLevel` enum (`MINIMAL`/`LOW`/`MEDIUM`/`HIGH`), with Gemini-3-Pro pinned to `LOW` minimum and `HIGH` for any "medium" or higher request. Effort-based OpenAI-compatible chat providers map minimum to `low`, low/medium directly, and high/maximum to `high`; the Codex/Responses backend maps maximum to `xhigh` where supported. `off` sends no reasoning config. 2.0-family Gemini models have no thinking config at all. | 540 | + | 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. |
| 534 | 541 | ||||
| 535 | You can add additional Gemini model IDs to `models.json` under the `google` provider. | 542 | You can add additional Gemini model IDs to `models.json` under the `google` provider. | ||
| 536 | 543 | ||||
M docs/extensions.md +3 -2
| @@ -97,8 +97,9 @@ zot scans two directories on startup, in this order: | |||
| 97 | 97 | 2. **Global**: `$ZOT_HOME/extensions/<name>/extension.json` | |
| 98 | 98 | ||
| 99 | 99 | A project-local extension with the same name wins over a global one. | |
| 100 | - | On macOS `$ZOT_HOME` defaults to `~/Library/Application Support/zot/`; | |
| 101 | - | on Linux it's `$XDG_STATE_HOME/zot` or `~/.local/state/zot`. | |
| 100 | + | When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to | |
| 101 | + | `$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/` | |
| 102 | + | on macOS, `~/.local/state/zot` on Linux, or `%LOCALAPPDATA%\zot` on Windows. | |
| 102 | 103 | ||
| 103 | 104 | Because each extension owns its own directory, the recommended place | |
| 104 | 105 | for extension state is inside that directory itself (for example | |
| @@ -97,8 +97,9 @@ zot scans two directories on startup, in this order: | |||||
| 97 | 2. **Global**: `$ZOT_HOME/extensions/<name>/extension.json` | 97 | 2. **Global**: `$ZOT_HOME/extensions/<name>/extension.json` | ||
| 98 | 98 | ||||
| 99 | A project-local extension with the same name wins over a global one. | 99 | A project-local extension with the same name wins over a global one. | ||
| 100 | - | On macOS `$ZOT_HOME` defaults to `~/Library/Application Support/zot/`; | 100 | + | When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to |
| 101 | - | on Linux it's `$XDG_STATE_HOME/zot` or `~/.local/state/zot`. | 101 | + | `$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/` |
| 102 | + | on macOS, `~/.local/state/zot` on Linux, or `%LOCALAPPDATA%\zot` on Windows. | |||
| 102 | 103 | ||||
| 103 | Because each extension owns its own directory, the recommended place | 104 | Because each extension owns its own directory, the recommended place | ||
| 104 | for extension state is inside that directory itself (for example | 105 | for extension state is inside that directory itself (for example | ||
M docs/providers.md +7 -3
| @@ -162,12 +162,16 @@ zot --provider cloudflare-workers-ai | |||
| 162 | 162 | ```bash | |
| 163 | 163 | export AZURE_OPENAI_API_KEY=... | |
| 164 | 164 | export AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com | |
| 165 | - | export AZURE_OPENAI_API_VERSION=2024-02-01 # optional | |
| 165 | + | export AZURE_OPENAI_API_VERSION=v1 # optional, v1 is the default | |
| 166 | 166 | zot --provider azure-openai-responses | |
| 167 | 167 | ``` | |
| 168 | 168 | ||
| 169 | - | If your Azure deployment names differ from zot model IDs, add model overrides | |
| 170 | - | in `$ZOT_HOME/models.json`. | |
| 169 | + | The provider uses Azure's Responses API. If deployment names differ from zot | |
| 170 | + | model IDs, map them without changing the catalog: | |
| 171 | + | ||
| 172 | + | ```bash | |
| 173 | + | export AZURE_OPENAI_DEPLOYMENT_NAME_MAP='gpt-5.6-luna=luna-preview,gpt-5.6-sol=sol-preview' | |
| 174 | + | ``` | |
| 171 | 175 | ||
| 172 | 176 | ## Auth file | |
| 173 | 177 | ||
| @@ -162,12 +162,16 @@ zot --provider cloudflare-workers-ai | |||||
| 162 | ```bash | 162 | ```bash | ||
| 163 | export AZURE_OPENAI_API_KEY=... | 163 | export AZURE_OPENAI_API_KEY=... | ||
| 164 | export AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com | 164 | export AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com | ||
| 165 | - | export AZURE_OPENAI_API_VERSION=2024-02-01 # optional | 165 | + | export AZURE_OPENAI_API_VERSION=v1 # optional, v1 is the default |
| 166 | zot --provider azure-openai-responses | 166 | zot --provider azure-openai-responses | ||
| 167 | ``` | 167 | ``` | ||
| 168 | 168 | ||||
| 169 | - | If your Azure deployment names differ from zot model IDs, add model overrides | 169 | + | The provider uses Azure's Responses API. If deployment names differ from zot |
| 170 | - | in `$ZOT_HOME/models.json`. | 170 | + | model IDs, map them without changing the catalog: |
| 171 | + | ||||
| 172 | + | ```bash | |||
| 173 | + | export AZURE_OPENAI_DEPLOYMENT_NAME_MAP='gpt-5.6-luna=luna-preview,gpt-5.6-sol=sol-preview' | |||
| 174 | + | ``` | |||
| 171 | 175 | ||||
| 172 | ## Auth file | 176 | ## Auth file | ||
| 173 | 177 | ||||
M docs/rpc.md +10 -0
| @@ -153,6 +153,16 @@ Switch model within the same provider. | |||
| 153 | 153 | ||
| 154 | 154 | Cross-provider swaps require relaunching `zot rpc` with the new `--provider`. | |
| 155 | 155 | ||
| 156 | + | ### `set_reasoning` | |
| 157 | + | ||
| 158 | + | Set the thinking level for subsequent prompts without restarting the process. Accepted values are `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, and `max`. | |
| 159 | + | ||
| 160 | + | ```json | |
| 161 | + | {"id":"8","type":"set_reasoning","reasoning":"max"} | |
| 162 | + | ``` | |
| 163 | + | ||
| 164 | + | The `max` tier is sent natively to supported models and clamped for other backends. | |
| 165 | + | ||
| 156 | 166 | ### `get_models` | |
| 157 | 167 | ||
| 158 | 168 | List models known for the current provider. | |
| @@ -153,6 +153,16 @@ Switch model within the same provider. | |||||
| 153 | 153 | ||||
| 154 | Cross-provider swaps require relaunching `zot rpc` with the new `--provider`. | 154 | Cross-provider swaps require relaunching `zot rpc` with the new `--provider`. | ||
| 155 | 155 | ||||
| 156 | + | ### `set_reasoning` | |||
| 157 | + | ||||
| 158 | + | Set the thinking level for subsequent prompts without restarting the process. Accepted values are `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, and `max`. | |||
| 159 | + | ||||
| 160 | + | ```json | |||
| 161 | + | {"id":"8","type":"set_reasoning","reasoning":"max"} | |||
| 162 | + | ``` | |||
| 163 | + | ||||
| 164 | + | The `max` tier is sent natively to supported models and clamped for other backends. | |||
| 165 | + | ||||
| 156 | ### `get_models` | 166 | ### `get_models` | ||
| 157 | 167 | ||||
| 158 | List models known for the current provider. | 168 | List models known for the current provider. | ||
M docs/skills.md +3 -2
| @@ -64,8 +64,9 @@ skill ecosystem works in zot unchanged. Drop your existing | |||
| 64 | 64 | `.claude/skills/` or `.agents/skills/` directories into a project and | |
| 65 | 65 | zot will pick them up. | |
| 66 | 66 | ||
| 67 | - | `$ZOT_HOME` defaults to `~/Library/Application Support/zot/` on macOS, | |
| 68 | - | `$XDG_STATE_HOME/zot` on Linux, `%LOCALAPPDATA%\zot` on Windows. | |
| 67 | + | When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to | |
| 68 | + | `$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/` | |
| 69 | + | on macOS, `~/.local/state/zot` on Linux, or `%LOCALAPPDATA%\zot` on Windows. | |
| 69 | 70 | ||
| 70 | 71 | ## Inspecting installed skills | |
| 71 | 72 | ||
| @@ -64,8 +64,9 @@ skill ecosystem works in zot unchanged. Drop your existing | |||||
| 64 | `.claude/skills/` or `.agents/skills/` directories into a project and | 64 | `.claude/skills/` or `.agents/skills/` directories into a project and | ||
| 65 | zot will pick them up. | 65 | zot will pick them up. | ||
| 66 | 66 | ||||
| 67 | - | `$ZOT_HOME` defaults to `~/Library/Application Support/zot/` on macOS, | 67 | + | When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to |
| 68 | - | `$XDG_STATE_HOME/zot` on Linux, `%LOCALAPPDATA%\zot` on Windows. | 68 | + | `$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/` |
| 69 | + | on macOS, `~/.local/state/zot` on Linux, or `%LOCALAPPDATA%\zot` on Windows. | |||
| 69 | 70 | ||||
| 70 | ## Inspecting installed skills | 71 | ## Inspecting installed skills | ||
| 71 | 72 | ||||
M docs/themes.md +1 -0
| @@ -191,6 +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 | 195 | - `selection_bg` — highlighted row background. | |
| 195 | 196 | - `selection_fg` — highlighted row foreground. | |
| 196 | 197 | ||
| @@ -191,6 +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 | - `selection_bg` — highlighted row background. | 195 | - `selection_bg` — highlighted row background. | ||
| 195 | - `selection_fg` — highlighted row foreground. | 196 | - `selection_fg` — highlighted row foreground. | ||
| 196 | 197 | ||||
M examples/extensions/mcp-bridge/Makefile +3 -3
| @@ -4,10 +4,10 @@ BIN := $(notdir $(CURDIR)) | |||
| 4 | 4 | # Must match "name" in extension.json (slash command/logical extension name). | |
| 5 | 5 | EXT := mcp | |
| 6 | 6 | ZOT_HOME ?= $(shell \ | |
| 7 | - | if [ "$$(uname -s)" = "Darwin" ]; then \ | |
| 8 | - | printf '%s/Library/Application Support/zot' "$$HOME"; \ | |
| 9 | - | elif [ -n "$$XDG_STATE_HOME" ]; then \ | |
| 7 | + | if [ -n "$$XDG_STATE_HOME" ]; then \ | |
| 10 | 8 | printf '%s/zot' "$$XDG_STATE_HOME"; \ | |
| 9 | + | elif [ "$$(uname -s)" = "Darwin" ]; then \ | |
| 10 | + | printf '%s/Library/Application Support/zot' "$$HOME"; \ | |
| 11 | 11 | else \ | |
| 12 | 12 | printf '%s/.local/state/zot' "$$HOME"; \ | |
| 13 | 13 | fi) | |
| @@ -4,10 +4,10 @@ BIN := $(notdir $(CURDIR)) | |||||
| 4 | # Must match "name" in extension.json (slash command/logical extension name). | 4 | # Must match "name" in extension.json (slash command/logical extension name). | ||
| 5 | EXT := mcp | 5 | EXT := mcp | ||
| 6 | ZOT_HOME ?= $(shell \ | 6 | ZOT_HOME ?= $(shell \ | ||
| 7 | - | if [ "$$(uname -s)" = "Darwin" ]; then \ | 7 | + | if [ -n "$$XDG_STATE_HOME" ]; then \ |
| 8 | - | printf '%s/Library/Application Support/zot' "$$HOME"; \ | |||
| 9 | - | elif [ -n "$$XDG_STATE_HOME" ]; then \ | |||
| 10 | printf '%s/zot' "$$XDG_STATE_HOME"; \ | 8 | printf '%s/zot' "$$XDG_STATE_HOME"; \ | ||
| 9 | + | elif [ "$$(uname -s)" = "Darwin" ]; then \ | |||
| 10 | + | printf '%s/Library/Application Support/zot' "$$HOME"; \ | |||
| 11 | else \ | 11 | else \ | ||
| 12 | printf '%s/.local/state/zot' "$$HOME"; \ | 12 | printf '%s/.local/state/zot' "$$HOME"; \ | ||
| 13 | fi) | 13 | fi) | ||
M examples/extensions/mcp-bridge/README.md +1 -1
| @@ -61,7 +61,7 @@ Config files are loaded from two locations (project overrides global per-server) | |||
| 61 | 61 | ||
| 62 | 62 | | Location | Scope | | |
| 63 | 63 | |---|---| | |
| 64 | - | | `$ZOT_HOME/mcp.json` | Global (macOS: `~/Library/Application Support/zot/mcp.json`) | | |
| 64 | + | | `$ZOT_HOME/mcp.json` | Global (`$XDG_STATE_HOME/zot/mcp.json` when `XDG_STATE_HOME` is set) | | |
| 65 | 65 | | `.zot/mcp.json` | Project-level (in your project root) | | |
| 66 | 66 | ||
| 67 | 67 | ### Config Format | |
| @@ -61,7 +61,7 @@ Config files are loaded from two locations (project overrides global per-server) | |||||
| 61 | 61 | ||||
| 62 | | Location | Scope | | 62 | | Location | Scope | | ||
| 63 | |---|---| | 63 | |---|---| | ||
| 64 | - | | `$ZOT_HOME/mcp.json` | Global (macOS: `~/Library/Application Support/zot/mcp.json`) | | 64 | + | | `$ZOT_HOME/mcp.json` | Global (`$XDG_STATE_HOME/zot/mcp.json` when `XDG_STATE_HOME` is set) | |
| 65 | | `.zot/mcp.json` | Project-level (in your project root) | | 65 | | `.zot/mcp.json` | Project-level (in your project root) | | ||
| 66 | 66 | ||||
| 67 | ### Config Format | 67 | ### Config Format | ||
M examples/extensions/mcp-bridge/config.go +4 -4
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | // Reads standard MCP config files (same format as Claude Desktop, Cursor, etc.) | |
| 4 | 4 | // from two locations: | |
| 5 | 5 | // | |
| 6 | - | // 1. Global: $ZOT_HOME/mcp.json (macOS: ~/Library/Application Support/zot/mcp.json) | |
| 6 | + | // 1. Global: $ZOT_HOME/mcp.json | |
| 7 | 7 | // 2. Project: .zot/mcp.json (in the current working directory) | |
| 8 | 8 | // | |
| 9 | 9 | // Project config overrides global config per-server (shallow merge). | |
| @@ -47,6 +47,9 @@ func zotHome() string { | |||
| 47 | 47 | if h := os.Getenv("ZOT_HOME"); h != "" { | |
| 48 | 48 | return h | |
| 49 | 49 | } | |
| 50 | + | if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" { | |
| 51 | + | return filepath.Join(xdg, "zot") | |
| 52 | + | } | |
| 50 | 53 | switch runtime.GOOS { | |
| 51 | 54 | case "darwin": | |
| 52 | 55 | home, _ := os.UserHomeDir() | |
| @@ -54,9 +57,6 @@ func zotHome() string { | |||
| 54 | 57 | case "windows": | |
| 55 | 58 | return filepath.Join(os.Getenv("APPDATA"), "zot") | |
| 56 | 59 | default: // linux, freebsd, etc. | |
| 57 | - | if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" { | |
| 58 | - | return filepath.Join(xdg, "zot") | |
| 59 | - | } | |
| 60 | 60 | home, _ := os.UserHomeDir() | |
| 61 | 61 | return filepath.Join(home, ".local", "state", "zot") | |
| 62 | 62 | } | |
| @@ -3,7 +3,7 @@ | |||||
| 3 | // Reads standard MCP config files (same format as Claude Desktop, Cursor, etc.) | 3 | // Reads standard MCP config files (same format as Claude Desktop, Cursor, etc.) | ||
| 4 | // from two locations: | 4 | // from two locations: | ||
| 5 | // | 5 | // | ||
| 6 | - | // 1. Global: $ZOT_HOME/mcp.json (macOS: ~/Library/Application Support/zot/mcp.json) | 6 | + | // 1. Global: $ZOT_HOME/mcp.json |
| 7 | // 2. Project: .zot/mcp.json (in the current working directory) | 7 | // 2. Project: .zot/mcp.json (in the current working directory) | ||
| 8 | // | 8 | // | ||
| 9 | // Project config overrides global config per-server (shallow merge). | 9 | // Project config overrides global config per-server (shallow merge). | ||
| @@ -47,6 +47,9 @@ func zotHome() string { | |||||
| 47 | if h := os.Getenv("ZOT_HOME"); h != "" { | 47 | if h := os.Getenv("ZOT_HOME"); h != "" { | ||
| 48 | return h | 48 | return h | ||
| 49 | } | 49 | } | ||
| 50 | + | if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" { | |||
| 51 | + | return filepath.Join(xdg, "zot") | |||
| 52 | + | } | |||
| 50 | switch runtime.GOOS { | 53 | switch runtime.GOOS { | ||
| 51 | case "darwin": | 54 | case "darwin": | ||
| 52 | home, _ := os.UserHomeDir() | 55 | home, _ := os.UserHomeDir() | ||
| @@ -54,9 +57,6 @@ func zotHome() string { | |||||
| 54 | case "windows": | 57 | case "windows": | ||
| 55 | return filepath.Join(os.Getenv("APPDATA"), "zot") | 58 | return filepath.Join(os.Getenv("APPDATA"), "zot") | ||
| 56 | default: // linux, freebsd, etc. | 59 | default: // linux, freebsd, etc. | ||
| 57 | - | if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" { | |||
| 58 | - | return filepath.Join(xdg, "zot") | |||
| 59 | - | } | |||
| 60 | home, _ := os.UserHomeDir() | 60 | home, _ := os.UserHomeDir() | ||
| 61 | return filepath.Join(home, ".local", "state", "zot") | 61 | return filepath.Join(home, ".local", "state", "zot") | ||
| 62 | } | 62 | } | ||
M packages/agent/args.go +3 -3
| @@ -211,10 +211,10 @@ func ParseArgs(in []string) (Args, error) { | |||
| 211 | 211 | return a, err | |
| 212 | 212 | } | |
| 213 | 213 | switch strings.ToLower(v) { | |
| 214 | - | case "", "off", "minimum", "minimal", "low", "medium", "high", "maximum", "max": | |
| 214 | + | case "", "off", "minimum", "minimal", "low", "medium", "high", "xhigh", "maximum", "max": | |
| 215 | 215 | a.Reasoning = strings.ToLower(v) | |
| 216 | 216 | default: | |
| 217 | - | return a, fmt.Errorf("--reasoning must be off|minimum|low|medium|high|maximum") | |
| 217 | + | return a, fmt.Errorf("--reasoning must be off|minimum|low|medium|high|xhigh|max") | |
| 218 | 218 | } | |
| 219 | 219 | case "--temperature": | |
| 220 | 220 | v, err := want(&i, arg) | |
| @@ -387,7 +387,7 @@ func PrintHelp(version string) { | |||
| 387 | 387 | row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, | |
| 388 | 388 | row{"--base-url URL", "override provider api base url"}, | |
| 389 | 389 | row{"--insecure", "skip TLS certificate verification (for self-signed-cert endpoints)"}, | |
| 390 | - | row{"--reasoning off|minimum|low|medium|high|maximum", "set thinking level on supported models"}, | |
| 390 | + | row{"--reasoning off|minimum|low|medium|high|xhigh|max", "set thinking level on supported models"}, | |
| 391 | 391 | row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"}, | |
| 392 | 392 | ) | |
| 393 | 393 | section("prompt and session flags", | |
| @@ -211,10 +211,10 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 211 | return a, err | 211 | return a, err | ||
| 212 | } | 212 | } | ||
| 213 | switch strings.ToLower(v) { | 213 | switch strings.ToLower(v) { | ||
| 214 | - | case "", "off", "minimum", "minimal", "low", "medium", "high", "maximum", "max": | 214 | + | case "", "off", "minimum", "minimal", "low", "medium", "high", "xhigh", "maximum", "max": |
| 215 | a.Reasoning = strings.ToLower(v) | 215 | a.Reasoning = strings.ToLower(v) | ||
| 216 | default: | 216 | default: | ||
| 217 | - | return a, fmt.Errorf("--reasoning must be off|minimum|low|medium|high|maximum") | 217 | + | return a, fmt.Errorf("--reasoning must be off|minimum|low|medium|high|xhigh|max") |
| 218 | } | 218 | } | ||
| 219 | case "--temperature": | 219 | case "--temperature": | ||
| 220 | v, err := want(&i, arg) | 220 | v, err := want(&i, arg) | ||
| @@ -387,7 +387,7 @@ func PrintHelp(version string) { | |||||
| 387 | row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, | 387 | row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, | ||
| 388 | row{"--base-url URL", "override provider api base url"}, | 388 | row{"--base-url URL", "override provider api base url"}, | ||
| 389 | row{"--insecure", "skip TLS certificate verification (for self-signed-cert endpoints)"}, | 389 | row{"--insecure", "skip TLS certificate verification (for self-signed-cert endpoints)"}, | ||
| 390 | - | row{"--reasoning off|minimum|low|medium|high|maximum", "set thinking level on supported models"}, | 390 | + | row{"--reasoning off|minimum|low|medium|high|xhigh|max", "set thinking level on supported models"}, |
| 391 | row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"}, | 391 | row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"}, | ||
| 392 | ) | 392 | ) | ||
| 393 | section("prompt and session flags", | 393 | section("prompt and session flags", | ||
M packages/agent/build.go +3 -0
| @@ -751,6 +751,9 @@ func (r Resolved) NewClient() provider.Client { | |||
| 751 | 751 | case "deepseek": | |
| 752 | 752 | return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL)) | |
| 753 | 753 | case "openai": | |
| 754 | + | if strings.HasPrefix(r.Model, "gpt-5.6-") { | |
| 755 | + | return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")) | |
| 756 | + | } | |
| 754 | 757 | return wrap(provider.NewOpenAI(r.Credential, r.BaseURL)) | |
| 755 | 758 | case "openai-codex": | |
| 756 | 759 | inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)) | |
| @@ -751,6 +751,9 @@ func (r Resolved) NewClient() provider.Client { | |||||
| 751 | case "deepseek": | 751 | case "deepseek": | ||
| 752 | return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL)) | 752 | return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL)) | ||
| 753 | case "openai": | 753 | case "openai": | ||
| 754 | + | if strings.HasPrefix(r.Model, "gpt-5.6-") { | |||
| 755 | + | return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")) | |||
| 756 | + | } | |||
| 754 | return wrap(provider.NewOpenAI(r.Credential, r.BaseURL)) | 757 | return wrap(provider.NewOpenAI(r.Credential, r.BaseURL)) | ||
| 755 | case "openai-codex": | 758 | case "openai-codex": | ||
| 756 | inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)) | 759 | inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)) | ||
M packages/agent/config.go +3 -3
| @@ -108,6 +108,9 @@ func ZotHome() string { | |||
| 108 | 108 | if v := os.Getenv("ZOT_HOME"); v != "" { | |
| 109 | 109 | return v | |
| 110 | 110 | } | |
| 111 | + | if v := os.Getenv("XDG_STATE_HOME"); v != "" { | |
| 112 | + | return filepath.Join(v, "zot") | |
| 113 | + | } | |
| 111 | 114 | switch runtime.GOOS { | |
| 112 | 115 | case "darwin": | |
| 113 | 116 | if home, err := os.UserHomeDir(); err == nil { | |
| @@ -118,9 +121,6 @@ func ZotHome() string { | |||
| 118 | 121 | return filepath.Join(v, "zot") | |
| 119 | 122 | } | |
| 120 | 123 | } | |
| 121 | - | if v := os.Getenv("XDG_STATE_HOME"); v != "" { | |
| 122 | - | return filepath.Join(v, "zot") | |
| 123 | - | } | |
| 124 | 124 | if home, err := os.UserHomeDir(); err == nil { | |
| 125 | 125 | return filepath.Join(home, ".local", "state", "zot") | |
| 126 | 126 | } | |
| @@ -108,6 +108,9 @@ func ZotHome() string { | |||||
| 108 | if v := os.Getenv("ZOT_HOME"); v != "" { | 108 | if v := os.Getenv("ZOT_HOME"); v != "" { | ||
| 109 | return v | 109 | return v | ||
| 110 | } | 110 | } | ||
| 111 | + | if v := os.Getenv("XDG_STATE_HOME"); v != "" { | |||
| 112 | + | return filepath.Join(v, "zot") | |||
| 113 | + | } | |||
| 111 | switch runtime.GOOS { | 114 | switch runtime.GOOS { | ||
| 112 | case "darwin": | 115 | case "darwin": | ||
| 113 | if home, err := os.UserHomeDir(); err == nil { | 116 | if home, err := os.UserHomeDir(); err == nil { | ||
| @@ -118,9 +121,6 @@ func ZotHome() string { | |||||
| 118 | return filepath.Join(v, "zot") | 121 | return filepath.Join(v, "zot") | ||
| 119 | } | 122 | } | ||
| 120 | } | 123 | } | ||
| 121 | - | if v := os.Getenv("XDG_STATE_HOME"); v != "" { | |||
| 122 | - | return filepath.Join(v, "zot") | |||
| 123 | - | } | |||
| 124 | if home, err := os.UserHomeDir(); err == nil { | 124 | if home, err := os.UserHomeDir(); err == nil { | ||
| 125 | return filepath.Join(home, ".local", "state", "zot") | 125 | return filepath.Join(home, ".local", "state", "zot") | ||
| 126 | } | 126 | } | ||
A packages/agent/config_home_test.go +24 -0
| @@ -0,0 +1,24 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestZotHomePrefersExplicitHomeOverXDGStateHome(t *testing.T) { | |
| 9 | + | t.Setenv("ZOT_HOME", filepath.Join("explicit", "zot")) | |
| 10 | + | t.Setenv("XDG_STATE_HOME", filepath.Join("xdg", "state")) | |
| 11 | + | ||
| 12 | + | if got, want := ZotHome(), filepath.Join("explicit", "zot"); got != want { | |
| 13 | + | t.Fatalf("ZotHome() = %q, want %q", got, want) | |
| 14 | + | } | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func TestZotHomeUsesXDGStateHome(t *testing.T) { | |
| 18 | + | t.Setenv("ZOT_HOME", "") | |
| 19 | + | t.Setenv("XDG_STATE_HOME", filepath.Join("xdg", "state")) | |
| 20 | + | ||
| 21 | + | if got, want := ZotHome(), filepath.Join("xdg", "state", "zot"); got != want { | |
| 22 | + | t.Fatalf("ZotHome() = %q, want %q", got, want) | |
| 23 | + | } | |
| 24 | + | } | |
| @@ -0,0 +1,24 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "path/filepath" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestZotHomePrefersExplicitHomeOverXDGStateHome(t *testing.T) { | |||
| 9 | + | t.Setenv("ZOT_HOME", filepath.Join("explicit", "zot")) | |||
| 10 | + | t.Setenv("XDG_STATE_HOME", filepath.Join("xdg", "state")) | |||
| 11 | + | ||||
| 12 | + | if got, want := ZotHome(), filepath.Join("explicit", "zot"); got != want { | |||
| 13 | + | t.Fatalf("ZotHome() = %q, want %q", got, want) | |||
| 14 | + | } | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func TestZotHomeUsesXDGStateHome(t *testing.T) { | |||
| 18 | + | t.Setenv("ZOT_HOME", "") | |||
| 19 | + | t.Setenv("XDG_STATE_HOME", filepath.Join("xdg", "state")) | |||
| 20 | + | ||||
| 21 | + | if got, want := ZotHome(), filepath.Join("xdg", "state", "zot"); got != want { | |||
| 22 | + | t.Fatalf("ZotHome() = %q, want %q", got, want) | |||
| 23 | + | } | |||
| 24 | + | } | |||
M packages/agent/modes/interactive.go +65 -31
| @@ -1532,6 +1532,12 @@ func (i *Interactive) redraw() { | |||
| 1532 | 1532 | cursorCol = c | |
| 1533 | 1533 | } | |
| 1534 | 1534 | } | |
| 1535 | + | if i.sessionTreeDialog.Active() { | |
| 1536 | + | if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 { | |
| 1537 | + | cursorRow = dialogLead + r | |
| 1538 | + | cursorCol = c | |
| 1539 | + | } | |
| 1540 | + | } | |
| 1535 | 1541 | if i.swarmDialog.Active() { | |
| 1536 | 1542 | if r, c := i.swarmDialog.CursorPos(cols); r >= 0 { | |
| 1537 | 1543 | cursorRow = dialogLead + r | |
| @@ -1961,7 +1967,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1961 | 1967 | } | |
| 1962 | 1968 | act := i.sessionTreeDialog.HandleKey(k) | |
| 1963 | 1969 | if act.Select { | |
| 1964 | - | i.applySessionTreeSelection(act.Path) | |
| 1970 | + | i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt) | |
| 1965 | 1971 | } | |
| 1966 | 1972 | i.invalidate() | |
| 1967 | 1973 | return false | |
| @@ -2921,7 +2927,8 @@ func (i *Interactive) openSettingsDialog() { | |||
| 2921 | 2927 | {value: "low", label: "low", desc: "light (~2k tokens)"}, | |
| 2922 | 2928 | {value: "medium", label: "medium", desc: "moderate (~8k tokens)"}, | |
| 2923 | 2929 | {value: "high", label: "high", desc: "deep (~16k tokens)"}, | |
| 2924 | - | {value: "maximum", label: "maximum", desc: "highest (~32k tokens)"}, | |
| 2930 | + | {value: "xhigh", label: "xhigh", desc: "extra-high effort"}, | |
| 2931 | + | {value: "max", label: "max", desc: "unconstrained effort on supported models"}, | |
| 2925 | 2932 | } | |
| 2926 | 2933 | reasoning := provider.NormalizeReasoning(i.cfg.Reasoning) | |
| 2927 | 2934 | reasoningChoice := 0 | |
| @@ -6138,62 +6145,89 @@ func (i *Interactive) doSessionFork() { | |||
| 6138 | 6145 | i.invalidate() | |
| 6139 | 6146 | } | |
| 6140 | 6147 | ||
| 6141 | - | // doSessionTree shows the branch topology picker for this cwd. | |
| 6142 | - | // Pick an entry to switch into it (same semantics as /sessions | |
| 6143 | - | // picking a past session, but with the parent/child indentation). | |
| 6148 | + | // doSessionTree shows the current session family as an inline branch tree. | |
| 6149 | + | // Pick an entry to check out that point into a new branch. Subsequent | |
| 6150 | + | // prompts use only context up to the selected message. | |
| 6144 | 6151 | func (i *Interactive) doSessionTree() { | |
| 6145 | - | if i.cfg.ZotHome == "" || i.cfg.CWD == "" { | |
| 6146 | - | i.mu.Lock() | |
| 6147 | - | i.statusErr = "tree: session storage not configured" | |
| 6148 | - | i.mu.Unlock() | |
| 6152 | + | current := "" | |
| 6153 | + | if i.cfg.CurrentSessionPath != nil { | |
| 6154 | + | current = i.cfg.CurrentSessionPath() | |
| 6155 | + | } | |
| 6156 | + | if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.cfg.ZotHome, i.cfg.CWD, current) { | |
| 6149 | 6157 | i.invalidate() | |
| 6150 | 6158 | return | |
| 6151 | 6159 | } | |
| 6152 | - | // Flush the running agent's transcript first so its message | |
| 6153 | - | // count + latest preview are accurate in the tree. | |
| 6154 | - | if i.cfg.FlushSession != nil { | |
| 6155 | - | i.cfg.FlushSession() | |
| 6160 | + | msgs := []provider.Message{} | |
| 6161 | + | if i.agent != nil { | |
| 6162 | + | msgs = i.agent.Messages() | |
| 6156 | 6163 | } | |
| 6157 | - | roots := core.BuildSessionTree(i.cfg.ZotHome, i.cfg.CWD) | |
| 6158 | - | if len(roots) == 0 { | |
| 6164 | + | if len(msgs) == 0 { | |
| 6159 | 6165 | i.mu.Lock() | |
| 6160 | - | i.statusErr = "tree: no sessions in this directory yet" | |
| 6166 | + | i.statusErr = "tree: no messages in this session yet" | |
| 6161 | 6167 | i.mu.Unlock() | |
| 6162 | 6168 | i.invalidate() | |
| 6163 | 6169 | return | |
| 6164 | 6170 | } | |
| 6165 | - | current := "" | |
| 6166 | - | if i.cfg.CurrentSessionPath != nil { | |
| 6167 | - | current = i.cfg.CurrentSessionPath() | |
| 6168 | - | } | |
| 6169 | - | if !i.sessionTreeDialog.Open(roots, current) { | |
| 6171 | + | if !i.sessionTreeDialog.OpenMessages(msgs) { | |
| 6170 | 6172 | i.mu.Lock() | |
| 6171 | - | i.statusErr = "tree: no branches to show" | |
| 6173 | + | i.statusErr = "tree: no messages to show" | |
| 6172 | 6174 | i.mu.Unlock() | |
| 6173 | 6175 | } | |
| 6174 | 6176 | i.invalidate() | |
| 6175 | 6177 | } | |
| 6176 | 6178 | ||
| 6177 | - | // applySessionTreeSelection switches the running agent to the | |
| 6178 | - | // session file at path. Thin wrapper around LoadSession that also | |
| 6179 | - | // writes a status line. | |
| 6180 | - | func (i *Interactive) applySessionTreeSelection(path string) { | |
| 6181 | - | if i.cfg.LoadSession == nil { | |
| 6179 | + | // applySessionTreeMessageSelection checks out the selected message into a | |
| 6180 | + | // new branch, then switches the running agent to that branch. Unlike /jump, | |
| 6181 | + | // this changes future model context: only messages up to the selected row are | |
| 6182 | + | // sent on subsequent turns. | |
| 6183 | + | func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) { | |
| 6184 | + | if i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil { | |
| 6182 | 6185 | i.mu.Lock() | |
| 6183 | - | i.statusErr = "tree: session swap not available in this build" | |
| 6186 | + | i.statusErr = "tree: session branching is not available in this build" | |
| 6184 | 6187 | i.mu.Unlock() | |
| 6185 | 6188 | i.invalidate() | |
| 6186 | 6189 | return | |
| 6187 | 6190 | } | |
| 6188 | - | if err := i.cfg.LoadSession(path); err != nil { | |
| 6191 | + | if src == "" { | |
| 6192 | + | src = i.cfg.CurrentSessionPath() | |
| 6193 | + | } | |
| 6194 | + | if src == "" { | |
| 6195 | + | i.mu.Lock() | |
| 6196 | + | i.statusErr = "tree: no session is active" | |
| 6197 | + | i.mu.Unlock() | |
| 6198 | + | i.invalidate() | |
| 6199 | + | return | |
| 6200 | + | } | |
| 6201 | + | if i.cfg.FlushSession != nil { | |
| 6202 | + | i.cfg.FlushSession() | |
| 6203 | + | } | |
| 6204 | + | upTo := msgIdx + 1 | |
| 6205 | + | if role == provider.RoleUser { | |
| 6206 | + | upTo = msgIdx | |
| 6207 | + | } | |
| 6208 | + | newPath, err := core.BranchSessionHidden(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo) | |
| 6209 | + | if err != nil { | |
| 6189 | 6210 | i.mu.Lock() | |
| 6190 | - | i.statusErr = "tree: load failed: " + err.Error() | |
| 6211 | + | i.statusErr = "tree: " + err.Error() | |
| 6191 | 6212 | i.mu.Unlock() | |
| 6192 | 6213 | i.invalidate() | |
| 6193 | 6214 | return | |
| 6194 | 6215 | } | |
| 6216 | + | if err := i.cfg.LoadSession(newPath); err != nil { | |
| 6217 | + | i.mu.Lock() | |
| 6218 | + | i.statusErr = "tree: checkout failed: " + err.Error() | |
| 6219 | + | i.mu.Unlock() | |
| 6220 | + | i.invalidate() | |
| 6221 | + | return | |
| 6222 | + | } | |
| 6223 | + | i.scrollToBottom() | |
| 6195 | 6224 | i.mu.Lock() | |
| 6196 | - | i.statusOK = "switched to branch " + friendlyPath(path) | |
| 6225 | + | if role == provider.RoleUser { | |
| 6226 | + | i.ed.SetValue(prompt) | |
| 6227 | + | i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo) | |
| 6228 | + | } else { | |
| 6229 | + | i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo) | |
| 6230 | + | } | |
| 6197 | 6231 | i.statusErr = "" | |
| 6198 | 6232 | i.mu.Unlock() | |
| 6199 | 6233 | i.invalidate() | |
| @@ -1532,6 +1532,12 @@ func (i *Interactive) redraw() { | |||||
| 1532 | cursorCol = c | 1532 | cursorCol = c | ||
| 1533 | } | 1533 | } | ||
| 1534 | } | 1534 | } | ||
| 1535 | + | if i.sessionTreeDialog.Active() { | |||
| 1536 | + | if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 { | |||
| 1537 | + | cursorRow = dialogLead + r | |||
| 1538 | + | cursorCol = c | |||
| 1539 | + | } | |||
| 1540 | + | } | |||
| 1535 | if i.swarmDialog.Active() { | 1541 | if i.swarmDialog.Active() { | ||
| 1536 | if r, c := i.swarmDialog.CursorPos(cols); r >= 0 { | 1542 | if r, c := i.swarmDialog.CursorPos(cols); r >= 0 { | ||
| 1537 | cursorRow = dialogLead + r | 1543 | cursorRow = dialogLead + r | ||
| @@ -1961,7 +1967,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 1961 | } | 1967 | } | ||
| 1962 | act := i.sessionTreeDialog.HandleKey(k) | 1968 | act := i.sessionTreeDialog.HandleKey(k) | ||
| 1963 | if act.Select { | 1969 | if act.Select { | ||
| 1964 | - | i.applySessionTreeSelection(act.Path) | 1970 | + | i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt) |
| 1965 | } | 1971 | } | ||
| 1966 | i.invalidate() | 1972 | i.invalidate() | ||
| 1967 | return false | 1973 | return false | ||
| @@ -2921,7 +2927,8 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 2921 | {value: "low", label: "low", desc: "light (~2k tokens)"}, | 2927 | {value: "low", label: "low", desc: "light (~2k tokens)"}, | ||
| 2922 | {value: "medium", label: "medium", desc: "moderate (~8k tokens)"}, | 2928 | {value: "medium", label: "medium", desc: "moderate (~8k tokens)"}, | ||
| 2923 | {value: "high", label: "high", desc: "deep (~16k tokens)"}, | 2929 | {value: "high", label: "high", desc: "deep (~16k tokens)"}, | ||
| 2924 | - | {value: "maximum", label: "maximum", desc: "highest (~32k tokens)"}, | 2930 | + | {value: "xhigh", label: "xhigh", desc: "extra-high effort"}, |
| 2931 | + | {value: "max", label: "max", desc: "unconstrained effort on supported models"}, | |||
| 2925 | } | 2932 | } | ||
| 2926 | reasoning := provider.NormalizeReasoning(i.cfg.Reasoning) | 2933 | reasoning := provider.NormalizeReasoning(i.cfg.Reasoning) | ||
| 2927 | reasoningChoice := 0 | 2934 | reasoningChoice := 0 | ||
| @@ -6138,62 +6145,89 @@ func (i *Interactive) doSessionFork() { | |||||
| 6138 | i.invalidate() | 6145 | i.invalidate() | ||
| 6139 | } | 6146 | } | ||
| 6140 | 6147 | ||||
| 6141 | - | // doSessionTree shows the branch topology picker for this cwd. | 6148 | + | // doSessionTree shows the current session family as an inline branch tree. |
| 6142 | - | // Pick an entry to switch into it (same semantics as /sessions | 6149 | + | // Pick an entry to check out that point into a new branch. Subsequent |
| 6143 | - | // picking a past session, but with the parent/child indentation). | 6150 | + | // prompts use only context up to the selected message. |
| 6144 | func (i *Interactive) doSessionTree() { | 6151 | func (i *Interactive) doSessionTree() { | ||
| 6145 | - | if i.cfg.ZotHome == "" || i.cfg.CWD == "" { | 6152 | + | current := "" |
| 6146 | - | i.mu.Lock() | 6153 | + | if i.cfg.CurrentSessionPath != nil { |
| 6147 | - | i.statusErr = "tree: session storage not configured" | 6154 | + | current = i.cfg.CurrentSessionPath() |
| 6148 | - | i.mu.Unlock() | 6155 | + | } |
| 6156 | + | if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.cfg.ZotHome, i.cfg.CWD, current) { | |||
| 6149 | i.invalidate() | 6157 | i.invalidate() | ||
| 6150 | return | 6158 | return | ||
| 6151 | } | 6159 | } | ||
| 6152 | - | // Flush the running agent's transcript first so its message | 6160 | + | msgs := []provider.Message{} |
| 6153 | - | // count + latest preview are accurate in the tree. | 6161 | + | if i.agent != nil { |
| 6154 | - | if i.cfg.FlushSession != nil { | 6162 | + | msgs = i.agent.Messages() |
| 6155 | - | i.cfg.FlushSession() | |||
| 6156 | } | 6163 | } | ||
| 6157 | - | roots := core.BuildSessionTree(i.cfg.ZotHome, i.cfg.CWD) | 6164 | + | if len(msgs) == 0 { |
| 6158 | - | if len(roots) == 0 { | |||
| 6159 | i.mu.Lock() | 6165 | i.mu.Lock() | ||
| 6160 | - | i.statusErr = "tree: no sessions in this directory yet" | 6166 | + | i.statusErr = "tree: no messages in this session yet" |
| 6161 | i.mu.Unlock() | 6167 | i.mu.Unlock() | ||
| 6162 | i.invalidate() | 6168 | i.invalidate() | ||
| 6163 | return | 6169 | return | ||
| 6164 | } | 6170 | } | ||
| 6165 | - | current := "" | 6171 | + | if !i.sessionTreeDialog.OpenMessages(msgs) { |
| 6166 | - | if i.cfg.CurrentSessionPath != nil { | |||
| 6167 | - | current = i.cfg.CurrentSessionPath() | |||
| 6168 | - | } | |||
| 6169 | - | if !i.sessionTreeDialog.Open(roots, current) { | |||
| 6170 | i.mu.Lock() | 6172 | i.mu.Lock() | ||
| 6171 | - | i.statusErr = "tree: no branches to show" | 6173 | + | i.statusErr = "tree: no messages to show" |
| 6172 | i.mu.Unlock() | 6174 | i.mu.Unlock() | ||
| 6173 | } | 6175 | } | ||
| 6174 | i.invalidate() | 6176 | i.invalidate() | ||
| 6175 | } | 6177 | } | ||
| 6176 | 6178 | ||||
| 6177 | - | // applySessionTreeSelection switches the running agent to the | 6179 | + | // applySessionTreeMessageSelection checks out the selected message into a |
| 6178 | - | // session file at path. Thin wrapper around LoadSession that also | 6180 | + | // new branch, then switches the running agent to that branch. Unlike /jump, |
| 6179 | - | // writes a status line. | 6181 | + | // this changes future model context: only messages up to the selected row are |
| 6180 | - | func (i *Interactive) applySessionTreeSelection(path string) { | 6182 | + | // sent on subsequent turns. |
| 6181 | - | if i.cfg.LoadSession == nil { | 6183 | + | func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) { |
| 6184 | + | if i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil { | |||
| 6182 | i.mu.Lock() | 6185 | i.mu.Lock() | ||
| 6183 | - | i.statusErr = "tree: session swap not available in this build" | 6186 | + | i.statusErr = "tree: session branching is not available in this build" |
| 6184 | i.mu.Unlock() | 6187 | i.mu.Unlock() | ||
| 6185 | i.invalidate() | 6188 | i.invalidate() | ||
| 6186 | return | 6189 | return | ||
| 6187 | } | 6190 | } | ||
| 6188 | - | if err := i.cfg.LoadSession(path); err != nil { | 6191 | + | if src == "" { |
| 6192 | + | src = i.cfg.CurrentSessionPath() | |||
| 6193 | + | } | |||
| 6194 | + | if src == "" { | |||
| 6195 | + | i.mu.Lock() | |||
| 6196 | + | i.statusErr = "tree: no session is active" | |||
| 6197 | + | i.mu.Unlock() | |||
| 6198 | + | i.invalidate() | |||
| 6199 | + | return | |||
| 6200 | + | } | |||
| 6201 | + | if i.cfg.FlushSession != nil { | |||
| 6202 | + | i.cfg.FlushSession() | |||
| 6203 | + | } | |||
| 6204 | + | upTo := msgIdx + 1 | |||
| 6205 | + | if role == provider.RoleUser { | |||
| 6206 | + | upTo = msgIdx | |||
| 6207 | + | } | |||
| 6208 | + | newPath, err := core.BranchSessionHidden(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo) | |||
| 6209 | + | if err != nil { | |||
| 6189 | i.mu.Lock() | 6210 | i.mu.Lock() | ||
| 6190 | - | i.statusErr = "tree: load failed: " + err.Error() | 6211 | + | i.statusErr = "tree: " + err.Error() |
| 6191 | i.mu.Unlock() | 6212 | i.mu.Unlock() | ||
| 6192 | i.invalidate() | 6213 | i.invalidate() | ||
| 6193 | return | 6214 | return | ||
| 6194 | } | 6215 | } | ||
| 6216 | + | if err := i.cfg.LoadSession(newPath); err != nil { | |||
| 6217 | + | i.mu.Lock() | |||
| 6218 | + | i.statusErr = "tree: checkout failed: " + err.Error() | |||
| 6219 | + | i.mu.Unlock() | |||
| 6220 | + | i.invalidate() | |||
| 6221 | + | return | |||
| 6222 | + | } | |||
| 6223 | + | i.scrollToBottom() | |||
| 6195 | i.mu.Lock() | 6224 | i.mu.Lock() | ||
| 6196 | - | i.statusOK = "switched to branch " + friendlyPath(path) | 6225 | + | if role == provider.RoleUser { |
| 6226 | + | i.ed.SetValue(prompt) | |||
| 6227 | + | i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo) | |||
| 6228 | + | } else { | |||
| 6229 | + | i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo) | |||
| 6230 | + | } | |||
| 6197 | i.statusErr = "" | 6231 | i.statusErr = "" | ||
| 6198 | i.mu.Unlock() | 6232 | i.mu.Unlock() | ||
| 6199 | i.invalidate() | 6233 | i.invalidate() | ||
M packages/agent/modes/session_dialog.go +11 -5
| @@ -68,9 +68,9 @@ func (d *sessionDialog) CursorPos() (row, col int) { | |||
| 68 | 68 | if !d.renaming { | |
| 69 | 69 | return -1, -1 | |
| 70 | 70 | } | |
| 71 | - | // Row: frameHeader(1) + rename hint(1) = row 2 (0-indexed) | |
| 72 | - | // Col: 2 spaces indent + text length | |
| 73 | - | return 2, 2 + len([]rune(d.rename)) | |
| 71 | + | // Row: frameHeader + padDialogFrame blank + rename hint = row 3 (0-indexed). | |
| 72 | + | // Col: " ▌ " prefix + text length. | |
| 73 | + | return 3, 4 + len([]rune(d.rename)) | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | 76 | // Close hides the dialog. | |
| @@ -93,8 +93,14 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||
| 93 | 93 | return lines | |
| 94 | 94 | } | |
| 95 | 95 | if d.renaming { | |
| 96 | - | lines = append(lines, th.FG256(th.Muted, "rename session (enter to save, esc to cancel):")) | |
| 97 | - | lines = append(lines, " "+th.FG256(th.FG, d.rename)) | |
| 96 | + | lines = append(lines, th.FG256(th.Muted, "rename session (enter save, esc cancel):")) | |
| 97 | + | text := d.rename | |
| 98 | + | if text == "" { | |
| 99 | + | text = th.FG256(th.Muted, "session name") | |
| 100 | + | } else { | |
| 101 | + | text = th.FG256(th.FG, text) | |
| 102 | + | } | |
| 103 | + | lines = append(lines, " "+th.FG256(th.Accent, "▌ ")+text) | |
| 98 | 104 | lines = append(lines, frameRule(th, width)) | |
| 99 | 105 | return lines | |
| 100 | 106 | } | |
| @@ -68,9 +68,9 @@ func (d *sessionDialog) CursorPos() (row, col int) { | |||||
| 68 | if !d.renaming { | 68 | if !d.renaming { | ||
| 69 | return -1, -1 | 69 | return -1, -1 | ||
| 70 | } | 70 | } | ||
| 71 | - | // Row: frameHeader(1) + rename hint(1) = row 2 (0-indexed) | 71 | + | // Row: frameHeader + padDialogFrame blank + rename hint = row 3 (0-indexed). |
| 72 | - | // Col: 2 spaces indent + text length | 72 | + | // Col: " ▌ " prefix + text length. |
| 73 | - | return 2, 2 + len([]rune(d.rename)) | 73 | + | return 3, 4 + len([]rune(d.rename)) |
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | // Close hides the dialog. | 76 | // Close hides the dialog. | ||
| @@ -93,8 +93,14 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string { | |||||
| 93 | return lines | 93 | return lines | ||
| 94 | } | 94 | } | ||
| 95 | if d.renaming { | 95 | if d.renaming { | ||
| 96 | - | lines = append(lines, th.FG256(th.Muted, "rename session (enter to save, esc to cancel):")) | 96 | + | lines = append(lines, th.FG256(th.Muted, "rename session (enter save, esc cancel):")) |
| 97 | - | lines = append(lines, " "+th.FG256(th.FG, d.rename)) | 97 | + | text := d.rename |
| 98 | + | if text == "" { | |||
| 99 | + | text = th.FG256(th.Muted, "session name") | |||
| 100 | + | } else { | |||
| 101 | + | text = th.FG256(th.FG, text) | |||
| 102 | + | } | |||
| 103 | + | lines = append(lines, " "+th.FG256(th.Accent, "▌ ")+text) | |||
| 98 | lines = append(lines, frameRule(th, width)) | 104 | lines = append(lines, frameRule(th, width)) | ||
| 99 | return lines | 105 | return lines | ||
| 100 | } | 106 | } | ||
M packages/agent/modes/session_tree_dialog.go +277 -77
| @@ -5,57 +5,74 @@ import ( | |||
| 5 | 5 | "strings" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/patriceckhart/zot/packages/core" | |
| 8 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 8 | 9 | "github.com/patriceckhart/zot/packages/tui" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | - | // sessionTreeDialog renders the branch topology for the current | |
| 12 | - | // cwd (parents, children, siblings) and lets the user pick another | |
| 13 | - | // session file to switch into. Reached via /session tree. | |
| 14 | - | // | |
| 15 | - | // Layout is a single flat list with indentation-based nesting so | |
| 16 | - | // we can reuse the simple up/down cursor model the other dialogs | |
| 17 | - | // use. The current session is tagged with a muted "[current]". | |
| 12 | + | // sessionTreeDialog renders a compact outline of the current session family. | |
| 13 | + | // Branches are shown inline at the point where they forked, using indentation | |
| 14 | + | // rather than file-level rows. Selecting a node checks out that point into a | |
| 15 | + | // new branch. | |
| 18 | 16 | type sessionTreeDialog struct { | |
| 19 | - | active bool | |
| 20 | - | items []treeItem | |
| 21 | - | cursor int | |
| 22 | - | current string // path of the session we're currently in (not selectable) | |
| 17 | + | active bool | |
| 18 | + | items []sessionTreeItem | |
| 19 | + | cursor int | |
| 23 | 20 | } | |
| 24 | 21 | ||
| 25 | - | // treeItem is one visible row. Depth drives the indent; Path is the | |
| 26 | - | // on-disk session file to swap to when the user hits enter. | |
| 27 | - | type treeItem struct { | |
| 28 | - | label string | |
| 29 | - | path string | |
| 30 | - | depth int | |
| 31 | - | isCur bool | |
| 22 | + | type sessionTreeItem struct { | |
| 23 | + | label string | |
| 24 | + | messageIdx int | |
| 25 | + | turnNo int | |
| 26 | + | role provider.Role | |
| 27 | + | prompt string | |
| 28 | + | path string | |
| 29 | + | depth int | |
| 30 | + | current bool | |
| 32 | 31 | } | |
| 33 | 32 | ||
| 34 | 33 | type sessionTreeAction struct { | |
| 35 | - | Select bool | |
| 36 | - | Path string | |
| 37 | - | Close bool | |
| 34 | + | Select bool | |
| 35 | + | MessageIdx int | |
| 36 | + | TurnNo int | |
| 37 | + | Role provider.Role | |
| 38 | + | Prompt string | |
| 39 | + | Path string | |
| 40 | + | Close bool | |
| 38 | 41 | } | |
| 39 | 42 | ||
| 40 | 43 | func newSessionTreeDialog() *sessionTreeDialog { return &sessionTreeDialog{} } | |
| 41 | 44 | ||
| 42 | - | // Open flattens the given forest into indented rows. currentPath | |
| 43 | - | // is highlighted and non-selectable (enter on it closes the dialog). | |
| 44 | - | func (d *sessionTreeDialog) Open(roots []*core.TreeNode, currentPath string) bool { | |
| 45 | - | items := flattenTree(roots, currentPath) | |
| 46 | - | if len(items) == 0 { | |
| 45 | + | func (d *sessionTreeDialog) OpenMessages(msgs []provider.Message) bool { | |
| 46 | + | d.items = buildSessionTreeItems("", msgs, 0, false) | |
| 47 | + | d.cursor = len(d.items) - 1 | |
| 48 | + | if d.cursor < 0 { | |
| 49 | + | d.cursor = 0 | |
| 50 | + | } | |
| 51 | + | d.active = true | |
| 52 | + | return len(d.items) > 0 | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func (d *sessionTreeDialog) OpenSessionFamily(root, cwd, currentPath string) bool { | |
| 56 | + | roots := core.BuildSessionTree(root, cwd) | |
| 57 | + | if len(roots) == 0 { | |
| 47 | 58 | return false | |
| 48 | 59 | } | |
| 49 | - | d.items = items | |
| 50 | - | d.current = currentPath | |
| 51 | - | d.cursor = indexOfCurrent(items, currentPath) | |
| 60 | + | rootNode := findTreeRootForPath(roots, currentPath) | |
| 61 | + | if rootNode == nil { | |
| 62 | + | rootNode = roots[0] | |
| 63 | + | } | |
| 64 | + | d.items = flattenSessionFamily(rootNode, currentPath) | |
| 65 | + | d.cursor = indexCurrentTreeItem(d.items) | |
| 52 | 66 | d.active = true | |
| 53 | - | return true | |
| 67 | + | return len(d.items) > 0 | |
| 54 | 68 | } | |
| 55 | 69 | ||
| 56 | 70 | // Close hides the dialog. | |
| 57 | 71 | func (d *sessionTreeDialog) Close() { d.active = false } | |
| 58 | 72 | ||
| 73 | + | // CursorPos returns -1 because this dialog has no inline editor. | |
| 74 | + | func (d *sessionTreeDialog) CursorPos() (row, col int) { return -1, -1 } | |
| 75 | + | ||
| 59 | 76 | // Active reports whether the dialog consumes input. | |
| 60 | 77 | func (d *sessionTreeDialog) Active() bool { return d != nil && d.active } | |
| 61 | 78 | ||
| @@ -70,6 +87,19 @@ func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction { | |||
| 70 | 87 | if d.cursor < len(d.items)-1 { | |
| 71 | 88 | d.cursor++ | |
| 72 | 89 | } | |
| 90 | + | case tui.KeyPageUp: | |
| 91 | + | d.cursor -= 5 | |
| 92 | + | if d.cursor < 0 { | |
| 93 | + | d.cursor = 0 | |
| 94 | + | } | |
| 95 | + | case tui.KeyPageDown: | |
| 96 | + | d.cursor += 5 | |
| 97 | + | if d.cursor >= len(d.items) { | |
| 98 | + | d.cursor = len(d.items) - 1 | |
| 99 | + | } | |
| 100 | + | if d.cursor < 0 { | |
| 101 | + | d.cursor = 0 | |
| 102 | + | } | |
| 73 | 103 | case tui.KeyEsc: | |
| 74 | 104 | d.Close() | |
| 75 | 105 | return sessionTreeAction{Close: true} | |
| @@ -80,10 +110,7 @@ func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction { | |||
| 80 | 110 | } | |
| 81 | 111 | it := d.items[d.cursor] | |
| 82 | 112 | d.Close() | |
| 83 | - | if it.isCur { | |
| 84 | - | return sessionTreeAction{Close: true} | |
| 85 | - | } | |
| 86 | - | return sessionTreeAction{Select: true, Path: it.path} | |
| 113 | + | return sessionTreeAction{Select: true, MessageIdx: it.messageIdx, TurnNo: it.turnNo, Role: it.role, Prompt: it.prompt, Path: it.path} | |
| 87 | 114 | } | |
| 88 | 115 | return sessionTreeAction{} | |
| 89 | 116 | } | |
| @@ -95,72 +122,245 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string { | |||
| 95 | 122 | } | |
| 96 | 123 | var lines []string | |
| 97 | 124 | lines = append(lines, frameHeader(th, "session tree", width)) | |
| 98 | - | lines = append(lines, th.FG256(th.Muted, "pick a branch to switch to (\u2191/\u2193, enter, esc to cancel):")) | |
| 99 | - | for i, it := range d.items { | |
| 125 | + | if len(d.items) == 0 { | |
| 126 | + | lines = append(lines, th.FG256(th.Muted, "no messages in this session yet")) | |
| 127 | + | lines = append(lines, th.FG256(th.Muted, "press esc to close")) | |
| 128 | + | lines = append(lines, frameRule(th, width)) | |
| 129 | + | return lines | |
| 130 | + | } | |
| 131 | + | lines = append(lines, th.FG256(th.Muted, "session branches (↑/↓, pgup/pgdn, enter checkout, esc cancel):")) | |
| 132 | + | ||
| 133 | + | const maxRows = 12 | |
| 134 | + | start := 0 | |
| 135 | + | end := len(d.items) | |
| 136 | + | if end > maxRows { | |
| 137 | + | start = d.cursor - maxRows/2 | |
| 138 | + | if start < 0 { | |
| 139 | + | start = 0 | |
| 140 | + | } | |
| 141 | + | end = start + maxRows | |
| 142 | + | if end > len(d.items) { | |
| 143 | + | end = len(d.items) | |
| 144 | + | start = end - maxRows | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | if start > 0 { | |
| 148 | + | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", start))) | |
| 149 | + | } | |
| 150 | + | for i := start; i < end; i++ { | |
| 151 | + | it := d.items[i] | |
| 100 | 152 | indent := strings.Repeat(" ", it.depth) | |
| 101 | - | label := " " + indent + it.label | |
| 102 | - | if it.isCur { | |
| 103 | - | label += " " + th.FG256(th.Muted, "[current]") | |
| 153 | + | plain := " " + indent + fitSessionTreeLabel(it.label, width-2-len([]rune(indent))) | |
| 154 | + | if it.current { | |
| 155 | + | plain += " [current]" | |
| 104 | 156 | } | |
| 105 | 157 | if i == d.cursor { | |
| 106 | - | lines = append(lines, th.PadHighlight(label, width)) | |
| 158 | + | lines = append(lines, th.PadHighlight(plain, width)) | |
| 107 | 159 | } else { | |
| 108 | - | lines = append(lines, th.FG256(th.Muted, label)) | |
| 160 | + | lines = append(lines, colorSessionTreeLine(th, plain)) | |
| 109 | 161 | } | |
| 110 | 162 | } | |
| 163 | + | if end < len(d.items) { | |
| 164 | + | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", len(d.items)-end))) | |
| 165 | + | } | |
| 166 | + | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("%d/%d", d.cursor+1, len(d.items)))) | |
| 111 | 167 | lines = append(lines, frameRule(th, width)) | |
| 112 | 168 | return lines | |
| 113 | 169 | } | |
| 114 | 170 | ||
| 115 | - | // flattenTree walks the forest depth-first and returns one treeItem | |
| 116 | - | // per node. Each label has the shape "<when> <first-prompt> (N msgs)". | |
| 117 | - | func flattenTree(roots []*core.TreeNode, currentPath string) []treeItem { | |
| 118 | - | var out []treeItem | |
| 119 | - | var walk func(n *core.TreeNode, depth int) | |
| 120 | - | walk = func(n *core.TreeNode, depth int) { | |
| 121 | - | label := formatTreeRow(n) | |
| 122 | - | out = append(out, treeItem{ | |
| 123 | - | label: label, | |
| 124 | - | path: n.Summary.Path, | |
| 125 | - | depth: depth, | |
| 126 | - | isCur: n.Summary.Path == currentPath, | |
| 127 | - | }) | |
| 128 | - | for _, c := range n.Children { | |
| 129 | - | walk(c, depth+1) | |
| 171 | + | func flattenSessionFamily(root *core.TreeNode, currentPath string) []sessionTreeItem { | |
| 172 | + | childrenByFork := map[int][]*core.TreeNode{} | |
| 173 | + | for _, child := range root.Children { | |
| 174 | + | childrenByFork[child.Meta.ForkPoint] = append(childrenByFork[child.Meta.ForkPoint], child) | |
| 175 | + | } | |
| 176 | + | sess, msgs, err := core.OpenSession(root.Summary.Path) | |
| 177 | + | if err != nil { | |
| 178 | + | return nil | |
| 179 | + | } | |
| 180 | + | _ = sess.Close() | |
| 181 | + | items := buildSessionTreeItems(root.Summary.Path, msgs, 0, root.Summary.Path == currentPath) | |
| 182 | + | var out []sessionTreeItem | |
| 183 | + | for idx, item := range items { | |
| 184 | + | out = append(out, item) | |
| 185 | + | for _, child := range childrenByFork[idx+1] { | |
| 186 | + | out = append(out, flattenSessionBranch(child, currentPath, 1)...) | |
| 130 | 187 | } | |
| 131 | 188 | } | |
| 132 | - | for _, r := range roots { | |
| 133 | - | walk(r, 0) | |
| 189 | + | for _, child := range childrenByFork[0] { | |
| 190 | + | out = append(out, flattenSessionBranch(child, currentPath, 1)...) | |
| 134 | 191 | } | |
| 135 | 192 | return out | |
| 136 | 193 | } | |
| 137 | 194 | ||
| 138 | - | // formatTreeRow renders one node's label line. Tries to fit a | |
| 139 | - | // compact "date preview (N msgs, $cost)" shape; falls back to | |
| 140 | - | // the meta id suffix when everything else is empty. | |
| 141 | - | func formatTreeRow(n *core.TreeNode) string { | |
| 142 | - | when := formatRelative(n.Summary.Started) | |
| 143 | - | preview := strings.TrimSpace(n.Summary.FirstUserText) | |
| 144 | - | if preview == "" { | |
| 145 | - | if n.Meta.ID != "" && len(n.Meta.ID) >= 8 { | |
| 146 | - | preview = "(" + n.Meta.ID[:8] + ")" | |
| 147 | - | } else { | |
| 148 | - | preview = "(empty)" | |
| 195 | + | func flattenSessionBranch(node *core.TreeNode, currentPath string, depth int) []sessionTreeItem { | |
| 196 | + | sess, msgs, err := core.OpenSession(node.Summary.Path) | |
| 197 | + | if err != nil { | |
| 198 | + | return nil | |
| 199 | + | } | |
| 200 | + | _ = sess.Close() | |
| 201 | + | start := node.Meta.ForkPoint | |
| 202 | + | if start < 0 { | |
| 203 | + | start = 0 | |
| 204 | + | } | |
| 205 | + | if start > len(msgs) { | |
| 206 | + | start = len(msgs) | |
| 207 | + | } | |
| 208 | + | items := buildSessionTreeItems(node.Summary.Path, msgs[start:], depth, node.Summary.Path == currentPath) | |
| 209 | + | for idx := range items { | |
| 210 | + | items[idx].messageIdx += start | |
| 211 | + | } | |
| 212 | + | childrenByFork := map[int][]*core.TreeNode{} | |
| 213 | + | for _, child := range node.Children { | |
| 214 | + | childrenByFork[child.Meta.ForkPoint] = append(childrenByFork[child.Meta.ForkPoint], child) | |
| 215 | + | } | |
| 216 | + | var out []sessionTreeItem | |
| 217 | + | for relIdx, item := range items { | |
| 218 | + | out = append(out, item) | |
| 219 | + | absAfter := start + relIdx + 1 | |
| 220 | + | for _, child := range childrenByFork[absAfter] { | |
| 221 | + | out = append(out, flattenSessionBranch(child, currentPath, depth+1)...) | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | return out | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func buildSessionTreeItems(path string, msgs []provider.Message, depth int, currentPath bool) []sessionTreeItem { | |
| 228 | + | out := make([]sessionTreeItem, 0, len(msgs)) | |
| 229 | + | turn := 0 | |
| 230 | + | lastTurn := 0 | |
| 231 | + | for idx, msg := range msgs { | |
| 232 | + | label := sessionTreeRoleLabel(msg.Role) | |
| 233 | + | if msg.Role == provider.RoleUser { | |
| 234 | + | turn++ | |
| 235 | + | lastTurn = turn | |
| 236 | + | } else if lastTurn == 0 { | |
| 237 | + | lastTurn = 1 | |
| 149 | 238 | } | |
| 239 | + | preview := sessionTreePreview(msg) | |
| 240 | + | out = append(out, sessionTreeItem{ | |
| 241 | + | label: fmt.Sprintf("%s: %s", label, preview), | |
| 242 | + | messageIdx: idx, | |
| 243 | + | turnNo: lastTurn, | |
| 244 | + | role: msg.Role, | |
| 245 | + | prompt: firstTextFromTreeMessage(msg), | |
| 246 | + | path: path, | |
| 247 | + | depth: depth, | |
| 248 | + | current: currentPath && idx == len(msgs)-1, | |
| 249 | + | }) | |
| 150 | 250 | } | |
| 151 | - | if len(preview) > 50 { | |
| 152 | - | preview = preview[:47] + "..." | |
| 251 | + | return out | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func findTreeRootForPath(roots []*core.TreeNode, path string) *core.TreeNode { | |
| 255 | + | for _, root := range roots { | |
| 256 | + | if treeContainsPath(root, path) { | |
| 257 | + | return root | |
| 258 | + | } | |
| 153 | 259 | } | |
| 154 | - | return fmt.Sprintf("%-14s %s %d msgs", when, preview, n.Summary.MessageCount) | |
| 260 | + | return nil | |
| 155 | 261 | } | |
| 156 | 262 | ||
| 157 | - | // indexOfCurrent returns the flat-list index of the row whose path | |
| 158 | - | // matches currentPath. -1 when not found; the caller defaults to 0. | |
| 159 | - | func indexOfCurrent(items []treeItem, currentPath string) int { | |
| 263 | + | func treeContainsPath(n *core.TreeNode, path string) bool { | |
| 264 | + | if n == nil { | |
| 265 | + | return false | |
| 266 | + | } | |
| 267 | + | if n.Summary.Path == path { | |
| 268 | + | return true | |
| 269 | + | } | |
| 270 | + | for _, child := range n.Children { | |
| 271 | + | if treeContainsPath(child, path) { | |
| 272 | + | return true | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | return false | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | func indexCurrentTreeItem(items []sessionTreeItem) int { | |
| 160 | 279 | for i, it := range items { | |
| 161 | - | if it.path == currentPath { | |
| 280 | + | if it.current { | |
| 162 | 281 | return i | |
| 163 | 282 | } | |
| 164 | 283 | } | |
| 165 | - | return 0 | |
| 284 | + | if len(items) == 0 { | |
| 285 | + | return 0 | |
| 286 | + | } | |
| 287 | + | return len(items) - 1 | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | func firstTextFromTreeMessage(msg provider.Message) string { | |
| 291 | + | for _, c := range msg.Content { | |
| 292 | + | if tb, ok := c.(provider.TextBlock); ok { | |
| 293 | + | return tb.Text | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | return "" | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | func sessionTreeRoleLabel(role provider.Role) string { | |
| 300 | + | switch role { | |
| 301 | + | case provider.RoleUser: | |
| 302 | + | return "you" | |
| 303 | + | case provider.RoleAssistant: | |
| 304 | + | return "zot" | |
| 305 | + | case provider.RoleTool: | |
| 306 | + | return "tool" | |
| 307 | + | default: | |
| 308 | + | return string(role) | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | func sessionTreePreview(msg provider.Message) string { | |
| 313 | + | var parts []string | |
| 314 | + | for _, c := range msg.Content { | |
| 315 | + | switch b := c.(type) { | |
| 316 | + | case provider.TextBlock: | |
| 317 | + | text := strings.TrimSpace(strings.ReplaceAll(b.Text, "\n", " ")) | |
| 318 | + | if text != "" { | |
| 319 | + | parts = append(parts, text) | |
| 320 | + | } | |
| 321 | + | case provider.ImageBlock: | |
| 322 | + | parts = append(parts, "[image]") | |
| 323 | + | case provider.ToolCallBlock: | |
| 324 | + | parts = append(parts, "tool "+b.Name) | |
| 325 | + | case provider.ToolResultBlock: | |
| 326 | + | if b.IsError { | |
| 327 | + | parts = append(parts, "tool result error") | |
| 328 | + | } else { | |
| 329 | + | parts = append(parts, "tool result") | |
| 330 | + | } | |
| 331 | + | case provider.ReasoningBlock: | |
| 332 | + | if b.Summary != "" { | |
| 333 | + | parts = append(parts, "reasoning") | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | if len(parts) == 0 { | |
| 338 | + | return "(empty)" | |
| 339 | + | } | |
| 340 | + | return strings.Join(parts, " ") | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | func colorSessionTreeLine(th tui.Theme, line string) string { | |
| 344 | + | plain := strings.TrimSpace(line) | |
| 345 | + | switch { | |
| 346 | + | case strings.HasPrefix(plain, "you:"): | |
| 347 | + | return th.FG256(th.FG, line) | |
| 348 | + | case strings.HasPrefix(plain, "zot:"): | |
| 349 | + | return th.FG256(th.Muted, line) | |
| 350 | + | case strings.HasPrefix(plain, "tool:"): | |
| 351 | + | return th.FG256(th.ToolOut, line) | |
| 352 | + | default: | |
| 353 | + | return th.FG256(th.Muted, line) | |
| 354 | + | } | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | func fitSessionTreeLabel(label string, maxWidth int) string { | |
| 358 | + | if maxWidth < 4 { | |
| 359 | + | maxWidth = 4 | |
| 360 | + | } | |
| 361 | + | runes := []rune(label) | |
| 362 | + | if len(runes) <= maxWidth { | |
| 363 | + | return label | |
| 364 | + | } | |
| 365 | + | return string(runes[:maxWidth-3]) + "..." | |
| 166 | 366 | } | |
| @@ -5,57 +5,74 @@ import ( | |||||
| 5 | "strings" | 5 | "strings" | ||
| 6 | 6 | ||||
| 7 | "github.com/patriceckhart/zot/packages/core" | 7 | "github.com/patriceckhart/zot/packages/core" | ||
| 8 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 8 | "github.com/patriceckhart/zot/packages/tui" | 9 | "github.com/patriceckhart/zot/packages/tui" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 11 | - | // sessionTreeDialog renders the branch topology for the current | 12 | + | // sessionTreeDialog renders a compact outline of the current session family. |
| 12 | - | // cwd (parents, children, siblings) and lets the user pick another | 13 | + | // Branches are shown inline at the point where they forked, using indentation |
| 13 | - | // session file to switch into. Reached via /session tree. | 14 | + | // rather than file-level rows. Selecting a node checks out that point into a |
| 14 | - | // | 15 | + | // new branch. |
| 15 | - | // Layout is a single flat list with indentation-based nesting so | |||
| 16 | - | // we can reuse the simple up/down cursor model the other dialogs | |||
| 17 | - | // use. The current session is tagged with a muted "[current]". | |||
| 18 | type sessionTreeDialog struct { | 16 | type sessionTreeDialog struct { | ||
| 19 | - | active bool | 17 | + | active bool |
| 20 | - | items []treeItem | 18 | + | items []sessionTreeItem |
| 21 | - | cursor int | 19 | + | cursor int |
| 22 | - | current string // path of the session we're currently in (not selectable) | |||
| 23 | } | 20 | } | ||
| 24 | 21 | ||||
| 25 | - | // treeItem is one visible row. Depth drives the indent; Path is the | 22 | + | type sessionTreeItem struct { |
| 26 | - | // on-disk session file to swap to when the user hits enter. | 23 | + | label string |
| 27 | - | type treeItem struct { | 24 | + | messageIdx int |
| 28 | - | label string | 25 | + | turnNo int |
| 29 | - | path string | 26 | + | role provider.Role |
| 30 | - | depth int | 27 | + | prompt string |
| 31 | - | isCur bool | 28 | + | path string |
| 29 | + | depth int | |||
| 30 | + | current bool | |||
| 32 | } | 31 | } | ||
| 33 | 32 | ||||
| 34 | type sessionTreeAction struct { | 33 | type sessionTreeAction struct { | ||
| 35 | - | Select bool | 34 | + | Select bool |
| 36 | - | Path string | 35 | + | MessageIdx int |
| 37 | - | Close bool | 36 | + | TurnNo int |
| 37 | + | Role provider.Role | |||
| 38 | + | Prompt string | |||
| 39 | + | Path string | |||
| 40 | + | Close bool | |||
| 38 | } | 41 | } | ||
| 39 | 42 | ||||
| 40 | func newSessionTreeDialog() *sessionTreeDialog { return &sessionTreeDialog{} } | 43 | func newSessionTreeDialog() *sessionTreeDialog { return &sessionTreeDialog{} } | ||
| 41 | 44 | ||||
| 42 | - | // Open flattens the given forest into indented rows. currentPath | 45 | + | func (d *sessionTreeDialog) OpenMessages(msgs []provider.Message) bool { |
| 43 | - | // is highlighted and non-selectable (enter on it closes the dialog). | 46 | + | d.items = buildSessionTreeItems("", msgs, 0, false) |
| 44 | - | func (d *sessionTreeDialog) Open(roots []*core.TreeNode, currentPath string) bool { | 47 | + | d.cursor = len(d.items) - 1 |
| 45 | - | items := flattenTree(roots, currentPath) | 48 | + | if d.cursor < 0 { |
| 46 | - | if len(items) == 0 { | 49 | + | d.cursor = 0 |
| 50 | + | } | |||
| 51 | + | d.active = true | |||
| 52 | + | return len(d.items) > 0 | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func (d *sessionTreeDialog) OpenSessionFamily(root, cwd, currentPath string) bool { | |||
| 56 | + | roots := core.BuildSessionTree(root, cwd) | |||
| 57 | + | if len(roots) == 0 { | |||
| 47 | return false | 58 | return false | ||
| 48 | } | 59 | } | ||
| 49 | - | d.items = items | 60 | + | rootNode := findTreeRootForPath(roots, currentPath) |
| 50 | - | d.current = currentPath | 61 | + | if rootNode == nil { |
| 51 | - | d.cursor = indexOfCurrent(items, currentPath) | 62 | + | rootNode = roots[0] |
| 63 | + | } | |||
| 64 | + | d.items = flattenSessionFamily(rootNode, currentPath) | |||
| 65 | + | d.cursor = indexCurrentTreeItem(d.items) | |||
| 52 | d.active = true | 66 | d.active = true | ||
| 53 | - | return true | 67 | + | return len(d.items) > 0 |
| 54 | } | 68 | } | ||
| 55 | 69 | ||||
| 56 | // Close hides the dialog. | 70 | // Close hides the dialog. | ||
| 57 | func (d *sessionTreeDialog) Close() { d.active = false } | 71 | func (d *sessionTreeDialog) Close() { d.active = false } | ||
| 58 | 72 | ||||
| 73 | + | // CursorPos returns -1 because this dialog has no inline editor. | |||
| 74 | + | func (d *sessionTreeDialog) CursorPos() (row, col int) { return -1, -1 } | |||
| 75 | + | ||||
| 59 | // Active reports whether the dialog consumes input. | 76 | // Active reports whether the dialog consumes input. | ||
| 60 | func (d *sessionTreeDialog) Active() bool { return d != nil && d.active } | 77 | func (d *sessionTreeDialog) Active() bool { return d != nil && d.active } | ||
| 61 | 78 | ||||
| @@ -70,6 +87,19 @@ func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction { | |||||
| 70 | if d.cursor < len(d.items)-1 { | 87 | if d.cursor < len(d.items)-1 { | ||
| 71 | d.cursor++ | 88 | d.cursor++ | ||
| 72 | } | 89 | } | ||
| 90 | + | case tui.KeyPageUp: | |||
| 91 | + | d.cursor -= 5 | |||
| 92 | + | if d.cursor < 0 { | |||
| 93 | + | d.cursor = 0 | |||
| 94 | + | } | |||
| 95 | + | case tui.KeyPageDown: | |||
| 96 | + | d.cursor += 5 | |||
| 97 | + | if d.cursor >= len(d.items) { | |||
| 98 | + | d.cursor = len(d.items) - 1 | |||
| 99 | + | } | |||
| 100 | + | if d.cursor < 0 { | |||
| 101 | + | d.cursor = 0 | |||
| 102 | + | } | |||
| 73 | case tui.KeyEsc: | 103 | case tui.KeyEsc: | ||
| 74 | d.Close() | 104 | d.Close() | ||
| 75 | return sessionTreeAction{Close: true} | 105 | return sessionTreeAction{Close: true} | ||
| @@ -80,10 +110,7 @@ func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction { | |||||
| 80 | } | 110 | } | ||
| 81 | it := d.items[d.cursor] | 111 | it := d.items[d.cursor] | ||
| 82 | d.Close() | 112 | d.Close() | ||
| 83 | - | if it.isCur { | 113 | + | return sessionTreeAction{Select: true, MessageIdx: it.messageIdx, TurnNo: it.turnNo, Role: it.role, Prompt: it.prompt, Path: it.path} |
| 84 | - | return sessionTreeAction{Close: true} | |||
| 85 | - | } | |||
| 86 | - | return sessionTreeAction{Select: true, Path: it.path} | |||
| 87 | } | 114 | } | ||
| 88 | return sessionTreeAction{} | 115 | return sessionTreeAction{} | ||
| 89 | } | 116 | } | ||
| @@ -95,72 +122,245 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string { | |||||
| 95 | } | 122 | } | ||
| 96 | var lines []string | 123 | var lines []string | ||
| 97 | lines = append(lines, frameHeader(th, "session tree", width)) | 124 | lines = append(lines, frameHeader(th, "session tree", width)) | ||
| 98 | - | lines = append(lines, th.FG256(th.Muted, "pick a branch to switch to (\u2191/\u2193, enter, esc to cancel):")) | 125 | + | if len(d.items) == 0 { |
| 99 | - | for i, it := range d.items { | 126 | + | lines = append(lines, th.FG256(th.Muted, "no messages in this session yet")) |
| 127 | + | lines = append(lines, th.FG256(th.Muted, "press esc to close")) | |||
| 128 | + | lines = append(lines, frameRule(th, width)) | |||
| 129 | + | return lines | |||
| 130 | + | } | |||
| 131 | + | lines = append(lines, th.FG256(th.Muted, "session branches (↑/↓, pgup/pgdn, enter checkout, esc cancel):")) | |||
| 132 | + | ||||
| 133 | + | const maxRows = 12 | |||
| 134 | + | start := 0 | |||
| 135 | + | end := len(d.items) | |||
| 136 | + | if end > maxRows { | |||
| 137 | + | start = d.cursor - maxRows/2 | |||
| 138 | + | if start < 0 { | |||
| 139 | + | start = 0 | |||
| 140 | + | } | |||
| 141 | + | end = start + maxRows | |||
| 142 | + | if end > len(d.items) { | |||
| 143 | + | end = len(d.items) | |||
| 144 | + | start = end - maxRows | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | if start > 0 { | |||
| 148 | + | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", start))) | |||
| 149 | + | } | |||
| 150 | + | for i := start; i < end; i++ { | |||
| 151 | + | it := d.items[i] | |||
| 100 | indent := strings.Repeat(" ", it.depth) | 152 | indent := strings.Repeat(" ", it.depth) | ||
| 101 | - | label := " " + indent + it.label | 153 | + | plain := " " + indent + fitSessionTreeLabel(it.label, width-2-len([]rune(indent))) |
| 102 | - | if it.isCur { | 154 | + | if it.current { |
| 103 | - | label += " " + th.FG256(th.Muted, "[current]") | 155 | + | plain += " [current]" |
| 104 | } | 156 | } | ||
| 105 | if i == d.cursor { | 157 | if i == d.cursor { | ||
| 106 | - | lines = append(lines, th.PadHighlight(label, width)) | 158 | + | lines = append(lines, th.PadHighlight(plain, width)) |
| 107 | } else { | 159 | } else { | ||
| 108 | - | lines = append(lines, th.FG256(th.Muted, label)) | 160 | + | lines = append(lines, colorSessionTreeLine(th, plain)) |
| 109 | } | 161 | } | ||
| 110 | } | 162 | } | ||
| 163 | + | if end < len(d.items) { | |||
| 164 | + | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", len(d.items)-end))) | |||
| 165 | + | } | |||
| 166 | + | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("%d/%d", d.cursor+1, len(d.items)))) | |||
| 111 | lines = append(lines, frameRule(th, width)) | 167 | lines = append(lines, frameRule(th, width)) | ||
| 112 | return lines | 168 | return lines | ||
| 113 | } | 169 | } | ||
| 114 | 170 | ||||
| 115 | - | // flattenTree walks the forest depth-first and returns one treeItem | 171 | + | func flattenSessionFamily(root *core.TreeNode, currentPath string) []sessionTreeItem { |
| 116 | - | // per node. Each label has the shape "<when> <first-prompt> (N msgs)". | 172 | + | childrenByFork := map[int][]*core.TreeNode{} |
| 117 | - | func flattenTree(roots []*core.TreeNode, currentPath string) []treeItem { | 173 | + | for _, child := range root.Children { |
| 118 | - | var out []treeItem | 174 | + | childrenByFork[child.Meta.ForkPoint] = append(childrenByFork[child.Meta.ForkPoint], child) |
| 119 | - | var walk func(n *core.TreeNode, depth int) | 175 | + | } |
| 120 | - | walk = func(n *core.TreeNode, depth int) { | 176 | + | sess, msgs, err := core.OpenSession(root.Summary.Path) |
| 121 | - | label := formatTreeRow(n) | 177 | + | if err != nil { |
| 122 | - | out = append(out, treeItem{ | 178 | + | return nil |
| 123 | - | label: label, | 179 | + | } |
| 124 | - | path: n.Summary.Path, | 180 | + | _ = sess.Close() |
| 125 | - | depth: depth, | 181 | + | items := buildSessionTreeItems(root.Summary.Path, msgs, 0, root.Summary.Path == currentPath) |
| 126 | - | isCur: n.Summary.Path == currentPath, | 182 | + | var out []sessionTreeItem |
| 127 | - | }) | 183 | + | for idx, item := range items { |
| 128 | - | for _, c := range n.Children { | 184 | + | out = append(out, item) |
| 129 | - | walk(c, depth+1) | 185 | + | for _, child := range childrenByFork[idx+1] { |
| 186 | + | out = append(out, flattenSessionBranch(child, currentPath, 1)...) | |||
| 130 | } | 187 | } | ||
| 131 | } | 188 | } | ||
| 132 | - | for _, r := range roots { | 189 | + | for _, child := range childrenByFork[0] { |
| 133 | - | walk(r, 0) | 190 | + | out = append(out, flattenSessionBranch(child, currentPath, 1)...) |
| 134 | } | 191 | } | ||
| 135 | return out | 192 | return out | ||
| 136 | } | 193 | } | ||
| 137 | 194 | ||||
| 138 | - | // formatTreeRow renders one node's label line. Tries to fit a | 195 | + | func flattenSessionBranch(node *core.TreeNode, currentPath string, depth int) []sessionTreeItem { |
| 139 | - | // compact "date preview (N msgs, $cost)" shape; falls back to | 196 | + | sess, msgs, err := core.OpenSession(node.Summary.Path) |
| 140 | - | // the meta id suffix when everything else is empty. | 197 | + | if err != nil { |
| 141 | - | func formatTreeRow(n *core.TreeNode) string { | 198 | + | return nil |
| 142 | - | when := formatRelative(n.Summary.Started) | 199 | + | } |
| 143 | - | preview := strings.TrimSpace(n.Summary.FirstUserText) | 200 | + | _ = sess.Close() |
| 144 | - | if preview == "" { | 201 | + | start := node.Meta.ForkPoint |
| 145 | - | if n.Meta.ID != "" && len(n.Meta.ID) >= 8 { | 202 | + | if start < 0 { |
| 146 | - | preview = "(" + n.Meta.ID[:8] + ")" | 203 | + | start = 0 |
| 147 | - | } else { | 204 | + | } |
| 148 | - | preview = "(empty)" | 205 | + | if start > len(msgs) { |
| 206 | + | start = len(msgs) | |||
| 207 | + | } | |||
| 208 | + | items := buildSessionTreeItems(node.Summary.Path, msgs[start:], depth, node.Summary.Path == currentPath) | |||
| 209 | + | for idx := range items { | |||
| 210 | + | items[idx].messageIdx += start | |||
| 211 | + | } | |||
| 212 | + | childrenByFork := map[int][]*core.TreeNode{} | |||
| 213 | + | for _, child := range node.Children { | |||
| 214 | + | childrenByFork[child.Meta.ForkPoint] = append(childrenByFork[child.Meta.ForkPoint], child) | |||
| 215 | + | } | |||
| 216 | + | var out []sessionTreeItem | |||
| 217 | + | for relIdx, item := range items { | |||
| 218 | + | out = append(out, item) | |||
| 219 | + | absAfter := start + relIdx + 1 | |||
| 220 | + | for _, child := range childrenByFork[absAfter] { | |||
| 221 | + | out = append(out, flattenSessionBranch(child, currentPath, depth+1)...) | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | return out | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func buildSessionTreeItems(path string, msgs []provider.Message, depth int, currentPath bool) []sessionTreeItem { | |||
| 228 | + | out := make([]sessionTreeItem, 0, len(msgs)) | |||
| 229 | + | turn := 0 | |||
| 230 | + | lastTurn := 0 | |||
| 231 | + | for idx, msg := range msgs { | |||
| 232 | + | label := sessionTreeRoleLabel(msg.Role) | |||
| 233 | + | if msg.Role == provider.RoleUser { | |||
| 234 | + | turn++ | |||
| 235 | + | lastTurn = turn | |||
| 236 | + | } else if lastTurn == 0 { | |||
| 237 | + | lastTurn = 1 | |||
| 149 | } | 238 | } | ||
| 239 | + | preview := sessionTreePreview(msg) | |||
| 240 | + | out = append(out, sessionTreeItem{ | |||
| 241 | + | label: fmt.Sprintf("%s: %s", label, preview), | |||
| 242 | + | messageIdx: idx, | |||
| 243 | + | turnNo: lastTurn, | |||
| 244 | + | role: msg.Role, | |||
| 245 | + | prompt: firstTextFromTreeMessage(msg), | |||
| 246 | + | path: path, | |||
| 247 | + | depth: depth, | |||
| 248 | + | current: currentPath && idx == len(msgs)-1, | |||
| 249 | + | }) | |||
| 150 | } | 250 | } | ||
| 151 | - | if len(preview) > 50 { | 251 | + | return out |
| 152 | - | preview = preview[:47] + "..." | 252 | + | } |
| 253 | + | ||||
| 254 | + | func findTreeRootForPath(roots []*core.TreeNode, path string) *core.TreeNode { | |||
| 255 | + | for _, root := range roots { | |||
| 256 | + | if treeContainsPath(root, path) { | |||
| 257 | + | return root | |||
| 258 | + | } | |||
| 153 | } | 259 | } | ||
| 154 | - | return fmt.Sprintf("%-14s %s %d msgs", when, preview, n.Summary.MessageCount) | 260 | + | return nil |
| 155 | } | 261 | } | ||
| 156 | 262 | ||||
| 157 | - | // indexOfCurrent returns the flat-list index of the row whose path | 263 | + | func treeContainsPath(n *core.TreeNode, path string) bool { |
| 158 | - | // matches currentPath. -1 when not found; the caller defaults to 0. | 264 | + | if n == nil { |
| 159 | - | func indexOfCurrent(items []treeItem, currentPath string) int { | 265 | + | return false |
| 266 | + | } | |||
| 267 | + | if n.Summary.Path == path { | |||
| 268 | + | return true | |||
| 269 | + | } | |||
| 270 | + | for _, child := range n.Children { | |||
| 271 | + | if treeContainsPath(child, path) { | |||
| 272 | + | return true | |||
| 273 | + | } | |||
| 274 | + | } | |||
| 275 | + | return false | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | func indexCurrentTreeItem(items []sessionTreeItem) int { | |||
| 160 | for i, it := range items { | 279 | for i, it := range items { | ||
| 161 | - | if it.path == currentPath { | 280 | + | if it.current { |
| 162 | return i | 281 | return i | ||
| 163 | } | 282 | } | ||
| 164 | } | 283 | } | ||
| 165 | - | return 0 | 284 | + | if len(items) == 0 { |
| 285 | + | return 0 | |||
| 286 | + | } | |||
| 287 | + | return len(items) - 1 | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | func firstTextFromTreeMessage(msg provider.Message) string { | |||
| 291 | + | for _, c := range msg.Content { | |||
| 292 | + | if tb, ok := c.(provider.TextBlock); ok { | |||
| 293 | + | return tb.Text | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | return "" | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | func sessionTreeRoleLabel(role provider.Role) string { | |||
| 300 | + | switch role { | |||
| 301 | + | case provider.RoleUser: | |||
| 302 | + | return "you" | |||
| 303 | + | case provider.RoleAssistant: | |||
| 304 | + | return "zot" | |||
| 305 | + | case provider.RoleTool: | |||
| 306 | + | return "tool" | |||
| 307 | + | default: | |||
| 308 | + | return string(role) | |||
| 309 | + | } | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | func sessionTreePreview(msg provider.Message) string { | |||
| 313 | + | var parts []string | |||
| 314 | + | for _, c := range msg.Content { | |||
| 315 | + | switch b := c.(type) { | |||
| 316 | + | case provider.TextBlock: | |||
| 317 | + | text := strings.TrimSpace(strings.ReplaceAll(b.Text, "\n", " ")) | |||
| 318 | + | if text != "" { | |||
| 319 | + | parts = append(parts, text) | |||
| 320 | + | } | |||
| 321 | + | case provider.ImageBlock: | |||
| 322 | + | parts = append(parts, "[image]") | |||
| 323 | + | case provider.ToolCallBlock: | |||
| 324 | + | parts = append(parts, "tool "+b.Name) | |||
| 325 | + | case provider.ToolResultBlock: | |||
| 326 | + | if b.IsError { | |||
| 327 | + | parts = append(parts, "tool result error") | |||
| 328 | + | } else { | |||
| 329 | + | parts = append(parts, "tool result") | |||
| 330 | + | } | |||
| 331 | + | case provider.ReasoningBlock: | |||
| 332 | + | if b.Summary != "" { | |||
| 333 | + | parts = append(parts, "reasoning") | |||
| 334 | + | } | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | if len(parts) == 0 { | |||
| 338 | + | return "(empty)" | |||
| 339 | + | } | |||
| 340 | + | return strings.Join(parts, " ") | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | func colorSessionTreeLine(th tui.Theme, line string) string { | |||
| 344 | + | plain := strings.TrimSpace(line) | |||
| 345 | + | switch { | |||
| 346 | + | case strings.HasPrefix(plain, "you:"): | |||
| 347 | + | return th.FG256(th.FG, line) | |||
| 348 | + | case strings.HasPrefix(plain, "zot:"): | |||
| 349 | + | return th.FG256(th.Muted, line) | |||
| 350 | + | case strings.HasPrefix(plain, "tool:"): | |||
| 351 | + | return th.FG256(th.ToolOut, line) | |||
| 352 | + | default: | |||
| 353 | + | return th.FG256(th.Muted, line) | |||
| 354 | + | } | |||
| 355 | + | } | |||
| 356 | + | ||||
| 357 | + | func fitSessionTreeLabel(label string, maxWidth int) string { | |||
| 358 | + | if maxWidth < 4 { | |||
| 359 | + | maxWidth = 4 | |||
| 360 | + | } | |||
| 361 | + | runes := []rune(label) | |||
| 362 | + | if len(runes) <= maxWidth { | |||
| 363 | + | return label | |||
| 364 | + | } | |||
| 365 | + | return string(runes[:maxWidth-3]) + "..." | |||
| 166 | } | 366 | } | ||
M packages/agent/modes/telegram/bridge.go +61 -6
| @@ -44,12 +44,13 @@ type Bridge struct { | |||
| 44 | 44 | Save func(Config) error | |
| 45 | 45 | Host Host | |
| 46 | 46 | ||
| 47 | - | mu sync.Mutex | |
| 48 | - | running bool | |
| 49 | - | cancel context.CancelFunc | |
| 50 | - | me *User | |
| 51 | - | chatID int64 // populated after first DM from the paired user | |
| 52 | - | replyBuf strings.Builder | |
| 47 | + | mu sync.Mutex | |
| 48 | + | running bool | |
| 49 | + | cancel context.CancelFunc | |
| 50 | + | me *User | |
| 51 | + | chatID int64 // populated after first DM from the paired user | |
| 52 | + | replyBuf strings.Builder | |
| 53 | + | workingCancel context.CancelFunc | |
| 53 | 54 | ||
| 54 | 55 | // nextReplyFromTelegram is set when the next assistant reply | |
| 55 | 56 | // should be sent bare (no "zot: " prefix) because the turn was | |
| @@ -140,9 +141,14 @@ func (b *Bridge) Start(parent context.Context) error { | |||
| 140 | 141 | func (b *Bridge) Stop() { | |
| 141 | 142 | b.mu.Lock() | |
| 142 | 143 | cancel := b.cancel | |
| 144 | + | workingCancel := b.workingCancel | |
| 143 | 145 | b.running = false | |
| 144 | 146 | b.cancel = nil | |
| 147 | + | b.workingCancel = nil | |
| 145 | 148 | b.mu.Unlock() | |
| 149 | + | if workingCancel != nil { | |
| 150 | + | workingCancel() | |
| 151 | + | } | |
| 146 | 152 | if cancel != nil { | |
| 147 | 153 | cancel() | |
| 148 | 154 | } | |
| @@ -160,11 +166,16 @@ func (b *Bridge) OnAssistantText(text string) { | |||
| 160 | 166 | b.mu.Lock() | |
| 161 | 167 | // prefix := "zot: " | |
| 162 | 168 | prefix := "" | |
| 169 | + | workingCancel := b.workingCancel | |
| 170 | + | b.workingCancel = nil | |
| 163 | 171 | if b.nextReplyFromTelegram { | |
| 164 | 172 | prefix = "" | |
| 165 | 173 | b.nextReplyFromTelegram = false | |
| 166 | 174 | } | |
| 167 | 175 | b.mu.Unlock() | |
| 176 | + | if workingCancel != nil { | |
| 177 | + | workingCancel() | |
| 178 | + | } | |
| 168 | 179 | b.sendToPaired(text, prefix) | |
| 169 | 180 | } | |
| 170 | 181 | ||
| @@ -178,6 +189,47 @@ func (b *Bridge) OnUserTyped(text string) { | |||
| 178 | 189 | b.sendToPaired(text, "you: ") | |
| 179 | 190 | } | |
| 180 | 191 | ||
| 192 | + | // indicateWorking keeps Telegram's typing indicator visible while a turn | |
| 193 | + | // submitted from Telegram is waiting or running. Telegram clears chat actions | |
| 194 | + | // after a few seconds, so refresh it until the reply is ready or the bridge | |
| 195 | + | // stops. | |
| 196 | + | func (b *Bridge) indicateWorking(ctx context.Context, chatID int64) { | |
| 197 | + | workingCtx, cancel := context.WithCancel(ctx) | |
| 198 | + | ||
| 199 | + | b.mu.Lock() | |
| 200 | + | previous := b.workingCancel | |
| 201 | + | b.workingCancel = cancel | |
| 202 | + | b.mu.Unlock() | |
| 203 | + | if previous != nil { | |
| 204 | + | previous() | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | go func() { | |
| 208 | + | ticker := time.NewTicker(4 * time.Second) | |
| 209 | + | defer ticker.Stop() | |
| 210 | + | for { | |
| 211 | + | _ = b.Client.SendChatAction(workingCtx, chatID, "typing") | |
| 212 | + | select { | |
| 213 | + | case <-workingCtx.Done(): | |
| 214 | + | return | |
| 215 | + | case <-ticker.C: | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | }() | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // stopWorking clears the local typing-indicator refresh loop. Telegram removes | |
| 222 | + | // the visible indicator itself when the next message arrives or it expires. | |
| 223 | + | func (b *Bridge) stopWorking() { | |
| 224 | + | b.mu.Lock() | |
| 225 | + | cancel := b.workingCancel | |
| 226 | + | b.workingCancel = nil | |
| 227 | + | b.mu.Unlock() | |
| 228 | + | if cancel != nil { | |
| 229 | + | cancel() | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 181 | 233 | // sendToPaired writes text (with an optional prefix, chunked to | |
| 182 | 234 | // Telegram's 4096-char cap) to the paired chat. No-op when the | |
| 183 | 235 | // bridge is stopped or before the paired chat id is known. | |
| @@ -342,12 +394,14 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) { | |||
| 342 | 394 | return | |
| 343 | 395 | case "/stop": | |
| 344 | 396 | b.Host.CancelTurn() | |
| 397 | + | b.stopWorking() | |
| 345 | 398 | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | |
| 346 | 399 | "cancelled the current turn.", msg.MessageID) | |
| 347 | 400 | return | |
| 348 | 401 | } | |
| 349 | 402 | if isStopCommand(text) { | |
| 350 | 403 | b.Host.CancelTurn() | |
| 404 | + | b.stopWorking() | |
| 351 | 405 | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | |
| 352 | 406 | "cancelled the current turn.", msg.MessageID) | |
| 353 | 407 | return | |
| @@ -382,6 +436,7 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) { | |||
| 382 | 436 | b.mu.Lock() | |
| 383 | 437 | b.nextReplyFromTelegram = true | |
| 384 | 438 | b.mu.Unlock() | |
| 439 | + | b.indicateWorking(ctx, msg.Chat.ID) | |
| 385 | 440 | b.Host.SubmitOrQueue(prompt, images) | |
| 386 | 441 | } | |
| 387 | 442 | ||
| @@ -44,12 +44,13 @@ type Bridge struct { | |||||
| 44 | Save func(Config) error | 44 | Save func(Config) error | ||
| 45 | Host Host | 45 | Host Host | ||
| 46 | 46 | ||||
| 47 | - | mu sync.Mutex | 47 | + | mu sync.Mutex |
| 48 | - | running bool | 48 | + | running bool |
| 49 | - | cancel context.CancelFunc | 49 | + | cancel context.CancelFunc |
| 50 | - | me *User | 50 | + | me *User |
| 51 | - | chatID int64 // populated after first DM from the paired user | 51 | + | chatID int64 // populated after first DM from the paired user |
| 52 | - | replyBuf strings.Builder | 52 | + | replyBuf strings.Builder |
| 53 | + | workingCancel context.CancelFunc | |||
| 53 | 54 | ||||
| 54 | // nextReplyFromTelegram is set when the next assistant reply | 55 | // nextReplyFromTelegram is set when the next assistant reply | ||
| 55 | // should be sent bare (no "zot: " prefix) because the turn was | 56 | // should be sent bare (no "zot: " prefix) because the turn was | ||
| @@ -140,9 +141,14 @@ func (b *Bridge) Start(parent context.Context) error { | |||||
| 140 | func (b *Bridge) Stop() { | 141 | func (b *Bridge) Stop() { | ||
| 141 | b.mu.Lock() | 142 | b.mu.Lock() | ||
| 142 | cancel := b.cancel | 143 | cancel := b.cancel | ||
| 144 | + | workingCancel := b.workingCancel | |||
| 143 | b.running = false | 145 | b.running = false | ||
| 144 | b.cancel = nil | 146 | b.cancel = nil | ||
| 147 | + | b.workingCancel = nil | |||
| 145 | b.mu.Unlock() | 148 | b.mu.Unlock() | ||
| 149 | + | if workingCancel != nil { | |||
| 150 | + | workingCancel() | |||
| 151 | + | } | |||
| 146 | if cancel != nil { | 152 | if cancel != nil { | ||
| 147 | cancel() | 153 | cancel() | ||
| 148 | } | 154 | } | ||
| @@ -160,11 +166,16 @@ func (b *Bridge) OnAssistantText(text string) { | |||||
| 160 | b.mu.Lock() | 166 | b.mu.Lock() | ||
| 161 | // prefix := "zot: " | 167 | // prefix := "zot: " | ||
| 162 | prefix := "" | 168 | prefix := "" | ||
| 169 | + | workingCancel := b.workingCancel | |||
| 170 | + | b.workingCancel = nil | |||
| 163 | if b.nextReplyFromTelegram { | 171 | if b.nextReplyFromTelegram { | ||
| 164 | prefix = "" | 172 | prefix = "" | ||
| 165 | b.nextReplyFromTelegram = false | 173 | b.nextReplyFromTelegram = false | ||
| 166 | } | 174 | } | ||
| 167 | b.mu.Unlock() | 175 | b.mu.Unlock() | ||
| 176 | + | if workingCancel != nil { | |||
| 177 | + | workingCancel() | |||
| 178 | + | } | |||
| 168 | b.sendToPaired(text, prefix) | 179 | b.sendToPaired(text, prefix) | ||
| 169 | } | 180 | } | ||
| 170 | 181 | ||||
| @@ -178,6 +189,47 @@ func (b *Bridge) OnUserTyped(text string) { | |||||
| 178 | b.sendToPaired(text, "you: ") | 189 | b.sendToPaired(text, "you: ") | ||
| 179 | } | 190 | } | ||
| 180 | 191 | ||||
| 192 | + | // indicateWorking keeps Telegram's typing indicator visible while a turn | |||
| 193 | + | // submitted from Telegram is waiting or running. Telegram clears chat actions | |||
| 194 | + | // after a few seconds, so refresh it until the reply is ready or the bridge | |||
| 195 | + | // stops. | |||
| 196 | + | func (b *Bridge) indicateWorking(ctx context.Context, chatID int64) { | |||
| 197 | + | workingCtx, cancel := context.WithCancel(ctx) | |||
| 198 | + | ||||
| 199 | + | b.mu.Lock() | |||
| 200 | + | previous := b.workingCancel | |||
| 201 | + | b.workingCancel = cancel | |||
| 202 | + | b.mu.Unlock() | |||
| 203 | + | if previous != nil { | |||
| 204 | + | previous() | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | go func() { | |||
| 208 | + | ticker := time.NewTicker(4 * time.Second) | |||
| 209 | + | defer ticker.Stop() | |||
| 210 | + | for { | |||
| 211 | + | _ = b.Client.SendChatAction(workingCtx, chatID, "typing") | |||
| 212 | + | select { | |||
| 213 | + | case <-workingCtx.Done(): | |||
| 214 | + | return | |||
| 215 | + | case <-ticker.C: | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | }() | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // stopWorking clears the local typing-indicator refresh loop. Telegram removes | |||
| 222 | + | // the visible indicator itself when the next message arrives or it expires. | |||
| 223 | + | func (b *Bridge) stopWorking() { | |||
| 224 | + | b.mu.Lock() | |||
| 225 | + | cancel := b.workingCancel | |||
| 226 | + | b.workingCancel = nil | |||
| 227 | + | b.mu.Unlock() | |||
| 228 | + | if cancel != nil { | |||
| 229 | + | cancel() | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | ||||
| 181 | // sendToPaired writes text (with an optional prefix, chunked to | 233 | // sendToPaired writes text (with an optional prefix, chunked to | ||
| 182 | // Telegram's 4096-char cap) to the paired chat. No-op when the | 234 | // Telegram's 4096-char cap) to the paired chat. No-op when the | ||
| 183 | // bridge is stopped or before the paired chat id is known. | 235 | // bridge is stopped or before the paired chat id is known. | ||
| @@ -342,12 +394,14 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) { | |||||
| 342 | return | 394 | return | ||
| 343 | case "/stop": | 395 | case "/stop": | ||
| 344 | b.Host.CancelTurn() | 396 | b.Host.CancelTurn() | ||
| 397 | + | b.stopWorking() | |||
| 345 | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | 398 | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | ||
| 346 | "cancelled the current turn.", msg.MessageID) | 399 | "cancelled the current turn.", msg.MessageID) | ||
| 347 | return | 400 | return | ||
| 348 | } | 401 | } | ||
| 349 | if isStopCommand(text) { | 402 | if isStopCommand(text) { | ||
| 350 | b.Host.CancelTurn() | 403 | b.Host.CancelTurn() | ||
| 404 | + | b.stopWorking() | |||
| 351 | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | 405 | _ = b.Client.SendMessage(ctx, msg.Chat.ID, | ||
| 352 | "cancelled the current turn.", msg.MessageID) | 406 | "cancelled the current turn.", msg.MessageID) | ||
| 353 | return | 407 | return | ||
| @@ -382,6 +436,7 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) { | |||||
| 382 | b.mu.Lock() | 436 | b.mu.Lock() | ||
| 383 | b.nextReplyFromTelegram = true | 437 | b.nextReplyFromTelegram = true | ||
| 384 | b.mu.Unlock() | 438 | b.mu.Unlock() | ||
| 439 | + | b.indicateWorking(ctx, msg.Chat.ID) | |||
| 385 | b.Host.SubmitOrQueue(prompt, images) | 440 | b.Host.SubmitOrQueue(prompt, images) | ||
| 386 | } | 441 | } | ||
| 387 | 442 | ||||
M packages/agent/rpc.go +20 -0
| @@ -30,6 +30,7 @@ import ( | |||
| 30 | 30 | // {"id":"3","type":"compact"} | |
| 31 | 31 | // {"id":"4","type":"get_state"} | |
| 32 | 32 | // {"id":"5","type":"set_model","model":"claude-opus-4-5"} | |
| 33 | + | // {"id":"6","type":"set_reasoning","reasoning":"max"} | |
| 33 | 34 | // {"id":"6","type":"get_messages"} | |
| 34 | 35 | // {"id":"7","type":"clear"} | |
| 35 | 36 | // {"id":"8","type":"get_models"} | |
| @@ -301,6 +302,24 @@ func (s *rpcServer) dispatch(cmd, id string, raw []byte) { | |||
| 301 | 302 | s.model = req.Model | |
| 302 | 303 | s.writeResponse(id, cmd, map[string]any{"model": req.Model}) | |
| 303 | 304 | ||
| 305 | + | case "set_reasoning": | |
| 306 | + | var req struct { | |
| 307 | + | Reasoning string `json:"reasoning"` | |
| 308 | + | } | |
| 309 | + | if err := json.Unmarshal(raw, &req); err != nil { | |
| 310 | + | s.writeError(id, cmd, err.Error()) | |
| 311 | + | return | |
| 312 | + | } | |
| 313 | + | reasoning := provider.NormalizeReasoning(req.Reasoning) | |
| 314 | + | switch reasoning { | |
| 315 | + | case "", "minimum", "low", "medium", "high", "xhigh", "max": | |
| 316 | + | default: | |
| 317 | + | s.writeError(id, cmd, "reasoning must be off|minimum|low|medium|high|xhigh|max") | |
| 318 | + | return | |
| 319 | + | } | |
| 320 | + | s.agent.Reasoning = reasoning | |
| 321 | + | s.writeResponse(id, cmd, map[string]any{"reasoning": reasoning}) | |
| 322 | + | ||
| 304 | 323 | case "get_models": | |
| 305 | 324 | out := []map[string]any{} | |
| 306 | 325 | for _, m := range provider.ModelsForProvider(s.provider) { | |
| @@ -388,6 +407,7 @@ func (s *rpcServer) snapshotState() map[string]any { | |||
| 388 | 407 | return map[string]any{ | |
| 389 | 408 | "provider": s.provider, | |
| 390 | 409 | "model": s.model, | |
| 410 | + | "reasoning": s.agent.Reasoning, | |
| 391 | 411 | "cwd": s.args.CWD, | |
| 392 | 412 | "message_count": len(s.agent.Messages()), | |
| 393 | 413 | "busy": s.activeCancel != nil, | |
| @@ -30,6 +30,7 @@ import ( | |||||
| 30 | // {"id":"3","type":"compact"} | 30 | // {"id":"3","type":"compact"} | ||
| 31 | // {"id":"4","type":"get_state"} | 31 | // {"id":"4","type":"get_state"} | ||
| 32 | // {"id":"5","type":"set_model","model":"claude-opus-4-5"} | 32 | // {"id":"5","type":"set_model","model":"claude-opus-4-5"} | ||
| 33 | + | // {"id":"6","type":"set_reasoning","reasoning":"max"} | |||
| 33 | // {"id":"6","type":"get_messages"} | 34 | // {"id":"6","type":"get_messages"} | ||
| 34 | // {"id":"7","type":"clear"} | 35 | // {"id":"7","type":"clear"} | ||
| 35 | // {"id":"8","type":"get_models"} | 36 | // {"id":"8","type":"get_models"} | ||
| @@ -301,6 +302,24 @@ func (s *rpcServer) dispatch(cmd, id string, raw []byte) { | |||||
| 301 | s.model = req.Model | 302 | s.model = req.Model | ||
| 302 | s.writeResponse(id, cmd, map[string]any{"model": req.Model}) | 303 | s.writeResponse(id, cmd, map[string]any{"model": req.Model}) | ||
| 303 | 304 | ||||
| 305 | + | case "set_reasoning": | |||
| 306 | + | var req struct { | |||
| 307 | + | Reasoning string `json:"reasoning"` | |||
| 308 | + | } | |||
| 309 | + | if err := json.Unmarshal(raw, &req); err != nil { | |||
| 310 | + | s.writeError(id, cmd, err.Error()) | |||
| 311 | + | return | |||
| 312 | + | } | |||
| 313 | + | reasoning := provider.NormalizeReasoning(req.Reasoning) | |||
| 314 | + | switch reasoning { | |||
| 315 | + | case "", "minimum", "low", "medium", "high", "xhigh", "max": | |||
| 316 | + | default: | |||
| 317 | + | s.writeError(id, cmd, "reasoning must be off|minimum|low|medium|high|xhigh|max") | |||
| 318 | + | return | |||
| 319 | + | } | |||
| 320 | + | s.agent.Reasoning = reasoning | |||
| 321 | + | s.writeResponse(id, cmd, map[string]any{"reasoning": reasoning}) | |||
| 322 | + | ||||
| 304 | case "get_models": | 323 | case "get_models": | ||
| 305 | out := []map[string]any{} | 324 | out := []map[string]any{} | ||
| 306 | for _, m := range provider.ModelsForProvider(s.provider) { | 325 | for _, m := range provider.ModelsForProvider(s.provider) { | ||
| @@ -388,6 +407,7 @@ func (s *rpcServer) snapshotState() map[string]any { | |||||
| 388 | return map[string]any{ | 407 | return map[string]any{ | ||
| 389 | "provider": s.provider, | 408 | "provider": s.provider, | ||
| 390 | "model": s.model, | 409 | "model": s.model, | ||
| 410 | + | "reasoning": s.agent.Reasoning, | |||
| 391 | "cwd": s.args.CWD, | 411 | "cwd": s.args.CWD, | ||
| 392 | "message_count": len(s.agent.Messages()), | 412 | "message_count": len(s.agent.Messages()), | ||
| 393 | "busy": s.activeCancel != nil, | 413 | "busy": s.activeCancel != nil, | ||
A packages/agent/rpc_reasoning_test.go +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/packages/core" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestRPCSetReasoningMax(t *testing.T) { | |
| 12 | + | var out bytes.Buffer | |
| 13 | + | s := &rpcServer{agent: &core.Agent{}, out: &out} | |
| 14 | + | s.dispatch("set_reasoning", "1", []byte(`{"reasoning":"max"}`)) | |
| 15 | + | ||
| 16 | + | if s.agent.Reasoning != "max" { | |
| 17 | + | t.Fatalf("reasoning = %q, want max", s.agent.Reasoning) | |
| 18 | + | } | |
| 19 | + | if !strings.Contains(out.String(), `"reasoning":"max"`) { | |
| 20 | + | t.Fatalf("response = %q", out.String()) | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestRPCSetReasoningRejectsUnknownLevel(t *testing.T) { | |
| 25 | + | var out bytes.Buffer | |
| 26 | + | s := &rpcServer{agent: &core.Agent{}, out: &out} | |
| 27 | + | s.dispatch("set_reasoning", "1", []byte(`{"reasoning":"extreme"}`)) | |
| 28 | + | ||
| 29 | + | if s.agent.Reasoning != "" { | |
| 30 | + | t.Fatalf("reasoning = %q, want unchanged", s.agent.Reasoning) | |
| 31 | + | } | |
| 32 | + | if !strings.Contains(out.String(), `"success":false`) { | |
| 33 | + | t.Fatalf("response = %q", out.String()) | |
| 34 | + | } | |
| 35 | + | } | |
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestRPCSetReasoningMax(t *testing.T) { | |||
| 12 | + | var out bytes.Buffer | |||
| 13 | + | s := &rpcServer{agent: &core.Agent{}, out: &out} | |||
| 14 | + | s.dispatch("set_reasoning", "1", []byte(`{"reasoning":"max"}`)) | |||
| 15 | + | ||||
| 16 | + | if s.agent.Reasoning != "max" { | |||
| 17 | + | t.Fatalf("reasoning = %q, want max", s.agent.Reasoning) | |||
| 18 | + | } | |||
| 19 | + | if !strings.Contains(out.String(), `"reasoning":"max"`) { | |||
| 20 | + | t.Fatalf("response = %q", out.String()) | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestRPCSetReasoningRejectsUnknownLevel(t *testing.T) { | |||
| 25 | + | var out bytes.Buffer | |||
| 26 | + | s := &rpcServer{agent: &core.Agent{}, out: &out} | |||
| 27 | + | s.dispatch("set_reasoning", "1", []byte(`{"reasoning":"extreme"}`)) | |||
| 28 | + | ||||
| 29 | + | if s.agent.Reasoning != "" { | |||
| 30 | + | t.Fatalf("reasoning = %q, want unchanged", s.agent.Reasoning) | |||
| 31 | + | } | |||
| 32 | + | if !strings.Contains(out.String(), `"success":false`) { | |||
| 33 | + | t.Fatalf("response = %q", out.String()) | |||
| 34 | + | } | |||
| 35 | + | } | |||
A packages/agent/sdk/reasoning_test.go +24 -0
| @@ -0,0 +1,24 @@ | |||
| 1 | + | package sdk | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/packages/core" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestRuntimeSetReasoningMax(t *testing.T) { | |
| 10 | + | r := &Runtime{agent: &core.Agent{}} | |
| 11 | + | if err := r.SetReasoning("max"); err != nil { | |
| 12 | + | t.Fatal(err) | |
| 13 | + | } | |
| 14 | + | if r.agent.Reasoning != "max" { | |
| 15 | + | t.Fatalf("reasoning = %q, want max", r.agent.Reasoning) | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func TestRuntimeSetReasoningRejectsUnknownLevel(t *testing.T) { | |
| 20 | + | r := &Runtime{agent: &core.Agent{}} | |
| 21 | + | if err := r.SetReasoning("extreme"); err == nil { | |
| 22 | + | t.Fatal("expected invalid reasoning error") | |
| 23 | + | } | |
| 24 | + | } | |
| @@ -0,0 +1,24 @@ | |||||
| 1 | + | package sdk | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestRuntimeSetReasoningMax(t *testing.T) { | |||
| 10 | + | r := &Runtime{agent: &core.Agent{}} | |||
| 11 | + | if err := r.SetReasoning("max"); err != nil { | |||
| 12 | + | t.Fatal(err) | |||
| 13 | + | } | |||
| 14 | + | if r.agent.Reasoning != "max" { | |||
| 15 | + | t.Fatalf("reasoning = %q, want max", r.agent.Reasoning) | |||
| 16 | + | } | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func TestRuntimeSetReasoningRejectsUnknownLevel(t *testing.T) { | |||
| 20 | + | r := &Runtime{agent: &core.Agent{}} | |||
| 21 | + | if err := r.SetReasoning("extreme"); err == nil { | |||
| 22 | + | t.Fatal("expected invalid reasoning error") | |||
| 23 | + | } | |||
| 24 | + | } | |||
M packages/agent/sdk/sdk.go +19 -1
| @@ -58,7 +58,7 @@ type Config struct { | |||
| 58 | 58 | AppendSystemPrompt []string | |
| 59 | 59 | ||
| 60 | 60 | // Reasoning sets the reasoning effort for models that support it | |
| 61 | - | // ("low", "medium", "high"). Empty = no reasoning. | |
| 61 | + | // ("minimum", "low", "medium", "high", "xhigh", "max"). Empty disables it. | |
| 62 | 62 | Reasoning string | |
| 63 | 63 | ||
| 64 | 64 | // Temperature sets the sampling temperature. Nil = provider default. | |
| @@ -296,6 +296,23 @@ func (r *Runtime) SetModel(model string) error { | |||
| 296 | 296 | return nil | |
| 297 | 297 | } | |
| 298 | 298 | ||
| 299 | + | // SetReasoning changes the thinking level used by subsequent prompts. | |
| 300 | + | func (r *Runtime) SetReasoning(level string) error { | |
| 301 | + | r.mu.Lock() | |
| 302 | + | defer r.mu.Unlock() | |
| 303 | + | if r.agent == nil { | |
| 304 | + | return fmt.Errorf("sdk: no agent") | |
| 305 | + | } | |
| 306 | + | reasoning := provider.NormalizeReasoning(level) | |
| 307 | + | switch reasoning { | |
| 308 | + | case "", "minimum", "low", "medium", "high", "xhigh", "max": | |
| 309 | + | r.agent.Reasoning = reasoning | |
| 310 | + | return nil | |
| 311 | + | default: | |
| 312 | + | return fmt.Errorf("sdk: reasoning must be off|minimum|low|medium|high|xhigh|max") | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | ||
| 299 | 316 | // State returns a snapshot of the runtime's current state. Useful for | |
| 300 | 317 | // driving UIs. | |
| 301 | 318 | func (r *Runtime) State() State { | |
| @@ -304,6 +321,7 @@ func (r *Runtime) State() State { | |||
| 304 | 321 | return State{ | |
| 305 | 322 | Provider: r.provider, | |
| 306 | 323 | Model: r.model, | |
| 324 | + | Reasoning: r.agent.Reasoning, | |
| 307 | 325 | CWD: r.cwd, | |
| 308 | 326 | Busy: r.activeCancel != nil, | |
| 309 | 327 | MessageCount: len(r.Messages()), | |
| @@ -58,7 +58,7 @@ type Config struct { | |||||
| 58 | AppendSystemPrompt []string | 58 | AppendSystemPrompt []string | ||
| 59 | 59 | ||||
| 60 | // Reasoning sets the reasoning effort for models that support it | 60 | // Reasoning sets the reasoning effort for models that support it | ||
| 61 | - | // ("low", "medium", "high"). Empty = no reasoning. | 61 | + | // ("minimum", "low", "medium", "high", "xhigh", "max"). Empty disables it. |
| 62 | Reasoning string | 62 | Reasoning string | ||
| 63 | 63 | ||||
| 64 | // Temperature sets the sampling temperature. Nil = provider default. | 64 | // Temperature sets the sampling temperature. Nil = provider default. | ||
| @@ -296,6 +296,23 @@ func (r *Runtime) SetModel(model string) error { | |||||
| 296 | return nil | 296 | return nil | ||
| 297 | } | 297 | } | ||
| 298 | 298 | ||||
| 299 | + | // SetReasoning changes the thinking level used by subsequent prompts. | |||
| 300 | + | func (r *Runtime) SetReasoning(level string) error { | |||
| 301 | + | r.mu.Lock() | |||
| 302 | + | defer r.mu.Unlock() | |||
| 303 | + | if r.agent == nil { | |||
| 304 | + | return fmt.Errorf("sdk: no agent") | |||
| 305 | + | } | |||
| 306 | + | reasoning := provider.NormalizeReasoning(level) | |||
| 307 | + | switch reasoning { | |||
| 308 | + | case "", "minimum", "low", "medium", "high", "xhigh", "max": | |||
| 309 | + | r.agent.Reasoning = reasoning | |||
| 310 | + | return nil | |||
| 311 | + | default: | |||
| 312 | + | return fmt.Errorf("sdk: reasoning must be off|minimum|low|medium|high|xhigh|max") | |||
| 313 | + | } | |||
| 314 | + | } | |||
| 315 | + | ||||
| 299 | // State returns a snapshot of the runtime's current state. Useful for | 316 | // State returns a snapshot of the runtime's current state. Useful for | ||
| 300 | // driving UIs. | 317 | // driving UIs. | ||
| 301 | func (r *Runtime) State() State { | 318 | func (r *Runtime) State() State { | ||
| @@ -304,6 +321,7 @@ func (r *Runtime) State() State { | |||||
| 304 | return State{ | 321 | return State{ | ||
| 305 | Provider: r.provider, | 322 | Provider: r.provider, | ||
| 306 | Model: r.model, | 323 | Model: r.model, | ||
| 324 | + | Reasoning: r.agent.Reasoning, | |||
| 307 | CWD: r.cwd, | 325 | CWD: r.cwd, | ||
| 308 | Busy: r.activeCancel != nil, | 326 | Busy: r.activeCancel != nil, | ||
| 309 | MessageCount: len(r.Messages()), | 327 | MessageCount: len(r.Messages()), | ||
M packages/agent/sdk/types.go +1 -0
| @@ -94,6 +94,7 @@ type Usage struct { | |||
| 94 | 94 | type State struct { | |
| 95 | 95 | Provider string `json:"provider"` | |
| 96 | 96 | Model string `json:"model"` | |
| 97 | + | Reasoning string `json:"reasoning"` | |
| 97 | 98 | CWD string `json:"cwd"` | |
| 98 | 99 | Busy bool `json:"busy"` | |
| 99 | 100 | MessageCount int `json:"message_count"` | |
| @@ -94,6 +94,7 @@ type Usage struct { | |||||
| 94 | type State struct { | 94 | type State struct { | ||
| 95 | Provider string `json:"provider"` | 95 | Provider string `json:"provider"` | ||
| 96 | Model string `json:"model"` | 96 | Model string `json:"model"` | ||
| 97 | + | Reasoning string `json:"reasoning"` | |||
| 97 | CWD string `json:"cwd"` | 98 | CWD string `json:"cwd"` | ||
| 98 | Busy bool `json:"busy"` | 99 | Busy bool `json:"busy"` | ||
| 99 | MessageCount int `json:"message_count"` | 100 | MessageCount int `json:"message_count"` | ||
M packages/core/session.go +99 -6
| @@ -59,6 +59,9 @@ type SessionMeta struct { | |||
| 59 | 59 | // are copied from the parent verbatim; the user's next turn on | |
| 60 | 60 | // the child session continues from there. | |
| 61 | 61 | ForkPoint int `json:"fork_point,omitempty"` | |
| 62 | + | // HideFromSessions hides internal tree-navigation branches from the | |
| 63 | + | // flat /sessions picker while keeping them available to /session tree. | |
| 64 | + | HideFromSessions bool `json:"hide_from_sessions,omitempty"` | |
| 62 | 65 | } | |
| 63 | 66 | ||
| 64 | 67 | // sessionLine is the on-disk row type. Message is kept as a raw | |
| @@ -389,6 +392,7 @@ type SessionSummary struct { | |||
| 389 | 392 | FirstUserText string | |
| 390 | 393 | TotalCost float64 | |
| 391 | 394 | Title string | |
| 395 | + | BranchDepth int | |
| 392 | 396 | } | |
| 393 | 397 | ||
| 394 | 398 | // RenameSession updates the title field in the session's meta line. | |
| @@ -409,18 +413,59 @@ func RenameSession(path, title string) error { | |||
| 409 | 413 | return err | |
| 410 | 414 | } | |
| 411 | 415 | ||
| 416 | + | // DeleteSession removes a session file. It refuses an empty path so | |
| 417 | + | // callers do not accidentally delete their cwd or another implicit target. | |
| 418 | + | func DeleteSession(path string) error { | |
| 419 | + | if strings.TrimSpace(path) == "" { | |
| 420 | + | return fmt.Errorf("session path is empty") | |
| 421 | + | } | |
| 422 | + | return os.Remove(path) | |
| 423 | + | } | |
| 424 | + | ||
| 412 | 425 | // DescribeSessions returns lightweight summaries for every session in | |
| 413 | 426 | // cwd, newest first. Parses only the first few lines and the last usage | |
| 414 | 427 | // line so it's cheap to run on every dialog open. | |
| 415 | 428 | func DescribeSessions(root, cwd string) []SessionSummary { | |
| 416 | 429 | paths := ListSessions(root, cwd) | |
| 417 | 430 | summaries := make([]SessionSummary, 0, len(paths)) | |
| 431 | + | metas := make(map[string]SessionMeta, len(paths)) | |
| 432 | + | idToPath := make(map[string]string, len(paths)) | |
| 433 | + | for _, p := range paths { | |
| 434 | + | if meta, err := readSessionMeta(p); err == nil && meta.ID != "" { | |
| 435 | + | metas[p] = meta | |
| 436 | + | idToPath[meta.ID] = p | |
| 437 | + | } | |
| 438 | + | } | |
| 418 | 439 | for _, p := range paths { | |
| 440 | + | if meta := metas[p]; meta.HideFromSessions { | |
| 441 | + | continue | |
| 442 | + | } | |
| 419 | 443 | summaries = append(summaries, describeSession(p)) | |
| 420 | 444 | } | |
| 445 | + | for idx := range summaries { | |
| 446 | + | summaries[idx].BranchDepth = sessionBranchDepth(summaries[idx].Path, metas, idToPath) | |
| 447 | + | } | |
| 421 | 448 | return summaries | |
| 422 | 449 | } | |
| 423 | 450 | ||
| 451 | + | func sessionBranchDepth(path string, metas map[string]SessionMeta, idToPath map[string]string) int { | |
| 452 | + | depth := 0 | |
| 453 | + | seen := map[string]bool{} | |
| 454 | + | for { | |
| 455 | + | meta, ok := metas[path] | |
| 456 | + | if !ok || meta.Parent == "" || seen[meta.ID] { | |
| 457 | + | return depth | |
| 458 | + | } | |
| 459 | + | seen[meta.ID] = true | |
| 460 | + | parentPath := idToPath[meta.Parent] | |
| 461 | + | if parentPath == "" { | |
| 462 | + | return depth | |
| 463 | + | } | |
| 464 | + | depth++ | |
| 465 | + | path = parentPath | |
| 466 | + | } | |
| 467 | + | } | |
| 468 | + | ||
| 424 | 469 | func describeSession(path string) SessionSummary { | |
| 425 | 470 | s := SessionSummary{Path: path} | |
| 426 | 471 | f, err := os.Open(path) | |
| @@ -428,6 +473,12 @@ func describeSession(path string) SessionSummary { | |||
| 428 | 473 | return s | |
| 429 | 474 | } | |
| 430 | 475 | defer f.Close() | |
| 476 | + | isBranch := false | |
| 477 | + | forkPoint := 0 | |
| 478 | + | messageIdx := 0 | |
| 479 | + | branchPrompt := "" | |
| 480 | + | lastUser := "" | |
| 481 | + | renameMessageIdx := -1 | |
| 431 | 482 | _ = forEachJSONLLine(f, func(line []byte) error { | |
| 432 | 483 | var head sessionLineHead | |
| 433 | 484 | if err := json.Unmarshal(line, &head); err != nil { | |
| @@ -443,18 +494,28 @@ func describeSession(path string) SessionSummary { | |||
| 443 | 494 | s.Model = row.Meta.Model | |
| 444 | 495 | s.Provider = row.Meta.Provider | |
| 445 | 496 | s.Title = row.Meta.Title | |
| 497 | + | isBranch = row.Meta.Parent != "" | |
| 498 | + | forkPoint = row.Meta.ForkPoint | |
| 446 | 499 | } | |
| 447 | 500 | case "message": | |
| 448 | - | s.MessageCount++ | |
| 449 | - | if s.FirstUserText == "" { | |
| 450 | - | s.FirstUserText = firstUserText(line) | |
| 501 | + | if text := firstUserText(line); text != "" { | |
| 502 | + | lastUser = text | |
| 503 | + | if s.FirstUserText == "" { | |
| 504 | + | s.FirstUserText = text | |
| 505 | + | } | |
| 506 | + | if isBranch && messageIdx >= forkPoint && branchPrompt == "" { | |
| 507 | + | branchPrompt = text | |
| 508 | + | } | |
| 451 | 509 | } | |
| 510 | + | messageIdx++ | |
| 511 | + | s.MessageCount++ | |
| 452 | 512 | case "compaction": | |
| 453 | 513 | if compacted, err := hydrateCompaction(line); err == nil { | |
| 454 | 514 | s.MessageCount = len(compacted) | |
| 455 | - | if s.FirstUserText == "" && len(compacted) > 0 { | |
| 456 | - | s.FirstUserText = firstTextFromMessage(compacted[0]) | |
| 457 | - | } | |
| 515 | + | messageIdx = len(compacted) | |
| 516 | + | s.FirstUserText = firstUserTextFromMessages(compacted) | |
| 517 | + | lastUser = lastUserText(compacted) | |
| 518 | + | branchPrompt = firstUserTextFromMessagesAfter(compacted, forkPoint) | |
| 458 | 519 | } | |
| 459 | 520 | case "rename": | |
| 460 | 521 | var row struct { | |
| @@ -462,6 +523,7 @@ func describeSession(path string) SessionSummary { | |||
| 462 | 523 | } | |
| 463 | 524 | if err := json.Unmarshal(line, &row); err == nil && row.Title != "" { | |
| 464 | 525 | s.Title = row.Title | |
| 526 | + | renameMessageIdx = messageIdx | |
| 465 | 527 | } | |
| 466 | 528 | case "usage": | |
| 467 | 529 | var row struct { | |
| @@ -473,6 +535,18 @@ func describeSession(path string) SessionSummary { | |||
| 473 | 535 | } | |
| 474 | 536 | return nil | |
| 475 | 537 | }) | |
| 538 | + | if isBranch { | |
| 539 | + | // A rename written after the branch has diverged is user intent | |
| 540 | + | // and must win. Older generated branch titles were written at | |
| 541 | + | // fork creation time, before any post-fork prompt, so those are | |
| 542 | + | // allowed to be replaced by the branch prompt fallback. | |
| 543 | + | userRenamedAfterFork := renameMessageIdx > forkPoint | |
| 544 | + | if !userRenamedAfterFork && branchPrompt != "" { | |
| 545 | + | s.Title = branchPrompt | |
| 546 | + | } else if s.Title == "" && lastUser != "" { | |
| 547 | + | s.Title = lastUser | |
| 548 | + | } | |
| 549 | + | } | |
| 476 | 550 | return s | |
| 477 | 551 | } | |
| 478 | 552 | ||
| @@ -499,6 +573,25 @@ func firstUserText(line []byte) string { | |||
| 499 | 573 | return "" | |
| 500 | 574 | } | |
| 501 | 575 | ||
| 576 | + | func firstUserTextFromMessages(msgs []provider.Message) string { | |
| 577 | + | return firstUserTextFromMessagesAfter(msgs, 0) | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | func firstUserTextFromMessagesAfter(msgs []provider.Message, start int) string { | |
| 581 | + | if start < 0 { | |
| 582 | + | start = 0 | |
| 583 | + | } | |
| 584 | + | for idx, msg := range msgs { | |
| 585 | + | if idx < start || msg.Role != provider.RoleUser { | |
| 586 | + | continue | |
| 587 | + | } | |
| 588 | + | if text := firstTextFromMessage(msg); text != "" { | |
| 589 | + | return text | |
| 590 | + | } | |
| 591 | + | } | |
| 592 | + | return "" | |
| 593 | + | } | |
| 594 | + | ||
| 502 | 595 | func firstTextFromMessage(msg provider.Message) string { | |
| 503 | 596 | for _, c := range msg.Content { | |
| 504 | 597 | if tb, ok := c.(provider.TextBlock); ok && tb.Text != "" { | |
| @@ -59,6 +59,9 @@ type SessionMeta struct { | |||||
| 59 | // are copied from the parent verbatim; the user's next turn on | 59 | // are copied from the parent verbatim; the user's next turn on | ||
| 60 | // the child session continues from there. | 60 | // the child session continues from there. | ||
| 61 | ForkPoint int `json:"fork_point,omitempty"` | 61 | ForkPoint int `json:"fork_point,omitempty"` | ||
| 62 | + | // HideFromSessions hides internal tree-navigation branches from the | |||
| 63 | + | // flat /sessions picker while keeping them available to /session tree. | |||
| 64 | + | HideFromSessions bool `json:"hide_from_sessions,omitempty"` | |||
| 62 | } | 65 | } | ||
| 63 | 66 | ||||
| 64 | // sessionLine is the on-disk row type. Message is kept as a raw | 67 | // sessionLine is the on-disk row type. Message is kept as a raw | ||
| @@ -389,6 +392,7 @@ type SessionSummary struct { | |||||
| 389 | FirstUserText string | 392 | FirstUserText string | ||
| 390 | TotalCost float64 | 393 | TotalCost float64 | ||
| 391 | Title string | 394 | Title string | ||
| 395 | + | BranchDepth int | |||
| 392 | } | 396 | } | ||
| 393 | 397 | ||||
| 394 | // RenameSession updates the title field in the session's meta line. | 398 | // RenameSession updates the title field in the session's meta line. | ||
| @@ -409,18 +413,59 @@ func RenameSession(path, title string) error { | |||||
| 409 | return err | 413 | return err | ||
| 410 | } | 414 | } | ||
| 411 | 415 | ||||
| 416 | + | // DeleteSession removes a session file. It refuses an empty path so | |||
| 417 | + | // callers do not accidentally delete their cwd or another implicit target. | |||
| 418 | + | func DeleteSession(path string) error { | |||
| 419 | + | if strings.TrimSpace(path) == "" { | |||
| 420 | + | return fmt.Errorf("session path is empty") | |||
| 421 | + | } | |||
| 422 | + | return os.Remove(path) | |||
| 423 | + | } | |||
| 424 | + | ||||
| 412 | // DescribeSessions returns lightweight summaries for every session in | 425 | // DescribeSessions returns lightweight summaries for every session in | ||
| 413 | // cwd, newest first. Parses only the first few lines and the last usage | 426 | // cwd, newest first. Parses only the first few lines and the last usage | ||
| 414 | // line so it's cheap to run on every dialog open. | 427 | // line so it's cheap to run on every dialog open. | ||
| 415 | func DescribeSessions(root, cwd string) []SessionSummary { | 428 | func DescribeSessions(root, cwd string) []SessionSummary { | ||
| 416 | paths := ListSessions(root, cwd) | 429 | paths := ListSessions(root, cwd) | ||
| 417 | summaries := make([]SessionSummary, 0, len(paths)) | 430 | summaries := make([]SessionSummary, 0, len(paths)) | ||
| 431 | + | metas := make(map[string]SessionMeta, len(paths)) | |||
| 432 | + | idToPath := make(map[string]string, len(paths)) | |||
| 433 | + | for _, p := range paths { | |||
| 434 | + | if meta, err := readSessionMeta(p); err == nil && meta.ID != "" { | |||
| 435 | + | metas[p] = meta | |||
| 436 | + | idToPath[meta.ID] = p | |||
| 437 | + | } | |||
| 438 | + | } | |||
| 418 | for _, p := range paths { | 439 | for _, p := range paths { | ||
| 440 | + | if meta := metas[p]; meta.HideFromSessions { | |||
| 441 | + | continue | |||
| 442 | + | } | |||
| 419 | summaries = append(summaries, describeSession(p)) | 443 | summaries = append(summaries, describeSession(p)) | ||
| 420 | } | 444 | } | ||
| 445 | + | for idx := range summaries { | |||
| 446 | + | summaries[idx].BranchDepth = sessionBranchDepth(summaries[idx].Path, metas, idToPath) | |||
| 447 | + | } | |||
| 421 | return summaries | 448 | return summaries | ||
| 422 | } | 449 | } | ||
| 423 | 450 | ||||
| 451 | + | func sessionBranchDepth(path string, metas map[string]SessionMeta, idToPath map[string]string) int { | |||
| 452 | + | depth := 0 | |||
| 453 | + | seen := map[string]bool{} | |||
| 454 | + | for { | |||
| 455 | + | meta, ok := metas[path] | |||
| 456 | + | if !ok || meta.Parent == "" || seen[meta.ID] { | |||
| 457 | + | return depth | |||
| 458 | + | } | |||
| 459 | + | seen[meta.ID] = true | |||
| 460 | + | parentPath := idToPath[meta.Parent] | |||
| 461 | + | if parentPath == "" { | |||
| 462 | + | return depth | |||
| 463 | + | } | |||
| 464 | + | depth++ | |||
| 465 | + | path = parentPath | |||
| 466 | + | } | |||
| 467 | + | } | |||
| 468 | + | ||||
| 424 | func describeSession(path string) SessionSummary { | 469 | func describeSession(path string) SessionSummary { | ||
| 425 | s := SessionSummary{Path: path} | 470 | s := SessionSummary{Path: path} | ||
| 426 | f, err := os.Open(path) | 471 | f, err := os.Open(path) | ||
| @@ -428,6 +473,12 @@ func describeSession(path string) SessionSummary { | |||||
| 428 | return s | 473 | return s | ||
| 429 | } | 474 | } | ||
| 430 | defer f.Close() | 475 | defer f.Close() | ||
| 476 | + | isBranch := false | |||
| 477 | + | forkPoint := 0 | |||
| 478 | + | messageIdx := 0 | |||
| 479 | + | branchPrompt := "" | |||
| 480 | + | lastUser := "" | |||
| 481 | + | renameMessageIdx := -1 | |||
| 431 | _ = forEachJSONLLine(f, func(line []byte) error { | 482 | _ = forEachJSONLLine(f, func(line []byte) error { | ||
| 432 | var head sessionLineHead | 483 | var head sessionLineHead | ||
| 433 | if err := json.Unmarshal(line, &head); err != nil { | 484 | if err := json.Unmarshal(line, &head); err != nil { | ||
| @@ -443,18 +494,28 @@ func describeSession(path string) SessionSummary { | |||||
| 443 | s.Model = row.Meta.Model | 494 | s.Model = row.Meta.Model | ||
| 444 | s.Provider = row.Meta.Provider | 495 | s.Provider = row.Meta.Provider | ||
| 445 | s.Title = row.Meta.Title | 496 | s.Title = row.Meta.Title | ||
| 497 | + | isBranch = row.Meta.Parent != "" | |||
| 498 | + | forkPoint = row.Meta.ForkPoint | |||
| 446 | } | 499 | } | ||
| 447 | case "message": | 500 | case "message": | ||
| 448 | - | s.MessageCount++ | 501 | + | if text := firstUserText(line); text != "" { |
| 449 | - | if s.FirstUserText == "" { | 502 | + | lastUser = text |
| 450 | - | s.FirstUserText = firstUserText(line) | 503 | + | if s.FirstUserText == "" { |
| 504 | + | s.FirstUserText = text | |||
| 505 | + | } | |||
| 506 | + | if isBranch && messageIdx >= forkPoint && branchPrompt == "" { | |||
| 507 | + | branchPrompt = text | |||
| 508 | + | } | |||
| 451 | } | 509 | } | ||
| 510 | + | messageIdx++ | |||
| 511 | + | s.MessageCount++ | |||
| 452 | case "compaction": | 512 | case "compaction": | ||
| 453 | if compacted, err := hydrateCompaction(line); err == nil { | 513 | if compacted, err := hydrateCompaction(line); err == nil { | ||
| 454 | s.MessageCount = len(compacted) | 514 | s.MessageCount = len(compacted) | ||
| 455 | - | if s.FirstUserText == "" && len(compacted) > 0 { | 515 | + | messageIdx = len(compacted) |
| 456 | - | s.FirstUserText = firstTextFromMessage(compacted[0]) | 516 | + | s.FirstUserText = firstUserTextFromMessages(compacted) |
| 457 | - | } | 517 | + | lastUser = lastUserText(compacted) |
| 518 | + | branchPrompt = firstUserTextFromMessagesAfter(compacted, forkPoint) | |||
| 458 | } | 519 | } | ||
| 459 | case "rename": | 520 | case "rename": | ||
| 460 | var row struct { | 521 | var row struct { | ||
| @@ -462,6 +523,7 @@ func describeSession(path string) SessionSummary { | |||||
| 462 | } | 523 | } | ||
| 463 | if err := json.Unmarshal(line, &row); err == nil && row.Title != "" { | 524 | if err := json.Unmarshal(line, &row); err == nil && row.Title != "" { | ||
| 464 | s.Title = row.Title | 525 | s.Title = row.Title | ||
| 526 | + | renameMessageIdx = messageIdx | |||
| 465 | } | 527 | } | ||
| 466 | case "usage": | 528 | case "usage": | ||
| 467 | var row struct { | 529 | var row struct { | ||
| @@ -473,6 +535,18 @@ func describeSession(path string) SessionSummary { | |||||
| 473 | } | 535 | } | ||
| 474 | return nil | 536 | return nil | ||
| 475 | }) | 537 | }) | ||
| 538 | + | if isBranch { | |||
| 539 | + | // A rename written after the branch has diverged is user intent | |||
| 540 | + | // and must win. Older generated branch titles were written at | |||
| 541 | + | // fork creation time, before any post-fork prompt, so those are | |||
| 542 | + | // allowed to be replaced by the branch prompt fallback. | |||
| 543 | + | userRenamedAfterFork := renameMessageIdx > forkPoint | |||
| 544 | + | if !userRenamedAfterFork && branchPrompt != "" { | |||
| 545 | + | s.Title = branchPrompt | |||
| 546 | + | } else if s.Title == "" && lastUser != "" { | |||
| 547 | + | s.Title = lastUser | |||
| 548 | + | } | |||
| 549 | + | } | |||
| 476 | return s | 550 | return s | ||
| 477 | } | 551 | } | ||
| 478 | 552 | ||||
| @@ -499,6 +573,25 @@ func firstUserText(line []byte) string { | |||||
| 499 | return "" | 573 | return "" | ||
| 500 | } | 574 | } | ||
| 501 | 575 | ||||
| 576 | + | func firstUserTextFromMessages(msgs []provider.Message) string { | |||
| 577 | + | return firstUserTextFromMessagesAfter(msgs, 0) | |||
| 578 | + | } | |||
| 579 | + | ||||
| 580 | + | func firstUserTextFromMessagesAfter(msgs []provider.Message, start int) string { | |||
| 581 | + | if start < 0 { | |||
| 582 | + | start = 0 | |||
| 583 | + | } | |||
| 584 | + | for idx, msg := range msgs { | |||
| 585 | + | if idx < start || msg.Role != provider.RoleUser { | |||
| 586 | + | continue | |||
| 587 | + | } | |||
| 588 | + | if text := firstTextFromMessage(msg); text != "" { | |||
| 589 | + | return text | |||
| 590 | + | } | |||
| 591 | + | } | |||
| 592 | + | return "" | |||
| 593 | + | } | |||
| 594 | + | ||||
| 502 | func firstTextFromMessage(msg provider.Message) string { | 595 | func firstTextFromMessage(msg provider.Message) string { | ||
| 503 | for _, c := range msg.Content { | 596 | for _, c := range msg.Content { | ||
| 504 | if tb, ok := c.(provider.TextBlock); ok && tb.Text != "" { | 597 | if tb, ok := c.(provider.TextBlock); ok && tb.Text != "" { | ||
M packages/core/session_portable.go +45 -12
| @@ -256,6 +256,16 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) { | |||
| 256 | 256 | // | |
| 257 | 257 | // Returns the path of the new session file, ready for OpenSession. | |
| 258 | 258 | func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) { | |
| 259 | + | return branchSession(parentPath, root, cwd, version, upToMessageIdx, false) | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | // BranchSessionHidden creates a branch that participates in /session tree but | |
| 263 | + | // is hidden from the flat /sessions picker. Used for in-place tree navigation. | |
| 264 | + | func BranchSessionHidden(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) { | |
| 265 | + | return branchSession(parentPath, root, cwd, version, upToMessageIdx, true) | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hideFromSessions bool) (string, error) { | |
| 259 | 269 | if parentPath == "" { | |
| 260 | 270 | return "", errors.New("branch: parent path is empty") | |
| 261 | 271 | } | |
| @@ -302,14 +312,15 @@ func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (s | |||
| 302 | 312 | ||
| 303 | 313 | // Write the branch meta. | |
| 304 | 314 | branchMeta := SessionMeta{ | |
| 305 | - | ID: newID, | |
| 306 | - | CWD: cwd, | |
| 307 | - | Model: parentMeta.Model, | |
| 308 | - | Provider: parentMeta.Provider, | |
| 309 | - | Started: time.Now().UTC(), | |
| 310 | - | Version: version, | |
| 311 | - | Parent: parentMeta.ID, | |
| 312 | - | ForkPoint: upToMessageIdx, | |
| 315 | + | ID: newID, | |
| 316 | + | CWD: cwd, | |
| 317 | + | Model: parentMeta.Model, | |
| 318 | + | Provider: parentMeta.Provider, | |
| 319 | + | Started: time.Now().UTC(), | |
| 320 | + | Version: version, | |
| 321 | + | Parent: parentMeta.ID, | |
| 322 | + | ForkPoint: upToMessageIdx, | |
| 323 | + | HideFromSessions: hideFromSessions, | |
| 313 | 324 | } | |
| 314 | 325 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta}) | |
| 315 | 326 | if err != nil { | |
| @@ -364,11 +375,12 @@ func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (s | |||
| 364 | 375 | }); err != nil && err != io.EOF { | |
| 365 | 376 | return "", fmt.Errorf("branch: read parent: %w", err) | |
| 366 | 377 | } | |
| 378 | + | limit := upToMessageIdx | |
| 379 | + | if limit > len(effective) { | |
| 380 | + | limit = len(effective) | |
| 381 | + | } | |
| 382 | + | branchTitle := lastUserText(effective[:limit]) | |
| 367 | 383 | if sawCompaction { | |
| 368 | - | limit := upToMessageIdx | |
| 369 | - | if limit > len(effective) { | |
| 370 | - | limit = len(effective) | |
| 371 | - | } | |
| 372 | 384 | for i := 0; i < limit; i++ { | |
| 373 | 385 | msg := effective[i] | |
| 374 | 386 | line, err := json.Marshal(sessionLine{Type: "message", Message: &msg}) | |
| @@ -392,12 +404,33 @@ func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (s | |||
| 392 | 404 | } | |
| 393 | 405 | } | |
| 394 | 406 | } | |
| 407 | + | if branchTitle != "" { | |
| 408 | + | line, _ := json.Marshal(map[string]string{"type": "rename", "title": branchTitle}) | |
| 409 | + | if _, err := bw.Write(line); err != nil { | |
| 410 | + | return "", err | |
| 411 | + | } | |
| 412 | + | if err := bw.WriteByte('\n'); err != nil { | |
| 413 | + | return "", err | |
| 414 | + | } | |
| 415 | + | } | |
| 395 | 416 | if err := bw.Flush(); err != nil { | |
| 396 | 417 | return "", err | |
| 397 | 418 | } | |
| 398 | 419 | return outPath, nil | |
| 399 | 420 | } | |
| 400 | 421 | ||
| 422 | + | func lastUserText(msgs []provider.Message) string { | |
| 423 | + | for i := len(msgs) - 1; i >= 0; i-- { | |
| 424 | + | if msgs[i].Role != provider.RoleUser { | |
| 425 | + | continue | |
| 426 | + | } | |
| 427 | + | if text := strings.TrimSpace(firstTextFromMessage(msgs[i])); text != "" { | |
| 428 | + | return text | |
| 429 | + | } | |
| 430 | + | } | |
| 431 | + | return "" | |
| 432 | + | } | |
| 433 | + | ||
| 401 | 434 | // TreeNode is one entry in the branch tree returned by | |
| 402 | 435 | // BuildSessionTree. Children are populated by linking on Parent ID. | |
| 403 | 436 | type TreeNode struct { | |
| @@ -256,6 +256,16 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) { | |||||
| 256 | // | 256 | // | ||
| 257 | // Returns the path of the new session file, ready for OpenSession. | 257 | // Returns the path of the new session file, ready for OpenSession. | ||
| 258 | func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) { | 258 | func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) { | ||
| 259 | + | return branchSession(parentPath, root, cwd, version, upToMessageIdx, false) | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | // BranchSessionHidden creates a branch that participates in /session tree but | |||
| 263 | + | // is hidden from the flat /sessions picker. Used for in-place tree navigation. | |||
| 264 | + | func BranchSessionHidden(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) { | |||
| 265 | + | return branchSession(parentPath, root, cwd, version, upToMessageIdx, true) | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hideFromSessions bool) (string, error) { | |||
| 259 | if parentPath == "" { | 269 | if parentPath == "" { | ||
| 260 | return "", errors.New("branch: parent path is empty") | 270 | return "", errors.New("branch: parent path is empty") | ||
| 261 | } | 271 | } | ||
| @@ -302,14 +312,15 @@ func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (s | |||||
| 302 | 312 | ||||
| 303 | // Write the branch meta. | 313 | // Write the branch meta. | ||
| 304 | branchMeta := SessionMeta{ | 314 | branchMeta := SessionMeta{ | ||
| 305 | - | ID: newID, | 315 | + | ID: newID, |
| 306 | - | CWD: cwd, | 316 | + | CWD: cwd, |
| 307 | - | Model: parentMeta.Model, | 317 | + | Model: parentMeta.Model, |
| 308 | - | Provider: parentMeta.Provider, | 318 | + | Provider: parentMeta.Provider, |
| 309 | - | Started: time.Now().UTC(), | 319 | + | Started: time.Now().UTC(), |
| 310 | - | Version: version, | 320 | + | Version: version, |
| 311 | - | Parent: parentMeta.ID, | 321 | + | Parent: parentMeta.ID, |
| 312 | - | ForkPoint: upToMessageIdx, | 322 | + | ForkPoint: upToMessageIdx, |
| 323 | + | HideFromSessions: hideFromSessions, | |||
| 313 | } | 324 | } | ||
| 314 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta}) | 325 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta}) | ||
| 315 | if err != nil { | 326 | if err != nil { | ||
| @@ -364,11 +375,12 @@ func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (s | |||||
| 364 | }); err != nil && err != io.EOF { | 375 | }); err != nil && err != io.EOF { | ||
| 365 | return "", fmt.Errorf("branch: read parent: %w", err) | 376 | return "", fmt.Errorf("branch: read parent: %w", err) | ||
| 366 | } | 377 | } | ||
| 378 | + | limit := upToMessageIdx | |||
| 379 | + | if limit > len(effective) { | |||
| 380 | + | limit = len(effective) | |||
| 381 | + | } | |||
| 382 | + | branchTitle := lastUserText(effective[:limit]) | |||
| 367 | if sawCompaction { | 383 | if sawCompaction { | ||
| 368 | - | limit := upToMessageIdx | |||
| 369 | - | if limit > len(effective) { | |||
| 370 | - | limit = len(effective) | |||
| 371 | - | } | |||
| 372 | for i := 0; i < limit; i++ { | 384 | for i := 0; i < limit; i++ { | ||
| 373 | msg := effective[i] | 385 | msg := effective[i] | ||
| 374 | line, err := json.Marshal(sessionLine{Type: "message", Message: &msg}) | 386 | line, err := json.Marshal(sessionLine{Type: "message", Message: &msg}) | ||
| @@ -392,12 +404,33 @@ func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (s | |||||
| 392 | } | 404 | } | ||
| 393 | } | 405 | } | ||
| 394 | } | 406 | } | ||
| 407 | + | if branchTitle != "" { | |||
| 408 | + | line, _ := json.Marshal(map[string]string{"type": "rename", "title": branchTitle}) | |||
| 409 | + | if _, err := bw.Write(line); err != nil { | |||
| 410 | + | return "", err | |||
| 411 | + | } | |||
| 412 | + | if err := bw.WriteByte('\n'); err != nil { | |||
| 413 | + | return "", err | |||
| 414 | + | } | |||
| 415 | + | } | |||
| 395 | if err := bw.Flush(); err != nil { | 416 | if err := bw.Flush(); err != nil { | ||
| 396 | return "", err | 417 | return "", err | ||
| 397 | } | 418 | } | ||
| 398 | return outPath, nil | 419 | return outPath, nil | ||
| 399 | } | 420 | } | ||
| 400 | 421 | ||||
| 422 | + | func lastUserText(msgs []provider.Message) string { | |||
| 423 | + | for i := len(msgs) - 1; i >= 0; i-- { | |||
| 424 | + | if msgs[i].Role != provider.RoleUser { | |||
| 425 | + | continue | |||
| 426 | + | } | |||
| 427 | + | if text := strings.TrimSpace(firstTextFromMessage(msgs[i])); text != "" { | |||
| 428 | + | return text | |||
| 429 | + | } | |||
| 430 | + | } | |||
| 431 | + | return "" | |||
| 432 | + | } | |||
| 433 | + | ||||
| 401 | // TreeNode is one entry in the branch tree returned by | 434 | // TreeNode is one entry in the branch tree returned by | ||
| 402 | // BuildSessionTree. Children are populated by linking on Parent ID. | 435 | // BuildSessionTree. Children are populated by linking on Parent ID. | ||
| 403 | type TreeNode struct { | 436 | type TreeNode struct { | ||
M packages/core/session_portable_test.go +87 -0
| @@ -229,6 +229,93 @@ func TestBranchSessionCopiesPrefix(t *testing.T) { | |||
| 229 | 229 | if branch.Meta.ID == parent.Meta.ID { | |
| 230 | 230 | t.Errorf("branch kept parent id; must rotate") | |
| 231 | 231 | } | |
| 232 | + | if branch.Meta.Title != "" { | |
| 233 | + | t.Errorf("meta title should remain append-only, got %q", branch.Meta.Title) | |
| 234 | + | } | |
| 235 | + | if summary := describeSession(branchPath); summary.Title != "first" { | |
| 236 | + | t.Errorf("branch title: want %q, got %q", "first", summary.Title) | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func TestBranchSessionTitleUsesSelectedUserPrompt(t *testing.T) { | |
| 241 | + | root := t.TempDir() | |
| 242 | + | cwd := "/project" | |
| 243 | + | parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test") | |
| 244 | + | if err != nil { | |
| 245 | + | t.Fatal(err) | |
| 246 | + | } | |
| 247 | + | for _, msg := range []provider.Message{ | |
| 248 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first prompt"}}}, | |
| 249 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first reply"}}}, | |
| 250 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second prompt"}}}, | |
| 251 | + | } { | |
| 252 | + | _ = parent.AppendMessage(msg) | |
| 253 | + | } | |
| 254 | + | _ = parent.Close() | |
| 255 | + | ||
| 256 | + | branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 3) | |
| 257 | + | if err != nil { | |
| 258 | + | t.Fatalf("BranchSession: %v", err) | |
| 259 | + | } | |
| 260 | + | if summary := describeSession(branchPath); summary.Title != "second prompt" { | |
| 261 | + | t.Errorf("branch title: want %q, got %q", "second prompt", summary.Title) | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func TestBranchSessionTitleUsesFirstPromptAfterForkWhenBranchDiverges(t *testing.T) { | |
| 266 | + | root := t.TempDir() | |
| 267 | + | cwd := "/project" | |
| 268 | + | parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test") | |
| 269 | + | if err != nil { | |
| 270 | + | t.Fatal(err) | |
| 271 | + | } | |
| 272 | + | _ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}) | |
| 273 | + | _ = parent.Close() | |
| 274 | + | ||
| 275 | + | branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1) | |
| 276 | + | if err != nil { | |
| 277 | + | t.Fatalf("BranchSession: %v", err) | |
| 278 | + | } | |
| 279 | + | branch, _, err := OpenSession(branchPath) | |
| 280 | + | if err != nil { | |
| 281 | + | t.Fatal(err) | |
| 282 | + | } | |
| 283 | + | _ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}}) | |
| 284 | + | _ = branch.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "fine"}}}) | |
| 285 | + | _ = branch.Close() | |
| 286 | + | ||
| 287 | + | if summary := describeSession(branchPath); summary.Title != "how are you?" { | |
| 288 | + | t.Errorf("branch title: want %q, got %q", "how are you?", summary.Title) | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func TestRenamedBranchKeepsExplicitTitle(t *testing.T) { | |
| 293 | + | root := t.TempDir() | |
| 294 | + | cwd := "/project" | |
| 295 | + | parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test") | |
| 296 | + | if err != nil { | |
| 297 | + | t.Fatal(err) | |
| 298 | + | } | |
| 299 | + | _ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}) | |
| 300 | + | _ = parent.Close() | |
| 301 | + | ||
| 302 | + | branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1) | |
| 303 | + | if err != nil { | |
| 304 | + | t.Fatalf("BranchSession: %v", err) | |
| 305 | + | } | |
| 306 | + | branch, _, err := OpenSession(branchPath) | |
| 307 | + | if err != nil { | |
| 308 | + | t.Fatal(err) | |
| 309 | + | } | |
| 310 | + | _ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}}) | |
| 311 | + | _ = branch.Close() | |
| 312 | + | if err := RenameSession(branchPath, "friendly branch name"); err != nil { | |
| 313 | + | t.Fatal(err) | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | if summary := describeSession(branchPath); summary.Title != "friendly branch name" { | |
| 317 | + | t.Errorf("branch title: want %q, got %q", "friendly branch name", summary.Title) | |
| 318 | + | } | |
| 232 | 319 | } | |
| 233 | 320 | ||
| 234 | 321 | func TestBranchSessionUsesEffectiveTranscriptAfterCompaction(t *testing.T) { | |
| @@ -229,6 +229,93 @@ func TestBranchSessionCopiesPrefix(t *testing.T) { | |||||
| 229 | if branch.Meta.ID == parent.Meta.ID { | 229 | if branch.Meta.ID == parent.Meta.ID { | ||
| 230 | t.Errorf("branch kept parent id; must rotate") | 230 | t.Errorf("branch kept parent id; must rotate") | ||
| 231 | } | 231 | } | ||
| 232 | + | if branch.Meta.Title != "" { | |||
| 233 | + | t.Errorf("meta title should remain append-only, got %q", branch.Meta.Title) | |||
| 234 | + | } | |||
| 235 | + | if summary := describeSession(branchPath); summary.Title != "first" { | |||
| 236 | + | t.Errorf("branch title: want %q, got %q", "first", summary.Title) | |||
| 237 | + | } | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | func TestBranchSessionTitleUsesSelectedUserPrompt(t *testing.T) { | |||
| 241 | + | root := t.TempDir() | |||
| 242 | + | cwd := "/project" | |||
| 243 | + | parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test") | |||
| 244 | + | if err != nil { | |||
| 245 | + | t.Fatal(err) | |||
| 246 | + | } | |||
| 247 | + | for _, msg := range []provider.Message{ | |||
| 248 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first prompt"}}}, | |||
| 249 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first reply"}}}, | |||
| 250 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second prompt"}}}, | |||
| 251 | + | } { | |||
| 252 | + | _ = parent.AppendMessage(msg) | |||
| 253 | + | } | |||
| 254 | + | _ = parent.Close() | |||
| 255 | + | ||||
| 256 | + | branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 3) | |||
| 257 | + | if err != nil { | |||
| 258 | + | t.Fatalf("BranchSession: %v", err) | |||
| 259 | + | } | |||
| 260 | + | if summary := describeSession(branchPath); summary.Title != "second prompt" { | |||
| 261 | + | t.Errorf("branch title: want %q, got %q", "second prompt", summary.Title) | |||
| 262 | + | } | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | func TestBranchSessionTitleUsesFirstPromptAfterForkWhenBranchDiverges(t *testing.T) { | |||
| 266 | + | root := t.TempDir() | |||
| 267 | + | cwd := "/project" | |||
| 268 | + | parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test") | |||
| 269 | + | if err != nil { | |||
| 270 | + | t.Fatal(err) | |||
| 271 | + | } | |||
| 272 | + | _ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}) | |||
| 273 | + | _ = parent.Close() | |||
| 274 | + | ||||
| 275 | + | branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1) | |||
| 276 | + | if err != nil { | |||
| 277 | + | t.Fatalf("BranchSession: %v", err) | |||
| 278 | + | } | |||
| 279 | + | branch, _, err := OpenSession(branchPath) | |||
| 280 | + | if err != nil { | |||
| 281 | + | t.Fatal(err) | |||
| 282 | + | } | |||
| 283 | + | _ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}}) | |||
| 284 | + | _ = branch.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "fine"}}}) | |||
| 285 | + | _ = branch.Close() | |||
| 286 | + | ||||
| 287 | + | if summary := describeSession(branchPath); summary.Title != "how are you?" { | |||
| 288 | + | t.Errorf("branch title: want %q, got %q", "how are you?", summary.Title) | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | func TestRenamedBranchKeepsExplicitTitle(t *testing.T) { | |||
| 293 | + | root := t.TempDir() | |||
| 294 | + | cwd := "/project" | |||
| 295 | + | parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test") | |||
| 296 | + | if err != nil { | |||
| 297 | + | t.Fatal(err) | |||
| 298 | + | } | |||
| 299 | + | _ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}) | |||
| 300 | + | _ = parent.Close() | |||
| 301 | + | ||||
| 302 | + | branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1) | |||
| 303 | + | if err != nil { | |||
| 304 | + | t.Fatalf("BranchSession: %v", err) | |||
| 305 | + | } | |||
| 306 | + | branch, _, err := OpenSession(branchPath) | |||
| 307 | + | if err != nil { | |||
| 308 | + | t.Fatal(err) | |||
| 309 | + | } | |||
| 310 | + | _ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}}) | |||
| 311 | + | _ = branch.Close() | |||
| 312 | + | if err := RenameSession(branchPath, "friendly branch name"); err != nil { | |||
| 313 | + | t.Fatal(err) | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | if summary := describeSession(branchPath); summary.Title != "friendly branch name" { | |||
| 317 | + | t.Errorf("branch title: want %q, got %q", "friendly branch name", summary.Title) | |||
| 318 | + | } | |||
| 232 | } | 319 | } | ||
| 233 | 320 | ||||
| 234 | func TestBranchSessionUsesEffectiveTranscriptAfterCompaction(t *testing.T) { | 321 | func TestBranchSessionUsesEffectiveTranscriptAfterCompaction(t *testing.T) { | ||
M packages/provider/azure_openai.go +90 -1
| @@ -30,7 +30,10 @@ import ( | |||
| 30 | 30 | "strings" | |
| 31 | 31 | ) | |
| 32 | 32 | ||
| 33 | - | const defaultAzureAPIVersion = "2024-10-21" | |
| 33 | + | const ( | |
| 34 | + | defaultAzureAPIVersion = "2024-10-21" | |
| 35 | + | defaultAzureResponsesAPIVersion = "v1" | |
| 36 | + | ) | |
| 34 | 37 | ||
| 35 | 38 | // azureRewriteTransport rewrites every outgoing OpenAI Chat Completions | |
| 36 | 39 | // URL into the Azure deployment-scoped shape: | |
| @@ -134,3 +137,89 @@ func NewAzureOpenAI(apiKey, baseURL string) Client { | |||
| 134 | 137 | http: httpClient, | |
| 135 | 138 | } | |
| 136 | 139 | } | |
| 140 | + | ||
| 141 | + | // azureResponsesTransport applies Azure API-key authentication and API | |
| 142 | + | // versioning to requests using the OpenAI Responses wire format. | |
| 143 | + | type azureResponsesTransport struct { | |
| 144 | + | inner http.RoundTripper | |
| 145 | + | apiKey string | |
| 146 | + | apiVersion string | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func (t *azureResponsesTransport) RoundTrip(req *http.Request) (*http.Response, error) { | |
| 150 | + | clone := req.Clone(req.Context()) | |
| 151 | + | clone.Header.Del("authorization") | |
| 152 | + | clone.Header.Del("chatgpt-account-id") | |
| 153 | + | clone.Header.Del("openai-beta") | |
| 154 | + | clone.Header.Del("originator") | |
| 155 | + | clone.Header.Set("api-key", t.apiKey) | |
| 156 | + | q := clone.URL.Query() | |
| 157 | + | q.Set("api-version", t.apiVersion) | |
| 158 | + | clone.URL.RawQuery = q.Encode() | |
| 159 | + | return t.inner.RoundTrip(clone) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // NewAzureOpenAIResponses creates an Azure OpenAI Responses API client. | |
| 163 | + | // The configured model id is used as the deployment name unless overridden | |
| 164 | + | // through AZURE_OPENAI_DEPLOYMENT_NAME_MAP (model=deployment pairs). | |
| 165 | + | func NewAzureOpenAIResponses(apiKey, baseURL string) Client { | |
| 166 | + | if baseURL == "" { | |
| 167 | + | baseURL = os.Getenv("AZURE_OPENAI_BASE_URL") | |
| 168 | + | if baseURL == "" { | |
| 169 | + | if resource := os.Getenv("AZURE_OPENAI_RESOURCE_NAME"); resource != "" { | |
| 170 | + | baseURL = "https://" + resource + ".openai.azure.com" | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | if baseURL == "" { | |
| 175 | + | return &unimplementedClient{ | |
| 176 | + | name: "azure-openai-responses", | |
| 177 | + | hint: "set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME (or pass --base-url)", | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | endpoint := strings.TrimRight(baseURL, "/") | |
| 182 | + | switch { | |
| 183 | + | case strings.HasSuffix(endpoint, "/openai/v1/responses"): | |
| 184 | + | case strings.HasSuffix(endpoint, "/openai/v1"): | |
| 185 | + | endpoint += "/responses" | |
| 186 | + | case strings.HasSuffix(endpoint, "/openai"): | |
| 187 | + | endpoint += "/v1/responses" | |
| 188 | + | default: | |
| 189 | + | endpoint += "/openai/v1/responses" | |
| 190 | + | } | |
| 191 | + | apiVersion := os.Getenv("AZURE_OPENAI_API_VERSION") | |
| 192 | + | if apiVersion == "" { | |
| 193 | + | apiVersion = defaultAzureResponsesAPIVersion | |
| 194 | + | } | |
| 195 | + | httpClient := &http.Client{ | |
| 196 | + | Transport: &azureResponsesTransport{ | |
| 197 | + | inner: http.DefaultTransport, | |
| 198 | + | apiKey: apiKey, | |
| 199 | + | apiVersion: apiVersion, | |
| 200 | + | }, | |
| 201 | + | Timeout: 0, | |
| 202 | + | } | |
| 203 | + | inner := &codexClient{ | |
| 204 | + | token: apiKey, | |
| 205 | + | baseURL: endpoint, | |
| 206 | + | errorLabel: "azure openai", | |
| 207 | + | providerName: "azure-openai-responses", | |
| 208 | + | modelName: azureDeploymentName, | |
| 209 | + | disableCLIRouting: true, | |
| 210 | + | http: httpClient, | |
| 211 | + | } | |
| 212 | + | return &renamedClient{inner: inner, name: "azure-openai-responses"} | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func azureDeploymentName(model string) string { | |
| 216 | + | for _, pair := range strings.Split(os.Getenv("AZURE_OPENAI_DEPLOYMENT_NAME_MAP"), ",") { | |
| 217 | + | parts := strings.SplitN(strings.TrimSpace(pair), "=", 2) | |
| 218 | + | if len(parts) == 2 && strings.TrimSpace(parts[0]) == model { | |
| 219 | + | if deployment := strings.TrimSpace(parts[1]); deployment != "" { | |
| 220 | + | return deployment | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | return model | |
| 225 | + | } | |
| @@ -30,7 +30,10 @@ import ( | |||||
| 30 | "strings" | 30 | "strings" | ||
| 31 | ) | 31 | ) | ||
| 32 | 32 | ||||
| 33 | - | const defaultAzureAPIVersion = "2024-10-21" | 33 | + | const ( |
| 34 | + | defaultAzureAPIVersion = "2024-10-21" | |||
| 35 | + | defaultAzureResponsesAPIVersion = "v1" | |||
| 36 | + | ) | |||
| 34 | 37 | ||||
| 35 | // azureRewriteTransport rewrites every outgoing OpenAI Chat Completions | 38 | // azureRewriteTransport rewrites every outgoing OpenAI Chat Completions | ||
| 36 | // URL into the Azure deployment-scoped shape: | 39 | // URL into the Azure deployment-scoped shape: | ||
| @@ -134,3 +137,89 @@ func NewAzureOpenAI(apiKey, baseURL string) Client { | |||||
| 134 | http: httpClient, | 137 | http: httpClient, | ||
| 135 | } | 138 | } | ||
| 136 | } | 139 | } | ||
| 140 | + | ||||
| 141 | + | // azureResponsesTransport applies Azure API-key authentication and API | |||
| 142 | + | // versioning to requests using the OpenAI Responses wire format. | |||
| 143 | + | type azureResponsesTransport struct { | |||
| 144 | + | inner http.RoundTripper | |||
| 145 | + | apiKey string | |||
| 146 | + | apiVersion string | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func (t *azureResponsesTransport) RoundTrip(req *http.Request) (*http.Response, error) { | |||
| 150 | + | clone := req.Clone(req.Context()) | |||
| 151 | + | clone.Header.Del("authorization") | |||
| 152 | + | clone.Header.Del("chatgpt-account-id") | |||
| 153 | + | clone.Header.Del("openai-beta") | |||
| 154 | + | clone.Header.Del("originator") | |||
| 155 | + | clone.Header.Set("api-key", t.apiKey) | |||
| 156 | + | q := clone.URL.Query() | |||
| 157 | + | q.Set("api-version", t.apiVersion) | |||
| 158 | + | clone.URL.RawQuery = q.Encode() | |||
| 159 | + | return t.inner.RoundTrip(clone) | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // NewAzureOpenAIResponses creates an Azure OpenAI Responses API client. | |||
| 163 | + | // The configured model id is used as the deployment name unless overridden | |||
| 164 | + | // through AZURE_OPENAI_DEPLOYMENT_NAME_MAP (model=deployment pairs). | |||
| 165 | + | func NewAzureOpenAIResponses(apiKey, baseURL string) Client { | |||
| 166 | + | if baseURL == "" { | |||
| 167 | + | baseURL = os.Getenv("AZURE_OPENAI_BASE_URL") | |||
| 168 | + | if baseURL == "" { | |||
| 169 | + | if resource := os.Getenv("AZURE_OPENAI_RESOURCE_NAME"); resource != "" { | |||
| 170 | + | baseURL = "https://" + resource + ".openai.azure.com" | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | if baseURL == "" { | |||
| 175 | + | return &unimplementedClient{ | |||
| 176 | + | name: "azure-openai-responses", | |||
| 177 | + | hint: "set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME (or pass --base-url)", | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | endpoint := strings.TrimRight(baseURL, "/") | |||
| 182 | + | switch { | |||
| 183 | + | case strings.HasSuffix(endpoint, "/openai/v1/responses"): | |||
| 184 | + | case strings.HasSuffix(endpoint, "/openai/v1"): | |||
| 185 | + | endpoint += "/responses" | |||
| 186 | + | case strings.HasSuffix(endpoint, "/openai"): | |||
| 187 | + | endpoint += "/v1/responses" | |||
| 188 | + | default: | |||
| 189 | + | endpoint += "/openai/v1/responses" | |||
| 190 | + | } | |||
| 191 | + | apiVersion := os.Getenv("AZURE_OPENAI_API_VERSION") | |||
| 192 | + | if apiVersion == "" { | |||
| 193 | + | apiVersion = defaultAzureResponsesAPIVersion | |||
| 194 | + | } | |||
| 195 | + | httpClient := &http.Client{ | |||
| 196 | + | Transport: &azureResponsesTransport{ | |||
| 197 | + | inner: http.DefaultTransport, | |||
| 198 | + | apiKey: apiKey, | |||
| 199 | + | apiVersion: apiVersion, | |||
| 200 | + | }, | |||
| 201 | + | Timeout: 0, | |||
| 202 | + | } | |||
| 203 | + | inner := &codexClient{ | |||
| 204 | + | token: apiKey, | |||
| 205 | + | baseURL: endpoint, | |||
| 206 | + | errorLabel: "azure openai", | |||
| 207 | + | providerName: "azure-openai-responses", | |||
| 208 | + | modelName: azureDeploymentName, | |||
| 209 | + | disableCLIRouting: true, | |||
| 210 | + | http: httpClient, | |||
| 211 | + | } | |||
| 212 | + | return &renamedClient{inner: inner, name: "azure-openai-responses"} | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func azureDeploymentName(model string) string { | |||
| 216 | + | for _, pair := range strings.Split(os.Getenv("AZURE_OPENAI_DEPLOYMENT_NAME_MAP"), ",") { | |||
| 217 | + | parts := strings.SplitN(strings.TrimSpace(pair), "=", 2) | |||
| 218 | + | if len(parts) == 2 && strings.TrimSpace(parts[0]) == model { | |||
| 219 | + | if deployment := strings.TrimSpace(parts[1]); deployment != "" { | |||
| 220 | + | return deployment | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | return model | |||
| 225 | + | } | |||
A packages/provider/azure_openai_test.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "io" | |
| 7 | + | "net/http" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestAzureOpenAIResponsesRequestShape(t *testing.T) { | |
| 13 | + | t.Setenv("AZURE_OPENAI_API_VERSION", "v1") | |
| 14 | + | t.Setenv("AZURE_OPENAI_DEPLOYMENT_NAME_MAP", "gpt-5.6-sol=sol-preview") | |
| 15 | + | ||
| 16 | + | named := NewAzureOpenAIResponses("azure-key", "https://example.openai.azure.com").(*renamedClient) | |
| 17 | + | c := named.inner.(*codexClient) | |
| 18 | + | transport := c.http.Transport.(*azureResponsesTransport) | |
| 19 | + | var gotReq *http.Request | |
| 20 | + | var gotBody codexRequest | |
| 21 | + | transport.inner = roundTripFunc(func(r *http.Request) (*http.Response, error) { | |
| 22 | + | gotReq = r | |
| 23 | + | if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | return &http.Response{ | |
| 27 | + | StatusCode: http.StatusOK, | |
| 28 | + | Header: http.Header{"Content-Type": []string{"text/event-stream"}}, | |
| 29 | + | Body: io.NopCloser(strings.NewReader("")), | |
| 30 | + | }, nil | |
| 31 | + | }) | |
| 32 | + | ||
| 33 | + | events, err := c.Stream(context.Background(), Request{ | |
| 34 | + | Model: "gpt-5.6-sol", | |
| 35 | + | Reasoning: "max", | |
| 36 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | |
| 37 | + | }) | |
| 38 | + | if err != nil { | |
| 39 | + | t.Fatal(err) | |
| 40 | + | } | |
| 41 | + | var startProvider string | |
| 42 | + | for event := range events { | |
| 43 | + | if start, ok := event.(EventStart); ok { | |
| 44 | + | startProvider = start.Provider | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | if startProvider != "azure-openai-responses" { | |
| 49 | + | t.Fatalf("start provider = %q", startProvider) | |
| 50 | + | } | |
| 51 | + | if gotReq == nil { | |
| 52 | + | t.Fatal("request was not sent") | |
| 53 | + | } | |
| 54 | + | if gotReq.URL.String() != "https://example.openai.azure.com/openai/v1/responses?api-version=v1" { | |
| 55 | + | t.Fatalf("url = %q", gotReq.URL.String()) | |
| 56 | + | } | |
| 57 | + | if gotReq.Header.Get("api-key") != "azure-key" || gotReq.Header.Get("authorization") != "" { | |
| 58 | + | t.Fatalf("headers = %#v", gotReq.Header) | |
| 59 | + | } | |
| 60 | + | if gotBody.Model != "sol-preview" { | |
| 61 | + | t.Fatalf("model = %q", gotBody.Model) | |
| 62 | + | } | |
| 63 | + | if gotBody.Reasoning == nil || gotBody.Reasoning.Effort != "max" { | |
| 64 | + | t.Fatalf("reasoning = %+v", gotBody.Reasoning) | |
| 65 | + | } | |
| 66 | + | if gotBody.PromptCacheKey != "" { | |
| 67 | + | t.Fatalf("prompt_cache_key = %q", gotBody.PromptCacheKey) | |
| 68 | + | } | |
| 69 | + | } | |
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "io" | |||
| 7 | + | "net/http" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestAzureOpenAIResponsesRequestShape(t *testing.T) { | |||
| 13 | + | t.Setenv("AZURE_OPENAI_API_VERSION", "v1") | |||
| 14 | + | t.Setenv("AZURE_OPENAI_DEPLOYMENT_NAME_MAP", "gpt-5.6-sol=sol-preview") | |||
| 15 | + | ||||
| 16 | + | named := NewAzureOpenAIResponses("azure-key", "https://example.openai.azure.com").(*renamedClient) | |||
| 17 | + | c := named.inner.(*codexClient) | |||
| 18 | + | transport := c.http.Transport.(*azureResponsesTransport) | |||
| 19 | + | var gotReq *http.Request | |||
| 20 | + | var gotBody codexRequest | |||
| 21 | + | transport.inner = roundTripFunc(func(r *http.Request) (*http.Response, error) { | |||
| 22 | + | gotReq = r | |||
| 23 | + | if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { | |||
| 24 | + | t.Fatal(err) | |||
| 25 | + | } | |||
| 26 | + | return &http.Response{ | |||
| 27 | + | StatusCode: http.StatusOK, | |||
| 28 | + | Header: http.Header{"Content-Type": []string{"text/event-stream"}}, | |||
| 29 | + | Body: io.NopCloser(strings.NewReader("")), | |||
| 30 | + | }, nil | |||
| 31 | + | }) | |||
| 32 | + | ||||
| 33 | + | events, err := c.Stream(context.Background(), Request{ | |||
| 34 | + | Model: "gpt-5.6-sol", | |||
| 35 | + | Reasoning: "max", | |||
| 36 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | |||
| 37 | + | }) | |||
| 38 | + | if err != nil { | |||
| 39 | + | t.Fatal(err) | |||
| 40 | + | } | |||
| 41 | + | var startProvider string | |||
| 42 | + | for event := range events { | |||
| 43 | + | if start, ok := event.(EventStart); ok { | |||
| 44 | + | startProvider = start.Provider | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | if startProvider != "azure-openai-responses" { | |||
| 49 | + | t.Fatalf("start provider = %q", startProvider) | |||
| 50 | + | } | |||
| 51 | + | if gotReq == nil { | |||
| 52 | + | t.Fatal("request was not sent") | |||
| 53 | + | } | |||
| 54 | + | if gotReq.URL.String() != "https://example.openai.azure.com/openai/v1/responses?api-version=v1" { | |||
| 55 | + | t.Fatalf("url = %q", gotReq.URL.String()) | |||
| 56 | + | } | |||
| 57 | + | if gotReq.Header.Get("api-key") != "azure-key" || gotReq.Header.Get("authorization") != "" { | |||
| 58 | + | t.Fatalf("headers = %#v", gotReq.Header) | |||
| 59 | + | } | |||
| 60 | + | if gotBody.Model != "sol-preview" { | |||
| 61 | + | t.Fatalf("model = %q", gotBody.Model) | |||
| 62 | + | } | |||
| 63 | + | if gotBody.Reasoning == nil || gotBody.Reasoning.Effort != "max" { | |||
| 64 | + | t.Fatalf("reasoning = %+v", gotBody.Reasoning) | |||
| 65 | + | } | |||
| 66 | + | if gotBody.PromptCacheKey != "" { | |||
| 67 | + | t.Fatalf("prompt_cache_key = %q", gotBody.PromptCacheKey) | |||
| 68 | + | } | |||
| 69 | + | } | |||
M packages/provider/catalog_builtin.go +6 -0
| @@ -466,6 +466,9 @@ var builtinCatalog = []Model{ | |||
| 466 | 466 | {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.4-pro", DisplayName: "GPT 5.4 Pro", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: "https://ai-gateway.vercel.sh"}, | |
| 467 | 467 | {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.5", DisplayName: "GPT 5.5", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, BaseURL: "https://ai-gateway.vercel.sh"}, | |
| 468 | 468 | {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.5-pro", DisplayName: "GPT 5.5 Pro", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: "https://ai-gateway.vercel.sh"}, | |
| 469 | + | {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.6-luna", DisplayName: "GPT 5.6 Luna", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25, BaseURL: "https://ai-gateway.vercel.sh"}, | |
| 470 | + | {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.6-sol", DisplayName: "GPT 5.6 Sol", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://ai-gateway.vercel.sh"}, | |
| 471 | + | {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.6-terra", DisplayName: "GPT 5.6 Terra", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125, BaseURL: "https://ai-gateway.vercel.sh"}, | |
| 469 | 472 | {Provider: "vercel-ai-gateway", ID: "openai/gpt-oss-20b", DisplayName: "GPT OSS 20B", ContextWindow: 131072, MaxOutput: 8192, Reasoning: true, PriceInput: 0.05, PriceOutput: 0.2, PriceCacheRead: 0, BaseURL: "https://ai-gateway.vercel.sh"}, | |
| 470 | 473 | {Provider: "vercel-ai-gateway", ID: "openai/gpt-oss-safeguard-20b", DisplayName: "GPT OSS Safeguard 20B", ContextWindow: 131072, MaxOutput: 65536, Reasoning: true, PriceInput: 0.075, PriceOutput: 0.3, PriceCacheRead: 0.037, BaseURL: "https://ai-gateway.vercel.sh"}, | |
| 471 | 474 | {Provider: "vercel-ai-gateway", ID: "openai/o1", DisplayName: "o1", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 15, PriceOutput: 60, PriceCacheRead: 7.5, BaseURL: "https://ai-gateway.vercel.sh"}, | |
| @@ -622,6 +625,9 @@ var builtinCatalog = []Model{ | |||
| 622 | 625 | {Provider: "azure-openai-responses", ID: "gpt-5.4-pro", DisplayName: "GPT-5.4 Pro", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: ""}, | |
| 623 | 626 | {Provider: "azure-openai-responses", ID: "gpt-5.5", DisplayName: "GPT-5.5", ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, BaseURL: ""}, | |
| 624 | 627 | {Provider: "azure-openai-responses", ID: "gpt-5.5-pro", DisplayName: "GPT-5.5 Pro", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: ""}, | |
| 628 | + | {Provider: "azure-openai-responses", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25, BaseURL: ""}, | |
| 629 | + | {Provider: "azure-openai-responses", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: ""}, | |
| 630 | + | {Provider: "azure-openai-responses", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125, BaseURL: ""}, | |
| 625 | 631 | {Provider: "azure-openai-responses", ID: "o1", DisplayName: "o1", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 15, PriceOutput: 60, PriceCacheRead: 7.5, BaseURL: ""}, | |
| 626 | 632 | {Provider: "azure-openai-responses", ID: "o1-pro", DisplayName: "o1-pro", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 150, PriceOutput: 600, PriceCacheRead: 0, BaseURL: ""}, | |
| 627 | 633 | {Provider: "azure-openai-responses", ID: "o3", DisplayName: "o3", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 2, PriceOutput: 8, PriceCacheRead: 0.5, BaseURL: ""}, | |
| @@ -466,6 +466,9 @@ var builtinCatalog = []Model{ | |||||
| 466 | {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.4-pro", DisplayName: "GPT 5.4 Pro", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: "https://ai-gateway.vercel.sh"}, | 466 | {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.4-pro", DisplayName: "GPT 5.4 Pro", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: "https://ai-gateway.vercel.sh"}, | ||
| 467 | {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.5", DisplayName: "GPT 5.5", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, BaseURL: "https://ai-gateway.vercel.sh"}, | 467 | {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.5", DisplayName: "GPT 5.5", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, BaseURL: "https://ai-gateway.vercel.sh"}, | ||
| 468 | {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.5-pro", DisplayName: "GPT 5.5 Pro", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: "https://ai-gateway.vercel.sh"}, | 468 | {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.5-pro", DisplayName: "GPT 5.5 Pro", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: "https://ai-gateway.vercel.sh"}, | ||
| 469 | + | {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.6-luna", DisplayName: "GPT 5.6 Luna", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25, BaseURL: "https://ai-gateway.vercel.sh"}, | |||
| 470 | + | {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.6-sol", DisplayName: "GPT 5.6 Sol", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://ai-gateway.vercel.sh"}, | |||
| 471 | + | {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.6-terra", DisplayName: "GPT 5.6 Terra", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125, BaseURL: "https://ai-gateway.vercel.sh"}, | |||
| 469 | {Provider: "vercel-ai-gateway", ID: "openai/gpt-oss-20b", DisplayName: "GPT OSS 20B", ContextWindow: 131072, MaxOutput: 8192, Reasoning: true, PriceInput: 0.05, PriceOutput: 0.2, PriceCacheRead: 0, BaseURL: "https://ai-gateway.vercel.sh"}, | 472 | {Provider: "vercel-ai-gateway", ID: "openai/gpt-oss-20b", DisplayName: "GPT OSS 20B", ContextWindow: 131072, MaxOutput: 8192, Reasoning: true, PriceInput: 0.05, PriceOutput: 0.2, PriceCacheRead: 0, BaseURL: "https://ai-gateway.vercel.sh"}, | ||
| 470 | {Provider: "vercel-ai-gateway", ID: "openai/gpt-oss-safeguard-20b", DisplayName: "GPT OSS Safeguard 20B", ContextWindow: 131072, MaxOutput: 65536, Reasoning: true, PriceInput: 0.075, PriceOutput: 0.3, PriceCacheRead: 0.037, BaseURL: "https://ai-gateway.vercel.sh"}, | 473 | {Provider: "vercel-ai-gateway", ID: "openai/gpt-oss-safeguard-20b", DisplayName: "GPT OSS Safeguard 20B", ContextWindow: 131072, MaxOutput: 65536, Reasoning: true, PriceInput: 0.075, PriceOutput: 0.3, PriceCacheRead: 0.037, BaseURL: "https://ai-gateway.vercel.sh"}, | ||
| 471 | {Provider: "vercel-ai-gateway", ID: "openai/o1", DisplayName: "o1", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 15, PriceOutput: 60, PriceCacheRead: 7.5, BaseURL: "https://ai-gateway.vercel.sh"}, | 474 | {Provider: "vercel-ai-gateway", ID: "openai/o1", DisplayName: "o1", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 15, PriceOutput: 60, PriceCacheRead: 7.5, BaseURL: "https://ai-gateway.vercel.sh"}, | ||
| @@ -622,6 +625,9 @@ var builtinCatalog = []Model{ | |||||
| 622 | {Provider: "azure-openai-responses", ID: "gpt-5.4-pro", DisplayName: "GPT-5.4 Pro", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: ""}, | 625 | {Provider: "azure-openai-responses", ID: "gpt-5.4-pro", DisplayName: "GPT-5.4 Pro", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: ""}, | ||
| 623 | {Provider: "azure-openai-responses", ID: "gpt-5.5", DisplayName: "GPT-5.5", ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, BaseURL: ""}, | 626 | {Provider: "azure-openai-responses", ID: "gpt-5.5", DisplayName: "GPT-5.5", ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, BaseURL: ""}, | ||
| 624 | {Provider: "azure-openai-responses", ID: "gpt-5.5-pro", DisplayName: "GPT-5.5 Pro", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: ""}, | 627 | {Provider: "azure-openai-responses", ID: "gpt-5.5-pro", DisplayName: "GPT-5.5 Pro", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: ""}, | ||
| 628 | + | {Provider: "azure-openai-responses", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25, BaseURL: ""}, | |||
| 629 | + | {Provider: "azure-openai-responses", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: ""}, | |||
| 630 | + | {Provider: "azure-openai-responses", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125, BaseURL: ""}, | |||
| 625 | {Provider: "azure-openai-responses", ID: "o1", DisplayName: "o1", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 15, PriceOutput: 60, PriceCacheRead: 7.5, BaseURL: ""}, | 631 | {Provider: "azure-openai-responses", ID: "o1", DisplayName: "o1", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 15, PriceOutput: 60, PriceCacheRead: 7.5, BaseURL: ""}, | ||
| 626 | {Provider: "azure-openai-responses", ID: "o1-pro", DisplayName: "o1-pro", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 150, PriceOutput: 600, PriceCacheRead: 0, BaseURL: ""}, | 632 | {Provider: "azure-openai-responses", ID: "o1-pro", DisplayName: "o1-pro", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 150, PriceOutput: 600, PriceCacheRead: 0, BaseURL: ""}, | ||
| 627 | {Provider: "azure-openai-responses", ID: "o3", DisplayName: "o3", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 2, PriceOutput: 8, PriceCacheRead: 0.5, BaseURL: ""}, | 633 | {Provider: "azure-openai-responses", ID: "o3", DisplayName: "o3", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 2, PriceOutput: 8, PriceCacheRead: 0.5, BaseURL: ""}, | ||
M packages/provider/extra_providers.go +0 -10
| @@ -253,16 +253,6 @@ func NewGoogleVertex(apiKey, baseURL string) Client { | |||
| 253 | 253 | return NewVertex(apiKey, baseURL) | |
| 254 | 254 | } | |
| 255 | 255 | ||
| 256 | - | // NewAzureOpenAIResponses delegates to the real Azure OpenAI client. | |
| 257 | - | // Despite the provider id mentioning "responses", zot uses Azure's | |
| 258 | - | // Chat Completions endpoint (older but functionally complete for our | |
| 259 | - | // agent loop) to avoid duplicating the full openai-responses wire | |
| 260 | - | // client. Models register under provider id `azure-openai-responses` | |
| 261 | - | // so user catalogs keep working unchanged. | |
| 262 | - | func NewAzureOpenAIResponses(apiKey, baseURL string) Client { | |
| 263 | - | return NewAzureOpenAI(apiKey, baseURL) | |
| 264 | - | } | |
| 265 | - | ||
| 266 | 256 | // NewMistral returns a Mistral client using their OpenAI-compatible Chat | |
| 267 | 257 | // Completions endpoint at https://api.mistral.ai/v1. Mistral also offers a | |
| 268 | 258 | // bespoke "Conversations" API, but the OpenAI-compat endpoint supports | |
| @@ -253,16 +253,6 @@ func NewGoogleVertex(apiKey, baseURL string) Client { | |||||
| 253 | return NewVertex(apiKey, baseURL) | 253 | return NewVertex(apiKey, baseURL) | ||
| 254 | } | 254 | } | ||
| 255 | 255 | ||||
| 256 | - | // NewAzureOpenAIResponses delegates to the real Azure OpenAI client. | |||
| 257 | - | // Despite the provider id mentioning "responses", zot uses Azure's | |||
| 258 | - | // Chat Completions endpoint (older but functionally complete for our | |||
| 259 | - | // agent loop) to avoid duplicating the full openai-responses wire | |||
| 260 | - | // client. Models register under provider id `azure-openai-responses` | |||
| 261 | - | // so user catalogs keep working unchanged. | |||
| 262 | - | func NewAzureOpenAIResponses(apiKey, baseURL string) Client { | |||
| 263 | - | return NewAzureOpenAI(apiKey, baseURL) | |||
| 264 | - | } | |||
| 265 | - | ||||
| 266 | // NewMistral returns a Mistral client using their OpenAI-compatible Chat | 256 | // NewMistral returns a Mistral client using their OpenAI-compatible Chat | ||
| 267 | // Completions endpoint at https://api.mistral.ai/v1. Mistral also offers a | 257 | // Completions endpoint at https://api.mistral.ai/v1. Mistral also offers a | ||
| 268 | // bespoke "Conversations" API, but the OpenAI-compat endpoint supports | 258 | // bespoke "Conversations" API, but the OpenAI-compat endpoint supports | ||
M packages/provider/gemini.go +1 -1
| @@ -413,7 +413,7 @@ func geminiThinkingConfig(modelID, level string) *gemThinkingConfig { | |||
| 413 | 413 | } else { | |
| 414 | 414 | lvl = "MEDIUM" | |
| 415 | 415 | } | |
| 416 | - | case "high", "maximum": | |
| 416 | + | case "high", "xhigh", "max": | |
| 417 | 417 | lvl = "HIGH" | |
| 418 | 418 | default: | |
| 419 | 419 | return nil | |
| @@ -413,7 +413,7 @@ func geminiThinkingConfig(modelID, level string) *gemThinkingConfig { | |||||
| 413 | } else { | 413 | } else { | ||
| 414 | lvl = "MEDIUM" | 414 | lvl = "MEDIUM" | ||
| 415 | } | 415 | } | ||
| 416 | - | case "high", "maximum": | 416 | + | case "high", "xhigh", "max": |
| 417 | lvl = "HIGH" | 417 | lvl = "HIGH" | ||
| 418 | default: | 418 | default: | ||
| 419 | return nil | 419 | return nil | ||
M packages/provider/models.go +36 -0
| @@ -316,6 +316,24 @@ var Catalog = []Model{ | |||
| 316 | 316 | PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, | |
| 317 | 317 | Speculative: true, | |
| 318 | 318 | }, | |
| 319 | + | { | |
| 320 | + | Provider: "openai", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", | |
| 321 | + | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |
| 322 | + | PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25, | |
| 323 | + | Speculative: true, | |
| 324 | + | }, | |
| 325 | + | { | |
| 326 | + | Provider: "openai", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol", | |
| 327 | + | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |
| 328 | + | PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, | |
| 329 | + | Speculative: true, | |
| 330 | + | }, | |
| 331 | + | { | |
| 332 | + | Provider: "openai", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra", | |
| 333 | + | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |
| 334 | + | PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125, | |
| 335 | + | Speculative: true, | |
| 336 | + | }, | |
| 319 | 337 | ||
| 320 | 338 | // ---- OpenAI Codex / ChatGPT subscription backend ---- | |
| 321 | 339 | // Same model ids as the OpenAI family, but routed through the | |
| @@ -344,6 +362,24 @@ var Catalog = []Model{ | |||
| 344 | 362 | PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, | |
| 345 | 363 | Speculative: true, | |
| 346 | 364 | }, | |
| 365 | + | { | |
| 366 | + | Provider: "openai-codex", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", | |
| 367 | + | ContextWindow: 372000, MaxOutput: 128000, Reasoning: true, | |
| 368 | + | PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25, | |
| 369 | + | Speculative: true, | |
| 370 | + | }, | |
| 371 | + | { | |
| 372 | + | Provider: "openai-codex", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol", | |
| 373 | + | ContextWindow: 372000, MaxOutput: 128000, Reasoning: true, | |
| 374 | + | PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, | |
| 375 | + | Speculative: true, | |
| 376 | + | }, | |
| 377 | + | { | |
| 378 | + | Provider: "openai-codex", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra", | |
| 379 | + | ContextWindow: 372000, MaxOutput: 128000, Reasoning: true, | |
| 380 | + | PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125, | |
| 381 | + | Speculative: true, | |
| 382 | + | }, | |
| 347 | 383 | } | |
| 348 | 384 | ||
| 349 | 385 | // DefaultModel is used when the user does not specify one. | |
| @@ -316,6 +316,24 @@ var Catalog = []Model{ | |||||
| 316 | PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, | 316 | PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, | ||
| 317 | Speculative: true, | 317 | Speculative: true, | ||
| 318 | }, | 318 | }, | ||
| 319 | + | { | |||
| 320 | + | Provider: "openai", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", | |||
| 321 | + | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |||
| 322 | + | PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25, | |||
| 323 | + | Speculative: true, | |||
| 324 | + | }, | |||
| 325 | + | { | |||
| 326 | + | Provider: "openai", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol", | |||
| 327 | + | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |||
| 328 | + | PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, | |||
| 329 | + | Speculative: true, | |||
| 330 | + | }, | |||
| 331 | + | { | |||
| 332 | + | Provider: "openai", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra", | |||
| 333 | + | ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, | |||
| 334 | + | PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125, | |||
| 335 | + | Speculative: true, | |||
| 336 | + | }, | |||
| 319 | 337 | ||||
| 320 | // ---- OpenAI Codex / ChatGPT subscription backend ---- | 338 | // ---- OpenAI Codex / ChatGPT subscription backend ---- | ||
| 321 | // Same model ids as the OpenAI family, but routed through the | 339 | // Same model ids as the OpenAI family, but routed through the | ||
| @@ -344,6 +362,24 @@ var Catalog = []Model{ | |||||
| 344 | PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, | 362 | PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, | ||
| 345 | Speculative: true, | 363 | Speculative: true, | ||
| 346 | }, | 364 | }, | ||
| 365 | + | { | |||
| 366 | + | Provider: "openai-codex", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", | |||
| 367 | + | ContextWindow: 372000, MaxOutput: 128000, Reasoning: true, | |||
| 368 | + | PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25, | |||
| 369 | + | Speculative: true, | |||
| 370 | + | }, | |||
| 371 | + | { | |||
| 372 | + | Provider: "openai-codex", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol", | |||
| 373 | + | ContextWindow: 372000, MaxOutput: 128000, Reasoning: true, | |||
| 374 | + | PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, | |||
| 375 | + | Speculative: true, | |||
| 376 | + | }, | |||
| 377 | + | { | |||
| 378 | + | Provider: "openai-codex", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra", | |||
| 379 | + | ContextWindow: 372000, MaxOutput: 128000, Reasoning: true, | |||
| 380 | + | PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125, | |||
| 381 | + | Speculative: true, | |||
| 382 | + | }, | |||
| 347 | } | 383 | } | ||
| 348 | 384 | ||||
| 349 | // DefaultModel is used when the user does not specify one. | 385 | // DefaultModel is used when the user does not specify one. | ||
M packages/provider/openai_codex.go +83 -13
| @@ -3,7 +3,9 @@ package provider | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | + | "crypto/rand" | |
| 6 | 7 | "encoding/base64" | |
| 8 | + | "encoding/hex" | |
| 7 | 9 | "encoding/json" | |
| 8 | 10 | "fmt" | |
| 9 | 11 | "io" | |
| @@ -36,10 +38,14 @@ import ( | |||
| 36 | 38 | const codexDefaultBaseURL = "https://chatgpt.com/backend-api/codex/responses" | |
| 37 | 39 | ||
| 38 | 40 | type codexClient struct { | |
| 39 | - | token string | |
| 40 | - | accountID string | |
| 41 | - | baseURL string | |
| 42 | - | http *http.Client | |
| 41 | + | token string | |
| 42 | + | accountID string | |
| 43 | + | baseURL string | |
| 44 | + | errorLabel string | |
| 45 | + | providerName string | |
| 46 | + | modelName func(string) string | |
| 47 | + | disableCLIRouting bool | |
| 48 | + | http *http.Client | |
| 43 | 49 | } | |
| 44 | 50 | ||
| 45 | 51 | // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint | |
| @@ -50,10 +56,12 @@ func NewOpenAICodex(token, accountID, baseURL string) Client { | |||
| 50 | 56 | baseURL = codexDefaultBaseURL | |
| 51 | 57 | } | |
| 52 | 58 | return &codexClient{ | |
| 53 | - | token: token, | |
| 54 | - | accountID: accountID, | |
| 55 | - | baseURL: strings.TrimRight(baseURL, "/"), | |
| 56 | - | http: &http.Client{Timeout: 0}, | |
| 59 | + | token: token, | |
| 60 | + | accountID: accountID, | |
| 61 | + | baseURL: strings.TrimRight(baseURL, "/"), | |
| 62 | + | errorLabel: "codex", | |
| 63 | + | providerName: "openai-codex", | |
| 64 | + | http: &http.Client{Timeout: 0}, | |
| 57 | 65 | } | |
| 58 | 66 | } | |
| 59 | 67 | ||
| @@ -146,6 +154,7 @@ type codexRequest struct { | |||
| 146 | 154 | ParallelToolCalls bool `json:"parallel_tool_calls"` | |
| 147 | 155 | Include []string `json:"include,omitempty"` | |
| 148 | 156 | Reasoning *codexReasoningConfig `json:"reasoning,omitempty"` | |
| 157 | + | PromptCacheKey string `json:"prompt_cache_key,omitempty"` | |
| 149 | 158 | } | |
| 150 | 159 | ||
| 151 | 160 | // ---- Request building ---- | |
| @@ -169,7 +178,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||
| 169 | 178 | Include: []string{"reasoning.encrypted_content"}, | |
| 170 | 179 | } | |
| 171 | 180 | if m.Reasoning { | |
| 172 | - | if effort := OpenAICodexReasoningEffort(req.Reasoning); effort != "" { | |
| 181 | + | if effort := OpenAICodexReasoningEffort(req.Reasoning, req.Model); effort != "" { | |
| 173 | 182 | body.Reasoning = &codexReasoningConfig{Effort: effort} | |
| 174 | 183 | } | |
| 175 | 184 | } | |
| @@ -310,6 +319,29 @@ func splitCallID(id string) (string, string) { | |||
| 310 | 319 | return id, "" | |
| 311 | 320 | } | |
| 312 | 321 | ||
| 322 | + | func usesCodexCLIRouting(model string) bool { | |
| 323 | + | switch model { | |
| 324 | + | case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro": | |
| 325 | + | return true | |
| 326 | + | default: | |
| 327 | + | return false | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | func newCodexSessionID() string { | |
| 332 | + | var b [16]byte | |
| 333 | + | if _, err := rand.Read(b[:]); err != nil { | |
| 334 | + | return fmt.Sprintf("zot-%d", time.Now().UnixNano()) | |
| 335 | + | } | |
| 336 | + | return strings.Join([]string{ | |
| 337 | + | hex.EncodeToString(b[0:4]), | |
| 338 | + | hex.EncodeToString(b[4:6]), | |
| 339 | + | hex.EncodeToString(b[6:8]), | |
| 340 | + | hex.EncodeToString(b[8:10]), | |
| 341 | + | hex.EncodeToString(b[10:16]), | |
| 342 | + | }, "-") | |
| 343 | + | } | |
| 344 | + | ||
| 313 | 345 | // ---- Streaming ---- | |
| 314 | 346 | ||
| 315 | 347 | func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| @@ -317,6 +349,14 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er | |||
| 317 | 349 | if err != nil { | |
| 318 | 350 | return nil, err | |
| 319 | 351 | } | |
| 352 | + | if c.modelName != nil { | |
| 353 | + | wire.Model = c.modelName(wire.Model) | |
| 354 | + | } | |
| 355 | + | var codexCLISessionID string | |
| 356 | + | if !c.disableCLIRouting && usesCodexCLIRouting(wire.Model) { | |
| 357 | + | codexCLISessionID = newCodexSessionID() | |
| 358 | + | wire.PromptCacheKey = codexCLISessionID | |
| 359 | + | } | |
| 320 | 360 | body, err := json.Marshal(wire) | |
| 321 | 361 | if err != nil { | |
| 322 | 362 | return nil, err | |
| @@ -332,8 +372,17 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er | |||
| 332 | 372 | httpReq.Header.Set("authorization", "Bearer "+c.token) | |
| 333 | 373 | httpReq.Header.Set("chatgpt-account-id", c.accountID) | |
| 334 | 374 | httpReq.Header.Set("openai-beta", "responses=experimental") | |
| 335 | - | httpReq.Header.Set("originator", "zot") | |
| 336 | - | httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH)) | |
| 375 | + | if codexCLISessionID != "" { | |
| 376 | + | // Some preview models are only admitted or reliably served by the | |
| 377 | + | // ChatGPT Codex backend when the request follows Codex CLI routing | |
| 378 | + | // metadata. Keep this narrow so Sol retains zot's proven shape. | |
| 379 | + | httpReq.Header.Set("originator", "codex_cli_rs") | |
| 380 | + | httpReq.Header.Set("session-id", codexCLISessionID) | |
| 381 | + | httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0") | |
| 382 | + | } else { | |
| 383 | + | httpReq.Header.Set("originator", "zot") | |
| 384 | + | httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH)) | |
| 385 | + | } | |
| 337 | 386 | return httpReq, nil | |
| 338 | 387 | } | |
| 339 | 388 | ||
| @@ -360,7 +409,11 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re | |||
| 360 | 409 | if model.ID == "" { | |
| 361 | 410 | model, _ = FindModel("openai", req.Model) | |
| 362 | 411 | } | |
| 363 | - | out <- EventStart{Model: req.Model, Provider: "openai-codex"} | |
| 412 | + | providerName := c.providerName | |
| 413 | + | if providerName == "" { | |
| 414 | + | providerName = "openai-codex" | |
| 415 | + | } | |
| 416 | + | out <- EventStart{Model: req.Model, Provider: providerName} | |
| 364 | 417 | ||
| 365 | 418 | raw := make(chan sseEvent, 16) | |
| 366 | 419 | go readSSE(resp.Body, raw) | |
| @@ -596,14 +649,31 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re | |||
| 596 | 649 | var p struct { | |
| 597 | 650 | Message string `json:"message"` | |
| 598 | 651 | Code string `json:"code"` | |
| 652 | + | Error struct { | |
| 653 | + | Message string `json:"message"` | |
| 654 | + | Code string `json:"code"` | |
| 655 | + | } `json:"error"` | |
| 599 | 656 | } | |
| 600 | 657 | _ = json.Unmarshal([]byte(ev.Data), &p) | |
| 601 | 658 | msg := p.Message | |
| 659 | + | if msg == "" { | |
| 660 | + | msg = p.Error.Message | |
| 661 | + | } | |
| 602 | 662 | if msg == "" { | |
| 603 | 663 | msg = p.Code | |
| 604 | 664 | } | |
| 665 | + | if msg == "" { | |
| 666 | + | msg = p.Error.Code | |
| 667 | + | } | |
| 668 | + | if msg == "" { | |
| 669 | + | msg = strings.TrimSpace(ev.Data) | |
| 670 | + | } | |
| 605 | 671 | stop = StopError | |
| 606 | - | finalErr = fmt.Errorf("codex error: %s", msg) | |
| 672 | + | label := c.errorLabel | |
| 673 | + | if label == "" { | |
| 674 | + | label = "codex" | |
| 675 | + | } | |
| 676 | + | finalErr = fmt.Errorf("%s error: %s", label, msg) | |
| 607 | 677 | sendDone() | |
| 608 | 678 | return | |
| 609 | 679 | } | |
| @@ -3,7 +3,9 @@ package provider | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "crypto/rand" | |||
| 6 | "encoding/base64" | 7 | "encoding/base64" | ||
| 8 | + | "encoding/hex" | |||
| 7 | "encoding/json" | 9 | "encoding/json" | ||
| 8 | "fmt" | 10 | "fmt" | ||
| 9 | "io" | 11 | "io" | ||
| @@ -36,10 +38,14 @@ import ( | |||||
| 36 | const codexDefaultBaseURL = "https://chatgpt.com/backend-api/codex/responses" | 38 | const codexDefaultBaseURL = "https://chatgpt.com/backend-api/codex/responses" | ||
| 37 | 39 | ||||
| 38 | type codexClient struct { | 40 | type codexClient struct { | ||
| 39 | - | token string | 41 | + | token string |
| 40 | - | accountID string | 42 | + | accountID string |
| 41 | - | baseURL string | 43 | + | baseURL string |
| 42 | - | http *http.Client | 44 | + | errorLabel string |
| 45 | + | providerName string | |||
| 46 | + | modelName func(string) string | |||
| 47 | + | disableCLIRouting bool | |||
| 48 | + | http *http.Client | |||
| 43 | } | 49 | } | ||
| 44 | 50 | ||||
| 45 | // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint | 51 | // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint | ||
| @@ -50,10 +56,12 @@ func NewOpenAICodex(token, accountID, baseURL string) Client { | |||||
| 50 | baseURL = codexDefaultBaseURL | 56 | baseURL = codexDefaultBaseURL | ||
| 51 | } | 57 | } | ||
| 52 | return &codexClient{ | 58 | return &codexClient{ | ||
| 53 | - | token: token, | 59 | + | token: token, |
| 54 | - | accountID: accountID, | 60 | + | accountID: accountID, |
| 55 | - | baseURL: strings.TrimRight(baseURL, "/"), | 61 | + | baseURL: strings.TrimRight(baseURL, "/"), |
| 56 | - | http: &http.Client{Timeout: 0}, | 62 | + | errorLabel: "codex", |
| 63 | + | providerName: "openai-codex", | |||
| 64 | + | http: &http.Client{Timeout: 0}, | |||
| 57 | } | 65 | } | ||
| 58 | } | 66 | } | ||
| 59 | 67 | ||||
| @@ -146,6 +154,7 @@ type codexRequest struct { | |||||
| 146 | ParallelToolCalls bool `json:"parallel_tool_calls"` | 154 | ParallelToolCalls bool `json:"parallel_tool_calls"` | ||
| 147 | Include []string `json:"include,omitempty"` | 155 | Include []string `json:"include,omitempty"` | ||
| 148 | Reasoning *codexReasoningConfig `json:"reasoning,omitempty"` | 156 | Reasoning *codexReasoningConfig `json:"reasoning,omitempty"` | ||
| 157 | + | PromptCacheKey string `json:"prompt_cache_key,omitempty"` | |||
| 149 | } | 158 | } | ||
| 150 | 159 | ||||
| 151 | // ---- Request building ---- | 160 | // ---- Request building ---- | ||
| @@ -169,7 +178,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||||
| 169 | Include: []string{"reasoning.encrypted_content"}, | 178 | Include: []string{"reasoning.encrypted_content"}, | ||
| 170 | } | 179 | } | ||
| 171 | if m.Reasoning { | 180 | if m.Reasoning { | ||
| 172 | - | if effort := OpenAICodexReasoningEffort(req.Reasoning); effort != "" { | 181 | + | if effort := OpenAICodexReasoningEffort(req.Reasoning, req.Model); effort != "" { |
| 173 | body.Reasoning = &codexReasoningConfig{Effort: effort} | 182 | body.Reasoning = &codexReasoningConfig{Effort: effort} | ||
| 174 | } | 183 | } | ||
| 175 | } | 184 | } | ||
| @@ -310,6 +319,29 @@ func splitCallID(id string) (string, string) { | |||||
| 310 | return id, "" | 319 | return id, "" | ||
| 311 | } | 320 | } | ||
| 312 | 321 | ||||
| 322 | + | func usesCodexCLIRouting(model string) bool { | |||
| 323 | + | switch model { | |||
| 324 | + | case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro": | |||
| 325 | + | return true | |||
| 326 | + | default: | |||
| 327 | + | return false | |||
| 328 | + | } | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | func newCodexSessionID() string { | |||
| 332 | + | var b [16]byte | |||
| 333 | + | if _, err := rand.Read(b[:]); err != nil { | |||
| 334 | + | return fmt.Sprintf("zot-%d", time.Now().UnixNano()) | |||
| 335 | + | } | |||
| 336 | + | return strings.Join([]string{ | |||
| 337 | + | hex.EncodeToString(b[0:4]), | |||
| 338 | + | hex.EncodeToString(b[4:6]), | |||
| 339 | + | hex.EncodeToString(b[6:8]), | |||
| 340 | + | hex.EncodeToString(b[8:10]), | |||
| 341 | + | hex.EncodeToString(b[10:16]), | |||
| 342 | + | }, "-") | |||
| 343 | + | } | |||
| 344 | + | ||||
| 313 | // ---- Streaming ---- | 345 | // ---- Streaming ---- | ||
| 314 | 346 | ||||
| 315 | func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | 347 | func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | ||
| @@ -317,6 +349,14 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er | |||||
| 317 | if err != nil { | 349 | if err != nil { | ||
| 318 | return nil, err | 350 | return nil, err | ||
| 319 | } | 351 | } | ||
| 352 | + | if c.modelName != nil { | |||
| 353 | + | wire.Model = c.modelName(wire.Model) | |||
| 354 | + | } | |||
| 355 | + | var codexCLISessionID string | |||
| 356 | + | if !c.disableCLIRouting && usesCodexCLIRouting(wire.Model) { | |||
| 357 | + | codexCLISessionID = newCodexSessionID() | |||
| 358 | + | wire.PromptCacheKey = codexCLISessionID | |||
| 359 | + | } | |||
| 320 | body, err := json.Marshal(wire) | 360 | body, err := json.Marshal(wire) | ||
| 321 | if err != nil { | 361 | if err != nil { | ||
| 322 | return nil, err | 362 | return nil, err | ||
| @@ -332,8 +372,17 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er | |||||
| 332 | httpReq.Header.Set("authorization", "Bearer "+c.token) | 372 | httpReq.Header.Set("authorization", "Bearer "+c.token) | ||
| 333 | httpReq.Header.Set("chatgpt-account-id", c.accountID) | 373 | httpReq.Header.Set("chatgpt-account-id", c.accountID) | ||
| 334 | httpReq.Header.Set("openai-beta", "responses=experimental") | 374 | httpReq.Header.Set("openai-beta", "responses=experimental") | ||
| 335 | - | httpReq.Header.Set("originator", "zot") | 375 | + | if codexCLISessionID != "" { |
| 336 | - | httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH)) | 376 | + | // Some preview models are only admitted or reliably served by the |
| 377 | + | // ChatGPT Codex backend when the request follows Codex CLI routing | |||
| 378 | + | // metadata. Keep this narrow so Sol retains zot's proven shape. | |||
| 379 | + | httpReq.Header.Set("originator", "codex_cli_rs") | |||
| 380 | + | httpReq.Header.Set("session-id", codexCLISessionID) | |||
| 381 | + | httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0") | |||
| 382 | + | } else { | |||
| 383 | + | httpReq.Header.Set("originator", "zot") | |||
| 384 | + | httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH)) | |||
| 385 | + | } | |||
| 337 | return httpReq, nil | 386 | return httpReq, nil | ||
| 338 | } | 387 | } | ||
| 339 | 388 | ||||
| @@ -360,7 +409,11 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re | |||||
| 360 | if model.ID == "" { | 409 | if model.ID == "" { | ||
| 361 | model, _ = FindModel("openai", req.Model) | 410 | model, _ = FindModel("openai", req.Model) | ||
| 362 | } | 411 | } | ||
| 363 | - | out <- EventStart{Model: req.Model, Provider: "openai-codex"} | 412 | + | providerName := c.providerName |
| 413 | + | if providerName == "" { | |||
| 414 | + | providerName = "openai-codex" | |||
| 415 | + | } | |||
| 416 | + | out <- EventStart{Model: req.Model, Provider: providerName} | |||
| 364 | 417 | ||||
| 365 | raw := make(chan sseEvent, 16) | 418 | raw := make(chan sseEvent, 16) | ||
| 366 | go readSSE(resp.Body, raw) | 419 | go readSSE(resp.Body, raw) | ||
| @@ -596,14 +649,31 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re | |||||
| 596 | var p struct { | 649 | var p struct { | ||
| 597 | Message string `json:"message"` | 650 | Message string `json:"message"` | ||
| 598 | Code string `json:"code"` | 651 | Code string `json:"code"` | ||
| 652 | + | Error struct { | |||
| 653 | + | Message string `json:"message"` | |||
| 654 | + | Code string `json:"code"` | |||
| 655 | + | } `json:"error"` | |||
| 599 | } | 656 | } | ||
| 600 | _ = json.Unmarshal([]byte(ev.Data), &p) | 657 | _ = json.Unmarshal([]byte(ev.Data), &p) | ||
| 601 | msg := p.Message | 658 | msg := p.Message | ||
| 659 | + | if msg == "" { | |||
| 660 | + | msg = p.Error.Message | |||
| 661 | + | } | |||
| 602 | if msg == "" { | 662 | if msg == "" { | ||
| 603 | msg = p.Code | 663 | msg = p.Code | ||
| 604 | } | 664 | } | ||
| 665 | + | if msg == "" { | |||
| 666 | + | msg = p.Error.Code | |||
| 667 | + | } | |||
| 668 | + | if msg == "" { | |||
| 669 | + | msg = strings.TrimSpace(ev.Data) | |||
| 670 | + | } | |||
| 605 | stop = StopError | 671 | stop = StopError | ||
| 606 | - | finalErr = fmt.Errorf("codex error: %s", msg) | 672 | + | label := c.errorLabel |
| 673 | + | if label == "" { | |||
| 674 | + | label = "codex" | |||
| 675 | + | } | |||
| 676 | + | finalErr = fmt.Errorf("%s error: %s", label, msg) | |||
| 607 | sendDone() | 677 | sendDone() | ||
| 608 | return | 678 | return | ||
| 609 | } | 679 | } | ||
M packages/provider/openai_codex_test.go +158 -0
| @@ -1,6 +1,11 @@ | |||
| 1 | 1 | package provider | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "io" | |
| 8 | + | "net/http" | |
| 4 | 9 | "strings" | |
| 5 | 10 | "testing" | |
| 6 | 11 | ) | |
| @@ -61,3 +66,156 @@ func TestCodexImageToolResultMirror(t *testing.T) { | |||
| 61 | 66 | t.Fatalf("mirrored user image was not serialized as input_image") | |
| 62 | 67 | } | |
| 63 | 68 | } | |
| 69 | + | ||
| 70 | + | type roundTripFunc func(*http.Request) (*http.Response, error) | |
| 71 | + | ||
| 72 | + | func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } | |
| 73 | + | ||
| 74 | + | func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) { | |
| 75 | + | c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient) | |
| 76 | + | var gotReq *http.Request | |
| 77 | + | var gotBody codexRequest | |
| 78 | + | c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { | |
| 79 | + | gotReq = r | |
| 80 | + | if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { | |
| 81 | + | t.Fatal(err) | |
| 82 | + | } | |
| 83 | + | return &http.Response{ | |
| 84 | + | StatusCode: http.StatusOK, | |
| 85 | + | Header: http.Header{"Content-Type": []string{"text/event-stream"}}, | |
| 86 | + | Body: io.NopCloser(strings.NewReader("")), | |
| 87 | + | }, nil | |
| 88 | + | }) | |
| 89 | + | ||
| 90 | + | events, err := c.Stream(context.Background(), Request{ | |
| 91 | + | Model: "gpt-5.6-terra", | |
| 92 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | |
| 93 | + | }) | |
| 94 | + | if err != nil { | |
| 95 | + | t.Fatal(err) | |
| 96 | + | } | |
| 97 | + | for range events { | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | if gotReq == nil { | |
| 101 | + | t.Fatal("request was not sent") | |
| 102 | + | } | |
| 103 | + | if gotReq.Header.Get("originator") != "codex_cli_rs" { | |
| 104 | + | t.Fatalf("originator = %q", gotReq.Header.Get("originator")) | |
| 105 | + | } | |
| 106 | + | if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" { | |
| 107 | + | t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent")) | |
| 108 | + | } | |
| 109 | + | if gotBody.PromptCacheKey == "" { | |
| 110 | + | t.Fatal("prompt_cache_key was not set") | |
| 111 | + | } | |
| 112 | + | if gotReq.Header.Get("session-id") != gotBody.PromptCacheKey { | |
| 113 | + | t.Fatalf("session-id = %q, prompt_cache_key = %q", gotReq.Header.Get("session-id"), gotBody.PromptCacheKey) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) { | |
| 118 | + | c := NewOpenAICodex("token", "acct", "").(*codexClient) | |
| 119 | + | wire, err := c.buildRequest(Request{Model: "gpt-5.6-sol", Reasoning: "max"}) | |
| 120 | + | if err != nil { | |
| 121 | + | t.Fatal(err) | |
| 122 | + | } | |
| 123 | + | if wire.Reasoning == nil || wire.Reasoning.Effort != "max" { | |
| 124 | + | t.Fatalf("reasoning = %+v", wire.Reasoning) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) { | |
| 129 | + | named := NewOpenAIResponsesNamed("token", "https://example.test/v1/responses", "openai").(*renamedClient) | |
| 130 | + | c := named.inner.(*codexClient) | |
| 131 | + | var gotReq *http.Request | |
| 132 | + | var gotBody codexRequest | |
| 133 | + | c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { | |
| 134 | + | gotReq = r | |
| 135 | + | if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { | |
| 136 | + | t.Fatal(err) | |
| 137 | + | } | |
| 138 | + | return &http.Response{ | |
| 139 | + | StatusCode: http.StatusOK, | |
| 140 | + | Header: http.Header{"Content-Type": []string{"text/event-stream"}}, | |
| 141 | + | Body: io.NopCloser(strings.NewReader("")), | |
| 142 | + | }, nil | |
| 143 | + | }) | |
| 144 | + | ||
| 145 | + | events, err := c.Stream(context.Background(), Request{ | |
| 146 | + | Model: "gpt-5.6-sol", | |
| 147 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | |
| 148 | + | }) | |
| 149 | + | if err != nil { | |
| 150 | + | t.Fatal(err) | |
| 151 | + | } | |
| 152 | + | for range events { | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | if gotReq == nil { | |
| 156 | + | t.Fatal("request was not sent") | |
| 157 | + | } | |
| 158 | + | if gotReq.Header.Get("session-id") != "" { | |
| 159 | + | t.Fatalf("session-id = %q", gotReq.Header.Get("session-id")) | |
| 160 | + | } | |
| 161 | + | if gotBody.PromptCacheKey != "" { | |
| 162 | + | t.Fatalf("prompt_cache_key = %q", gotBody.PromptCacheKey) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func TestCodexNestedStreamError(t *testing.T) { | |
| 167 | + | c := NewOpenAICodex("token", "acct", "").(*codexClient) | |
| 168 | + | resp := &http.Response{ | |
| 169 | + | Body: io.NopCloser(strings.NewReader("data: {\"type\":\"error\",\"error\":{\"code\":\"model_not_available\",\"message\":\"limited preview\"}}\n\n")), | |
| 170 | + | } | |
| 171 | + | out := make(chan Event, 16) | |
| 172 | + | go c.runStream(context.Background(), resp, Request{Model: "gpt-5.6-sol"}, out) | |
| 173 | + | ||
| 174 | + | var got error | |
| 175 | + | for ev := range out { | |
| 176 | + | if done, ok := ev.(EventDone); ok { | |
| 177 | + | got = done.Err | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | if got == nil || got.Error() != "codex error: limited preview" { | |
| 181 | + | t.Fatalf("error = %v", got) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func TestCodexSolKeepsZotShape(t *testing.T) { | |
| 186 | + | c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient) | |
| 187 | + | var gotReq *http.Request | |
| 188 | + | var body bytes.Buffer | |
| 189 | + | c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { | |
| 190 | + | gotReq = r | |
| 191 | + | _, _ = body.ReadFrom(r.Body) | |
| 192 | + | return &http.Response{ | |
| 193 | + | StatusCode: http.StatusOK, | |
| 194 | + | Header: http.Header{"Content-Type": []string{"text/event-stream"}}, | |
| 195 | + | Body: io.NopCloser(strings.NewReader("")), | |
| 196 | + | }, nil | |
| 197 | + | }) | |
| 198 | + | ||
| 199 | + | events, err := c.Stream(context.Background(), Request{ | |
| 200 | + | Model: "gpt-5.6-sol", | |
| 201 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | |
| 202 | + | }) | |
| 203 | + | if err != nil { | |
| 204 | + | t.Fatal(err) | |
| 205 | + | } | |
| 206 | + | for range events { | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | if gotReq == nil { | |
| 210 | + | t.Fatal("request was not sent") | |
| 211 | + | } | |
| 212 | + | if gotReq.Header.Get("originator") != "zot" { | |
| 213 | + | t.Fatalf("originator = %q", gotReq.Header.Get("originator")) | |
| 214 | + | } | |
| 215 | + | if gotReq.Header.Get("session-id") != "" { | |
| 216 | + | t.Fatalf("session-id = %q", gotReq.Header.Get("session-id")) | |
| 217 | + | } | |
| 218 | + | if strings.Contains(body.String(), "prompt_cache_key") { | |
| 219 | + | t.Fatalf("Sol request unexpectedly included prompt_cache_key: %s", body.String()) | |
| 220 | + | } | |
| 221 | + | } | |
| @@ -1,6 +1,11 @@ | |||||
| 1 | package provider | 1 | package provider | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "io" | |||
| 8 | + | "net/http" | |||
| 4 | "strings" | 9 | "strings" | ||
| 5 | "testing" | 10 | "testing" | ||
| 6 | ) | 11 | ) | ||
| @@ -61,3 +66,156 @@ func TestCodexImageToolResultMirror(t *testing.T) { | |||||
| 61 | t.Fatalf("mirrored user image was not serialized as input_image") | 66 | t.Fatalf("mirrored user image was not serialized as input_image") | ||
| 62 | } | 67 | } | ||
| 63 | } | 68 | } | ||
| 69 | + | ||||
| 70 | + | type roundTripFunc func(*http.Request) (*http.Response, error) | |||
| 71 | + | ||||
| 72 | + | func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } | |||
| 73 | + | ||||
| 74 | + | func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) { | |||
| 75 | + | c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient) | |||
| 76 | + | var gotReq *http.Request | |||
| 77 | + | var gotBody codexRequest | |||
| 78 | + | c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { | |||
| 79 | + | gotReq = r | |||
| 80 | + | if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { | |||
| 81 | + | t.Fatal(err) | |||
| 82 | + | } | |||
| 83 | + | return &http.Response{ | |||
| 84 | + | StatusCode: http.StatusOK, | |||
| 85 | + | Header: http.Header{"Content-Type": []string{"text/event-stream"}}, | |||
| 86 | + | Body: io.NopCloser(strings.NewReader("")), | |||
| 87 | + | }, nil | |||
| 88 | + | }) | |||
| 89 | + | ||||
| 90 | + | events, err := c.Stream(context.Background(), Request{ | |||
| 91 | + | Model: "gpt-5.6-terra", | |||
| 92 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | |||
| 93 | + | }) | |||
| 94 | + | if err != nil { | |||
| 95 | + | t.Fatal(err) | |||
| 96 | + | } | |||
| 97 | + | for range events { | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | if gotReq == nil { | |||
| 101 | + | t.Fatal("request was not sent") | |||
| 102 | + | } | |||
| 103 | + | if gotReq.Header.Get("originator") != "codex_cli_rs" { | |||
| 104 | + | t.Fatalf("originator = %q", gotReq.Header.Get("originator")) | |||
| 105 | + | } | |||
| 106 | + | if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" { | |||
| 107 | + | t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent")) | |||
| 108 | + | } | |||
| 109 | + | if gotBody.PromptCacheKey == "" { | |||
| 110 | + | t.Fatal("prompt_cache_key was not set") | |||
| 111 | + | } | |||
| 112 | + | if gotReq.Header.Get("session-id") != gotBody.PromptCacheKey { | |||
| 113 | + | t.Fatalf("session-id = %q, prompt_cache_key = %q", gotReq.Header.Get("session-id"), gotBody.PromptCacheKey) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) { | |||
| 118 | + | c := NewOpenAICodex("token", "acct", "").(*codexClient) | |||
| 119 | + | wire, err := c.buildRequest(Request{Model: "gpt-5.6-sol", Reasoning: "max"}) | |||
| 120 | + | if err != nil { | |||
| 121 | + | t.Fatal(err) | |||
| 122 | + | } | |||
| 123 | + | if wire.Reasoning == nil || wire.Reasoning.Effort != "max" { | |||
| 124 | + | t.Fatalf("reasoning = %+v", wire.Reasoning) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) { | |||
| 129 | + | named := NewOpenAIResponsesNamed("token", "https://example.test/v1/responses", "openai").(*renamedClient) | |||
| 130 | + | c := named.inner.(*codexClient) | |||
| 131 | + | var gotReq *http.Request | |||
| 132 | + | var gotBody codexRequest | |||
| 133 | + | c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { | |||
| 134 | + | gotReq = r | |||
| 135 | + | if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { | |||
| 136 | + | t.Fatal(err) | |||
| 137 | + | } | |||
| 138 | + | return &http.Response{ | |||
| 139 | + | StatusCode: http.StatusOK, | |||
| 140 | + | Header: http.Header{"Content-Type": []string{"text/event-stream"}}, | |||
| 141 | + | Body: io.NopCloser(strings.NewReader("")), | |||
| 142 | + | }, nil | |||
| 143 | + | }) | |||
| 144 | + | ||||
| 145 | + | events, err := c.Stream(context.Background(), Request{ | |||
| 146 | + | Model: "gpt-5.6-sol", | |||
| 147 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | |||
| 148 | + | }) | |||
| 149 | + | if err != nil { | |||
| 150 | + | t.Fatal(err) | |||
| 151 | + | } | |||
| 152 | + | for range events { | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | if gotReq == nil { | |||
| 156 | + | t.Fatal("request was not sent") | |||
| 157 | + | } | |||
| 158 | + | if gotReq.Header.Get("session-id") != "" { | |||
| 159 | + | t.Fatalf("session-id = %q", gotReq.Header.Get("session-id")) | |||
| 160 | + | } | |||
| 161 | + | if gotBody.PromptCacheKey != "" { | |||
| 162 | + | t.Fatalf("prompt_cache_key = %q", gotBody.PromptCacheKey) | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func TestCodexNestedStreamError(t *testing.T) { | |||
| 167 | + | c := NewOpenAICodex("token", "acct", "").(*codexClient) | |||
| 168 | + | resp := &http.Response{ | |||
| 169 | + | Body: io.NopCloser(strings.NewReader("data: {\"type\":\"error\",\"error\":{\"code\":\"model_not_available\",\"message\":\"limited preview\"}}\n\n")), | |||
| 170 | + | } | |||
| 171 | + | out := make(chan Event, 16) | |||
| 172 | + | go c.runStream(context.Background(), resp, Request{Model: "gpt-5.6-sol"}, out) | |||
| 173 | + | ||||
| 174 | + | var got error | |||
| 175 | + | for ev := range out { | |||
| 176 | + | if done, ok := ev.(EventDone); ok { | |||
| 177 | + | got = done.Err | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | if got == nil || got.Error() != "codex error: limited preview" { | |||
| 181 | + | t.Fatalf("error = %v", got) | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func TestCodexSolKeepsZotShape(t *testing.T) { | |||
| 186 | + | c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient) | |||
| 187 | + | var gotReq *http.Request | |||
| 188 | + | var body bytes.Buffer | |||
| 189 | + | c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { | |||
| 190 | + | gotReq = r | |||
| 191 | + | _, _ = body.ReadFrom(r.Body) | |||
| 192 | + | return &http.Response{ | |||
| 193 | + | StatusCode: http.StatusOK, | |||
| 194 | + | Header: http.Header{"Content-Type": []string{"text/event-stream"}}, | |||
| 195 | + | Body: io.NopCloser(strings.NewReader("")), | |||
| 196 | + | }, nil | |||
| 197 | + | }) | |||
| 198 | + | ||||
| 199 | + | events, err := c.Stream(context.Background(), Request{ | |||
| 200 | + | Model: "gpt-5.6-sol", | |||
| 201 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | |||
| 202 | + | }) | |||
| 203 | + | if err != nil { | |||
| 204 | + | t.Fatal(err) | |||
| 205 | + | } | |||
| 206 | + | for range events { | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | if gotReq == nil { | |||
| 210 | + | t.Fatal("request was not sent") | |||
| 211 | + | } | |||
| 212 | + | if gotReq.Header.Get("originator") != "zot" { | |||
| 213 | + | t.Fatalf("originator = %q", gotReq.Header.Get("originator")) | |||
| 214 | + | } | |||
| 215 | + | if gotReq.Header.Get("session-id") != "" { | |||
| 216 | + | t.Fatalf("session-id = %q", gotReq.Header.Get("session-id")) | |||
| 217 | + | } | |||
| 218 | + | if strings.Contains(body.String(), "prompt_cache_key") { | |||
| 219 | + | t.Fatalf("Sol request unexpectedly included prompt_cache_key: %s", body.String()) | |||
| 220 | + | } | |||
| 221 | + | } | |||
M packages/provider/openai_responses.go +14 -5
| @@ -48,6 +48,13 @@ func (t *openaiResponsesTransport) RoundTrip(req *http.Request) (*http.Response, | |||
| 48 | 48 | // | |
| 49 | 49 | // baseURL may be empty; defaults to https://api.openai.com/v1/responses. | |
| 50 | 50 | func NewOpenAIResponses(apiKey, baseURL string) Client { | |
| 51 | + | return NewOpenAIResponsesNamed(apiKey, baseURL, "openai-responses") | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // NewOpenAIResponsesNamed returns a public OpenAI Responses-API client | |
| 55 | + | // reporting the supplied provider name. This lets the `openai` provider | |
| 56 | + | // route Responses-only preview models without changing the visible provider. | |
| 57 | + | func NewOpenAIResponsesNamed(apiKey, baseURL, name string) Client { | |
| 51 | 58 | if baseURL == "" { | |
| 52 | 59 | baseURL = openaiResponsesDefaultBaseURL | |
| 53 | 60 | } | |
| @@ -56,10 +63,12 @@ func NewOpenAIResponses(apiKey, baseURL string) Client { | |||
| 56 | 63 | Timeout: 0, | |
| 57 | 64 | } | |
| 58 | 65 | inner := &codexClient{ | |
| 59 | - | token: apiKey, | |
| 60 | - | accountID: "", // unused; transport strips the header | |
| 61 | - | baseURL: strings.TrimRight(baseURL, "/"), | |
| 62 | - | http: httpClient, | |
| 66 | + | token: apiKey, | |
| 67 | + | accountID: "", // unused; transport strips the header | |
| 68 | + | baseURL: strings.TrimRight(baseURL, "/"), | |
| 69 | + | errorLabel: "openai", | |
| 70 | + | providerName: name, | |
| 71 | + | http: httpClient, | |
| 63 | 72 | } | |
| 64 | - | return &renamedClient{inner: inner, name: "openai-responses"} | |
| 73 | + | return &renamedClient{inner: inner, name: name} | |
| 65 | 74 | } | |
| @@ -48,6 +48,13 @@ func (t *openaiResponsesTransport) RoundTrip(req *http.Request) (*http.Response, | |||||
| 48 | // | 48 | // | ||
| 49 | // baseURL may be empty; defaults to https://api.openai.com/v1/responses. | 49 | // baseURL may be empty; defaults to https://api.openai.com/v1/responses. | ||
| 50 | func NewOpenAIResponses(apiKey, baseURL string) Client { | 50 | func NewOpenAIResponses(apiKey, baseURL string) Client { | ||
| 51 | + | return NewOpenAIResponsesNamed(apiKey, baseURL, "openai-responses") | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // NewOpenAIResponsesNamed returns a public OpenAI Responses-API client | |||
| 55 | + | // reporting the supplied provider name. This lets the `openai` provider | |||
| 56 | + | // route Responses-only preview models without changing the visible provider. | |||
| 57 | + | func NewOpenAIResponsesNamed(apiKey, baseURL, name string) Client { | |||
| 51 | if baseURL == "" { | 58 | if baseURL == "" { | ||
| 52 | baseURL = openaiResponsesDefaultBaseURL | 59 | baseURL = openaiResponsesDefaultBaseURL | ||
| 53 | } | 60 | } | ||
| @@ -56,10 +63,12 @@ func NewOpenAIResponses(apiKey, baseURL string) Client { | |||||
| 56 | Timeout: 0, | 63 | Timeout: 0, | ||
| 57 | } | 64 | } | ||
| 58 | inner := &codexClient{ | 65 | inner := &codexClient{ | ||
| 59 | - | token: apiKey, | 66 | + | token: apiKey, |
| 60 | - | accountID: "", // unused; transport strips the header | 67 | + | accountID: "", // unused; transport strips the header |
| 61 | - | baseURL: strings.TrimRight(baseURL, "/"), | 68 | + | baseURL: strings.TrimRight(baseURL, "/"), |
| 62 | - | http: httpClient, | 69 | + | errorLabel: "openai", |
| 70 | + | providerName: name, | |||
| 71 | + | http: httpClient, | |||
| 63 | } | 72 | } | ||
| 64 | - | return &renamedClient{inner: inner, name: "openai-responses"} | 73 | + | return &renamedClient{inner: inner, name: name} |
| 65 | } | 74 | } | ||
M packages/provider/provider.go +3 -4
| @@ -216,10 +216,9 @@ type Request struct { | |||
| 216 | 216 | Tools []Tool | |
| 217 | 217 | MaxTokens int | |
| 218 | 218 | Temperature *float32 | |
| 219 | - | // Reasoning is "", "minimum", "low", "medium", "high", or "maximum". | |
| 220 | - | // Empty disables reasoning. Budget-based providers map these to roughly | |
| 221 | - | // 1k/2k/8k/16k/32k thinking tokens; effort-based providers map them onto | |
| 222 | - | // their closest supported reasoning_effort values. | |
| 219 | + | // Reasoning is "", "minimum", "low", "medium", "high", "xhigh", or | |
| 220 | + | // "max". Empty disables reasoning. The max tier is sent natively only to | |
| 221 | + | // models that support it and clamped elsewhere. | |
| 223 | 222 | Reasoning string | |
| 224 | 223 | } | |
| 225 | 224 | ||
| @@ -216,10 +216,9 @@ type Request struct { | |||||
| 216 | Tools []Tool | 216 | Tools []Tool | ||
| 217 | MaxTokens int | 217 | MaxTokens int | ||
| 218 | Temperature *float32 | 218 | Temperature *float32 | ||
| 219 | - | // Reasoning is "", "minimum", "low", "medium", "high", or "maximum". | 219 | + | // Reasoning is "", "minimum", "low", "medium", "high", "xhigh", or |
| 220 | - | // Empty disables reasoning. Budget-based providers map these to roughly | 220 | + | // "max". Empty disables reasoning. The max tier is sent natively only to |
| 221 | - | // 1k/2k/8k/16k/32k thinking tokens; effort-based providers map them onto | 221 | + | // models that support it and clamped elsewhere. |
| 222 | - | // their closest supported reasoning_effort values. | |||
| 223 | Reasoning string | 222 | Reasoning string | ||
| 224 | } | 223 | } | ||
| 225 | 224 | ||||
M packages/provider/provider_test.go +50 -0
| @@ -118,6 +118,19 @@ func TestAnthropicAdaptiveThinking(t *testing.T) { | |||
| 118 | 118 | t.Fatalf("want effort=xhigh, got %+v", wire.OutputConfig) | |
| 119 | 119 | } | |
| 120 | 120 | ||
| 121 | + | // max is a separate native tier above xhigh on adaptive models. | |
| 122 | + | wire, err = c.buildRequest(Request{ | |
| 123 | + | Model: "claude-opus-4-8", | |
| 124 | + | Reasoning: "max", | |
| 125 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | |
| 126 | + | }) | |
| 127 | + | if err != nil { | |
| 128 | + | t.Fatal(err) | |
| 129 | + | } | |
| 130 | + | if wire.OutputConfig == nil || wire.OutputConfig.Effort != "max" { | |
| 131 | + | t.Fatalf("want effort=max, got %+v", wire.OutputConfig) | |
| 132 | + | } | |
| 133 | + | ||
| 121 | 134 | // Sonnet 5 -> adaptive thinking, effort set, no budget, no temperature. | |
| 122 | 135 | wire, err = c.buildRequest(Request{ | |
| 123 | 136 | Model: "claude-sonnet-5", | |
| @@ -385,6 +398,43 @@ func TestDiscoverOpenRouter(t *testing.T) { | |||
| 385 | 398 | } | |
| 386 | 399 | } | |
| 387 | 400 | ||
| 401 | + | func TestGPT56CatalogEntries(t *testing.T) { | |
| 402 | + | cases := []struct { | |
| 403 | + | provider string | |
| 404 | + | id string | |
| 405 | + | context int | |
| 406 | + | priceInput float64 | |
| 407 | + | priceOut float64 | |
| 408 | + | cacheRead float64 | |
| 409 | + | cacheWrite float64 | |
| 410 | + | }{ | |
| 411 | + | {"openai", "gpt-5.6-luna", 272000, 1, 6, 0.1, 1.25}, | |
| 412 | + | {"openai", "gpt-5.6-sol", 272000, 5, 30, 0.5, 6.25}, | |
| 413 | + | {"openai", "gpt-5.6-terra", 272000, 2.5, 15, 0.25, 3.125}, | |
| 414 | + | {"openai-codex", "gpt-5.6-luna", 372000, 1, 6, 0.1, 1.25}, | |
| 415 | + | {"openai-codex", "gpt-5.6-sol", 372000, 5, 30, 0.5, 6.25}, | |
| 416 | + | {"openai-codex", "gpt-5.6-terra", 372000, 2.5, 15, 0.25, 3.125}, | |
| 417 | + | {"azure-openai-responses", "gpt-5.6-luna", 1050000, 1, 6, 0.1, 1.25}, | |
| 418 | + | {"azure-openai-responses", "gpt-5.6-sol", 1050000, 5, 30, 0.5, 6.25}, | |
| 419 | + | {"azure-openai-responses", "gpt-5.6-terra", 1050000, 2.5, 15, 0.25, 3.125}, | |
| 420 | + | {"vercel-ai-gateway", "openai/gpt-5.6-luna", 1050000, 1, 6, 0.1, 1.25}, | |
| 421 | + | {"vercel-ai-gateway", "openai/gpt-5.6-sol", 1050000, 5, 30, 0.5, 6.25}, | |
| 422 | + | {"vercel-ai-gateway", "openai/gpt-5.6-terra", 1050000, 2.5, 15, 0.25, 3.125}, | |
| 423 | + | } | |
| 424 | + | for _, tc := range cases { | |
| 425 | + | m, err := FindModel(tc.provider, tc.id) | |
| 426 | + | if err != nil { | |
| 427 | + | t.Fatal(err) | |
| 428 | + | } | |
| 429 | + | if m.ContextWindow != tc.context || m.MaxOutput != 128000 || !m.Reasoning { | |
| 430 | + | t.Fatalf("%s/%s limits: %+v", tc.provider, tc.id, m) | |
| 431 | + | } | |
| 432 | + | if m.PriceInput != tc.priceInput || m.PriceOutput != tc.priceOut || m.PriceCacheRead != tc.cacheRead || m.PriceCacheWrite != tc.cacheWrite { | |
| 433 | + | t.Fatalf("%s/%s prices: %+v", tc.provider, tc.id, m) | |
| 434 | + | } | |
| 435 | + | } | |
| 436 | + | } | |
| 437 | + | ||
| 388 | 438 | // TestOpenAIOmitsZeroMaxTokens guards against sending max_tokens: 0 for | |
| 389 | 439 | // discovered models that don't advertise an output cap (MaxOutput == 0). | |
| 390 | 440 | func TestOpenAIOmitsZeroMaxTokens(t *testing.T) { | |
| @@ -118,6 +118,19 @@ func TestAnthropicAdaptiveThinking(t *testing.T) { | |||||
| 118 | t.Fatalf("want effort=xhigh, got %+v", wire.OutputConfig) | 118 | t.Fatalf("want effort=xhigh, got %+v", wire.OutputConfig) | ||
| 119 | } | 119 | } | ||
| 120 | 120 | ||||
| 121 | + | // max is a separate native tier above xhigh on adaptive models. | |||
| 122 | + | wire, err = c.buildRequest(Request{ | |||
| 123 | + | Model: "claude-opus-4-8", | |||
| 124 | + | Reasoning: "max", | |||
| 125 | + | Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, | |||
| 126 | + | }) | |||
| 127 | + | if err != nil { | |||
| 128 | + | t.Fatal(err) | |||
| 129 | + | } | |||
| 130 | + | if wire.OutputConfig == nil || wire.OutputConfig.Effort != "max" { | |||
| 131 | + | t.Fatalf("want effort=max, got %+v", wire.OutputConfig) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 121 | // Sonnet 5 -> adaptive thinking, effort set, no budget, no temperature. | 134 | // Sonnet 5 -> adaptive thinking, effort set, no budget, no temperature. | ||
| 122 | wire, err = c.buildRequest(Request{ | 135 | wire, err = c.buildRequest(Request{ | ||
| 123 | Model: "claude-sonnet-5", | 136 | Model: "claude-sonnet-5", | ||
| @@ -385,6 +398,43 @@ func TestDiscoverOpenRouter(t *testing.T) { | |||||
| 385 | } | 398 | } | ||
| 386 | } | 399 | } | ||
| 387 | 400 | ||||
| 401 | + | func TestGPT56CatalogEntries(t *testing.T) { | |||
| 402 | + | cases := []struct { | |||
| 403 | + | provider string | |||
| 404 | + | id string | |||
| 405 | + | context int | |||
| 406 | + | priceInput float64 | |||
| 407 | + | priceOut float64 | |||
| 408 | + | cacheRead float64 | |||
| 409 | + | cacheWrite float64 | |||
| 410 | + | }{ | |||
| 411 | + | {"openai", "gpt-5.6-luna", 272000, 1, 6, 0.1, 1.25}, | |||
| 412 | + | {"openai", "gpt-5.6-sol", 272000, 5, 30, 0.5, 6.25}, | |||
| 413 | + | {"openai", "gpt-5.6-terra", 272000, 2.5, 15, 0.25, 3.125}, | |||
| 414 | + | {"openai-codex", "gpt-5.6-luna", 372000, 1, 6, 0.1, 1.25}, | |||
| 415 | + | {"openai-codex", "gpt-5.6-sol", 372000, 5, 30, 0.5, 6.25}, | |||
| 416 | + | {"openai-codex", "gpt-5.6-terra", 372000, 2.5, 15, 0.25, 3.125}, | |||
| 417 | + | {"azure-openai-responses", "gpt-5.6-luna", 1050000, 1, 6, 0.1, 1.25}, | |||
| 418 | + | {"azure-openai-responses", "gpt-5.6-sol", 1050000, 5, 30, 0.5, 6.25}, | |||
| 419 | + | {"azure-openai-responses", "gpt-5.6-terra", 1050000, 2.5, 15, 0.25, 3.125}, | |||
| 420 | + | {"vercel-ai-gateway", "openai/gpt-5.6-luna", 1050000, 1, 6, 0.1, 1.25}, | |||
| 421 | + | {"vercel-ai-gateway", "openai/gpt-5.6-sol", 1050000, 5, 30, 0.5, 6.25}, | |||
| 422 | + | {"vercel-ai-gateway", "openai/gpt-5.6-terra", 1050000, 2.5, 15, 0.25, 3.125}, | |||
| 423 | + | } | |||
| 424 | + | for _, tc := range cases { | |||
| 425 | + | m, err := FindModel(tc.provider, tc.id) | |||
| 426 | + | if err != nil { | |||
| 427 | + | t.Fatal(err) | |||
| 428 | + | } | |||
| 429 | + | if m.ContextWindow != tc.context || m.MaxOutput != 128000 || !m.Reasoning { | |||
| 430 | + | t.Fatalf("%s/%s limits: %+v", tc.provider, tc.id, m) | |||
| 431 | + | } | |||
| 432 | + | if m.PriceInput != tc.priceInput || m.PriceOutput != tc.priceOut || m.PriceCacheRead != tc.cacheRead || m.PriceCacheWrite != tc.cacheWrite { | |||
| 433 | + | t.Fatalf("%s/%s prices: %+v", tc.provider, tc.id, m) | |||
| 434 | + | } | |||
| 435 | + | } | |||
| 436 | + | } | |||
| 437 | + | ||||
| 388 | // TestOpenAIOmitsZeroMaxTokens guards against sending max_tokens: 0 for | 438 | // TestOpenAIOmitsZeroMaxTokens guards against sending max_tokens: 0 for | ||
| 389 | // discovered models that don't advertise an output cap (MaxOutput == 0). | 439 | // discovered models that don't advertise an output cap (MaxOutput == 0). | ||
| 390 | func TestOpenAIOmitsZeroMaxTokens(t *testing.T) { | 440 | func TestOpenAIOmitsZeroMaxTokens(t *testing.T) { | ||
M packages/provider/reasoning.go +31 -28
| @@ -3,7 +3,8 @@ package provider | |||
| 3 | 3 | import "strings" | |
| 4 | 4 | ||
| 5 | 5 | // NormalizeReasoning canonicalizes zot's user-facing thinking levels. | |
| 6 | - | // Empty string means reasoning/thinking is disabled. | |
| 6 | + | // Empty string means reasoning/thinking is disabled. "maximum" remains | |
| 7 | + | // an alias for xhigh; "max" is the separate opt-in tier above it. | |
| 7 | 8 | func NormalizeReasoning(level string) string { | |
| 8 | 9 | switch strings.ToLower(strings.TrimSpace(level)) { | |
| 9 | 10 | case "", "off", "none", "no", "false", "disabled": | |
| @@ -16,8 +17,10 @@ func NormalizeReasoning(level string) string { | |||
| 16 | 17 | return "medium" | |
| 17 | 18 | case "hi", "high": | |
| 18 | 19 | return "high" | |
| 19 | - | case "max", "maximum": | |
| 20 | - | return "maximum" | |
| 20 | + | case "xhigh", "maximum": | |
| 21 | + | return "xhigh" | |
| 22 | + | case "max": | |
| 23 | + | return "max" | |
| 21 | 24 | default: | |
| 22 | 25 | return strings.ToLower(strings.TrimSpace(level)) | |
| 23 | 26 | } | |
| @@ -35,7 +38,7 @@ func ReasoningBudget(level string) int { | |||
| 35 | 38 | return 8192 | |
| 36 | 39 | case "high": | |
| 37 | 40 | return 16384 | |
| 38 | - | case "maximum": | |
| 41 | + | case "xhigh", "max": | |
| 39 | 42 | return 32768 | |
| 40 | 43 | default: | |
| 41 | 44 | return 0 | |
| @@ -43,10 +46,8 @@ func ReasoningBudget(level string) int { | |||
| 43 | 46 | } | |
| 44 | 47 | ||
| 45 | 48 | // AnthropicAdaptiveEffort maps zot's user-facing thinking levels onto the | |
| 46 | - | // effort enum used by Anthropic's adaptive-thinking models (Opus 4.7+). | |
| 47 | - | // These models reject explicit thinking budgets; thinking depth is | |
| 48 | - | // controlled by output_config.effort instead. Returns "" when reasoning | |
| 49 | - | // is disabled. | |
| 49 | + | // effort enum used by adaptive-thinking models. These models reject explicit | |
| 50 | + | // thinking budgets; reasoning depth is controlled by output_config.effort. | |
| 50 | 51 | func AnthropicAdaptiveEffort(level string) string { | |
| 51 | 52 | switch NormalizeReasoning(level) { | |
| 52 | 53 | case "minimum", "low": | |
| @@ -55,38 +56,34 @@ func AnthropicAdaptiveEffort(level string) string { | |||
| 55 | 56 | return "medium" | |
| 56 | 57 | case "high": | |
| 57 | 58 | return "high" | |
| 58 | - | case "maximum": | |
| 59 | + | case "xhigh": | |
| 59 | 60 | return "xhigh" | |
| 61 | + | case "max": | |
| 62 | + | return "max" | |
| 60 | 63 | default: | |
| 61 | 64 | return "" | |
| 62 | 65 | } | |
| 63 | 66 | } | |
| 64 | 67 | ||
| 65 | - | // OpenAIReasoningEffort maps zot's six-level setting onto the effort enum | |
| 66 | - | // accepted by OpenAI-compatible chat-completions endpoints. | |
| 68 | + | // OpenAIReasoningEffort maps zot's thinking setting onto the effort enum | |
| 69 | + | // accepted by generic OpenAI-compatible chat-completions endpoints. | |
| 67 | 70 | func OpenAIReasoningEffort(level string) string { | |
| 68 | 71 | switch NormalizeReasoning(level) { | |
| 69 | 72 | case "minimum", "low": | |
| 70 | - | // Many OpenAI-compatible endpoints only accept low/medium/high. | |
| 71 | - | // Use low for zot's minimum instead of the newer minimal enum. | |
| 73 | + | // Many compatible endpoints only accept low/medium/high. | |
| 72 | 74 | return "low" | |
| 73 | 75 | case "medium": | |
| 74 | 76 | return "medium" | |
| 75 | - | case "high", "maximum": | |
| 77 | + | case "high", "xhigh", "max": | |
| 76 | 78 | return "high" | |
| 77 | 79 | default: | |
| 78 | 80 | return "" | |
| 79 | 81 | } | |
| 80 | 82 | } | |
| 81 | 83 | ||
| 82 | - | // OpenAICompatAnthropicEffort maps zot's user-facing thinking levels | |
| 83 | - | // onto reasoning_effort values when an adaptive-thinking Anthropic | |
| 84 | - | // model (Opus 4.7+) is served over the OpenAI-compatible chat- | |
| 85 | - | // completions wire (openrouter, opencode, ...). Differs from | |
| 86 | - | // OpenAIReasoningEffort only at the top: zot's "maximum" maps to | |
| 87 | - | // "xhigh" instead of being clamped to "high", so the model's full | |
| 88 | - | // adaptive-thinking ceiling is preserved when reachable through a | |
| 89 | - | // gateway that accepts the effort knob. | |
| 84 | + | // OpenAICompatAnthropicEffort maps zot's thinking setting when an adaptive | |
| 85 | + | // Anthropic model is served over an OpenAI-compatible chat-completions wire. | |
| 86 | + | // Adaptive models accept native xhigh and max effort values. | |
| 90 | 87 | func OpenAICompatAnthropicEffort(level string) string { | |
| 91 | 88 | switch NormalizeReasoning(level) { | |
| 92 | 89 | case "minimum", "low": | |
| @@ -95,17 +92,18 @@ func OpenAICompatAnthropicEffort(level string) string { | |||
| 95 | 92 | return "medium" | |
| 96 | 93 | case "high": | |
| 97 | 94 | return "high" | |
| 98 | - | case "maximum": | |
| 95 | + | case "xhigh": | |
| 99 | 96 | return "xhigh" | |
| 97 | + | case "max": | |
| 98 | + | return "max" | |
| 100 | 99 | default: | |
| 101 | 100 | return "" | |
| 102 | 101 | } | |
| 103 | 102 | } | |
| 104 | 103 | ||
| 105 | - | // OpenAICodexReasoningEffort maps zot levels onto the ChatGPT/Codex | |
| 106 | - | // Responses backend enum. That backend rejects "minimal" and uses | |
| 107 | - | // "xhigh" for the highest tier on recent GPT-5.x models. | |
| 108 | - | func OpenAICodexReasoningEffort(level string) string { | |
| 104 | + | // OpenAICodexReasoningEffort maps zot levels onto the Responses API effort | |
| 105 | + | // enum. GPT-5.6 supports native max; other models clamp max to xhigh. | |
| 106 | + | func OpenAICodexReasoningEffort(level, model string) string { | |
| 109 | 107 | switch NormalizeReasoning(level) { | |
| 110 | 108 | case "minimum", "low": | |
| 111 | 109 | return "low" | |
| @@ -113,7 +111,12 @@ func OpenAICodexReasoningEffort(level string) string { | |||
| 113 | 111 | return "medium" | |
| 114 | 112 | case "high": | |
| 115 | 113 | return "high" | |
| 116 | - | case "maximum": | |
| 114 | + | case "xhigh": | |
| 115 | + | return "xhigh" | |
| 116 | + | case "max": | |
| 117 | + | if strings.HasPrefix(strings.ToLower(model), "gpt-5.6-") { | |
| 118 | + | return "max" | |
| 119 | + | } | |
| 117 | 120 | return "xhigh" | |
| 118 | 121 | default: | |
| 119 | 122 | return "" | |
| @@ -3,7 +3,8 @@ package provider | |||||
| 3 | import "strings" | 3 | import "strings" | ||
| 4 | 4 | ||||
| 5 | // NormalizeReasoning canonicalizes zot's user-facing thinking levels. | 5 | // NormalizeReasoning canonicalizes zot's user-facing thinking levels. | ||
| 6 | - | // Empty string means reasoning/thinking is disabled. | 6 | + | // Empty string means reasoning/thinking is disabled. "maximum" remains |
| 7 | + | // an alias for xhigh; "max" is the separate opt-in tier above it. | |||
| 7 | func NormalizeReasoning(level string) string { | 8 | func NormalizeReasoning(level string) string { | ||
| 8 | switch strings.ToLower(strings.TrimSpace(level)) { | 9 | switch strings.ToLower(strings.TrimSpace(level)) { | ||
| 9 | case "", "off", "none", "no", "false", "disabled": | 10 | case "", "off", "none", "no", "false", "disabled": | ||
| @@ -16,8 +17,10 @@ func NormalizeReasoning(level string) string { | |||||
| 16 | return "medium" | 17 | return "medium" | ||
| 17 | case "hi", "high": | 18 | case "hi", "high": | ||
| 18 | return "high" | 19 | return "high" | ||
| 19 | - | case "max", "maximum": | 20 | + | case "xhigh", "maximum": |
| 20 | - | return "maximum" | 21 | + | return "xhigh" |
| 22 | + | case "max": | |||
| 23 | + | return "max" | |||
| 21 | default: | 24 | default: | ||
| 22 | return strings.ToLower(strings.TrimSpace(level)) | 25 | return strings.ToLower(strings.TrimSpace(level)) | ||
| 23 | } | 26 | } | ||
| @@ -35,7 +38,7 @@ func ReasoningBudget(level string) int { | |||||
| 35 | return 8192 | 38 | return 8192 | ||
| 36 | case "high": | 39 | case "high": | ||
| 37 | return 16384 | 40 | return 16384 | ||
| 38 | - | case "maximum": | 41 | + | case "xhigh", "max": |
| 39 | return 32768 | 42 | return 32768 | ||
| 40 | default: | 43 | default: | ||
| 41 | return 0 | 44 | return 0 | ||
| @@ -43,10 +46,8 @@ func ReasoningBudget(level string) int { | |||||
| 43 | } | 46 | } | ||
| 44 | 47 | ||||
| 45 | // AnthropicAdaptiveEffort maps zot's user-facing thinking levels onto the | 48 | // AnthropicAdaptiveEffort maps zot's user-facing thinking levels onto the | ||
| 46 | - | // effort enum used by Anthropic's adaptive-thinking models (Opus 4.7+). | 49 | + | // effort enum used by adaptive-thinking models. These models reject explicit |
| 47 | - | // These models reject explicit thinking budgets; thinking depth is | 50 | + | // thinking budgets; reasoning depth is controlled by output_config.effort. |
| 48 | - | // controlled by output_config.effort instead. Returns "" when reasoning | |||
| 49 | - | // is disabled. | |||
| 50 | func AnthropicAdaptiveEffort(level string) string { | 51 | func AnthropicAdaptiveEffort(level string) string { | ||
| 51 | switch NormalizeReasoning(level) { | 52 | switch NormalizeReasoning(level) { | ||
| 52 | case "minimum", "low": | 53 | case "minimum", "low": | ||
| @@ -55,38 +56,34 @@ func AnthropicAdaptiveEffort(level string) string { | |||||
| 55 | return "medium" | 56 | return "medium" | ||
| 56 | case "high": | 57 | case "high": | ||
| 57 | return "high" | 58 | return "high" | ||
| 58 | - | case "maximum": | 59 | + | case "xhigh": |
| 59 | return "xhigh" | 60 | return "xhigh" | ||
| 61 | + | case "max": | |||
| 62 | + | return "max" | |||
| 60 | default: | 63 | default: | ||
| 61 | return "" | 64 | return "" | ||
| 62 | } | 65 | } | ||
| 63 | } | 66 | } | ||
| 64 | 67 | ||||
| 65 | - | // OpenAIReasoningEffort maps zot's six-level setting onto the effort enum | 68 | + | // OpenAIReasoningEffort maps zot's thinking setting onto the effort enum |
| 66 | - | // accepted by OpenAI-compatible chat-completions endpoints. | 69 | + | // accepted by generic OpenAI-compatible chat-completions endpoints. |
| 67 | func OpenAIReasoningEffort(level string) string { | 70 | func OpenAIReasoningEffort(level string) string { | ||
| 68 | switch NormalizeReasoning(level) { | 71 | switch NormalizeReasoning(level) { | ||
| 69 | case "minimum", "low": | 72 | case "minimum", "low": | ||
| 70 | - | // Many OpenAI-compatible endpoints only accept low/medium/high. | 73 | + | // Many compatible endpoints only accept low/medium/high. |
| 71 | - | // Use low for zot's minimum instead of the newer minimal enum. | |||
| 72 | return "low" | 74 | return "low" | ||
| 73 | case "medium": | 75 | case "medium": | ||
| 74 | return "medium" | 76 | return "medium" | ||
| 75 | - | case "high", "maximum": | 77 | + | case "high", "xhigh", "max": |
| 76 | return "high" | 78 | return "high" | ||
| 77 | default: | 79 | default: | ||
| 78 | return "" | 80 | return "" | ||
| 79 | } | 81 | } | ||
| 80 | } | 82 | } | ||
| 81 | 83 | ||||
| 82 | - | // OpenAICompatAnthropicEffort maps zot's user-facing thinking levels | 84 | + | // OpenAICompatAnthropicEffort maps zot's thinking setting when an adaptive |
| 83 | - | // onto reasoning_effort values when an adaptive-thinking Anthropic | 85 | + | // Anthropic model is served over an OpenAI-compatible chat-completions wire. |
| 84 | - | // model (Opus 4.7+) is served over the OpenAI-compatible chat- | 86 | + | // Adaptive models accept native xhigh and max effort values. |
| 85 | - | // completions wire (openrouter, opencode, ...). Differs from | |||
| 86 | - | // OpenAIReasoningEffort only at the top: zot's "maximum" maps to | |||
| 87 | - | // "xhigh" instead of being clamped to "high", so the model's full | |||
| 88 | - | // adaptive-thinking ceiling is preserved when reachable through a | |||
| 89 | - | // gateway that accepts the effort knob. | |||
| 90 | func OpenAICompatAnthropicEffort(level string) string { | 87 | func OpenAICompatAnthropicEffort(level string) string { | ||
| 91 | switch NormalizeReasoning(level) { | 88 | switch NormalizeReasoning(level) { | ||
| 92 | case "minimum", "low": | 89 | case "minimum", "low": | ||
| @@ -95,17 +92,18 @@ func OpenAICompatAnthropicEffort(level string) string { | |||||
| 95 | return "medium" | 92 | return "medium" | ||
| 96 | case "high": | 93 | case "high": | ||
| 97 | return "high" | 94 | return "high" | ||
| 98 | - | case "maximum": | 95 | + | case "xhigh": |
| 99 | return "xhigh" | 96 | return "xhigh" | ||
| 97 | + | case "max": | |||
| 98 | + | return "max" | |||
| 100 | default: | 99 | default: | ||
| 101 | return "" | 100 | return "" | ||
| 102 | } | 101 | } | ||
| 103 | } | 102 | } | ||
| 104 | 103 | ||||
| 105 | - | // OpenAICodexReasoningEffort maps zot levels onto the ChatGPT/Codex | 104 | + | // OpenAICodexReasoningEffort maps zot levels onto the Responses API effort |
| 106 | - | // Responses backend enum. That backend rejects "minimal" and uses | 105 | + | // enum. GPT-5.6 supports native max; other models clamp max to xhigh. |
| 107 | - | // "xhigh" for the highest tier on recent GPT-5.x models. | 106 | + | func OpenAICodexReasoningEffort(level, model string) string { |
| 108 | - | func OpenAICodexReasoningEffort(level string) string { | |||
| 109 | switch NormalizeReasoning(level) { | 107 | switch NormalizeReasoning(level) { | ||
| 110 | case "minimum", "low": | 108 | case "minimum", "low": | ||
| 111 | return "low" | 109 | return "low" | ||
| @@ -113,7 +111,12 @@ func OpenAICodexReasoningEffort(level string) string { | |||||
| 113 | return "medium" | 111 | return "medium" | ||
| 114 | case "high": | 112 | case "high": | ||
| 115 | return "high" | 113 | return "high" | ||
| 116 | - | case "maximum": | 114 | + | case "xhigh": |
| 115 | + | return "xhigh" | |||
| 116 | + | case "max": | |||
| 117 | + | if strings.HasPrefix(strings.ToLower(model), "gpt-5.6-") { | |||
| 118 | + | return "max" | |||
| 119 | + | } | |||
| 117 | return "xhigh" | 120 | return "xhigh" | ||
| 118 | default: | 121 | default: | ||
| 119 | return "" | 122 | return "" | ||
M packages/provider/reasoning_test.go +13 -10
| @@ -5,20 +5,23 @@ import "testing" | |||
| 5 | 5 | func TestReasoningEffortMappings(t *testing.T) { | |
| 6 | 6 | cases := []struct { | |
| 7 | 7 | level string | |
| 8 | + | model string | |
| 8 | 9 | openai string | |
| 9 | 10 | anthCompat string | |
| 10 | 11 | codex string | |
| 11 | 12 | budget int | |
| 12 | 13 | normalized string | |
| 13 | 14 | }{ | |
| 14 | - | {"off", "", "", "", 0, ""}, | |
| 15 | - | {"minimum", "low", "low", "low", 1024, "minimum"}, | |
| 16 | - | {"minimal", "low", "low", "low", 1024, "minimum"}, | |
| 17 | - | {"low", "low", "low", "low", 2048, "low"}, | |
| 18 | - | {"medium", "medium", "medium", "medium", 8192, "medium"}, | |
| 19 | - | {"high", "high", "high", "high", 16384, "high"}, | |
| 20 | - | {"maximum", "high", "xhigh", "xhigh", 32768, "maximum"}, | |
| 21 | - | {"max", "high", "xhigh", "xhigh", 32768, "maximum"}, | |
| 15 | + | {"off", "gpt-5.6-sol", "", "", "", 0, ""}, | |
| 16 | + | {"minimum", "gpt-5.6-sol", "low", "low", "low", 1024, "minimum"}, | |
| 17 | + | {"minimal", "gpt-5.6-sol", "low", "low", "low", 1024, "minimum"}, | |
| 18 | + | {"low", "gpt-5.6-sol", "low", "low", "low", 2048, "low"}, | |
| 19 | + | {"medium", "gpt-5.6-sol", "medium", "medium", "medium", 8192, "medium"}, | |
| 20 | + | {"high", "gpt-5.6-sol", "high", "high", "high", 16384, "high"}, | |
| 21 | + | {"maximum", "gpt-5.6-sol", "high", "xhigh", "xhigh", 32768, "xhigh"}, | |
| 22 | + | {"xhigh", "gpt-5.6-sol", "high", "xhigh", "xhigh", 32768, "xhigh"}, | |
| 23 | + | {"max", "gpt-5.6-sol", "high", "max", "max", 32768, "max"}, | |
| 24 | + | {"max", "gpt-5.5", "high", "max", "xhigh", 32768, "max"}, | |
| 22 | 25 | } | |
| 23 | 26 | for _, tc := range cases { | |
| 24 | 27 | if got := NormalizeReasoning(tc.level); got != tc.normalized { | |
| @@ -30,8 +33,8 @@ func TestReasoningEffortMappings(t *testing.T) { | |||
| 30 | 33 | if got := OpenAICompatAnthropicEffort(tc.level); got != tc.anthCompat { | |
| 31 | 34 | t.Errorf("OpenAICompatAnthropicEffort(%q)=%q want %q", tc.level, got, tc.anthCompat) | |
| 32 | 35 | } | |
| 33 | - | if got := OpenAICodexReasoningEffort(tc.level); got != tc.codex { | |
| 34 | - | t.Errorf("OpenAICodexReasoningEffort(%q)=%q want %q", tc.level, got, tc.codex) | |
| 36 | + | if got := OpenAICodexReasoningEffort(tc.level, tc.model); got != tc.codex { | |
| 37 | + | t.Errorf("OpenAICodexReasoningEffort(%q, %q)=%q want %q", tc.level, tc.model, got, tc.codex) | |
| 35 | 38 | } | |
| 36 | 39 | if got := ReasoningBudget(tc.level); got != tc.budget { | |
| 37 | 40 | t.Errorf("ReasoningBudget(%q)=%d want %d", tc.level, got, tc.budget) | |
| @@ -5,20 +5,23 @@ import "testing" | |||||
| 5 | func TestReasoningEffortMappings(t *testing.T) { | 5 | func TestReasoningEffortMappings(t *testing.T) { | ||
| 6 | cases := []struct { | 6 | cases := []struct { | ||
| 7 | level string | 7 | level string | ||
| 8 | + | model string | |||
| 8 | openai string | 9 | openai string | ||
| 9 | anthCompat string | 10 | anthCompat string | ||
| 10 | codex string | 11 | codex string | ||
| 11 | budget int | 12 | budget int | ||
| 12 | normalized string | 13 | normalized string | ||
| 13 | }{ | 14 | }{ | ||
| 14 | - | {"off", "", "", "", 0, ""}, | 15 | + | {"off", "gpt-5.6-sol", "", "", "", 0, ""}, |
| 15 | - | {"minimum", "low", "low", "low", 1024, "minimum"}, | 16 | + | {"minimum", "gpt-5.6-sol", "low", "low", "low", 1024, "minimum"}, |
| 16 | - | {"minimal", "low", "low", "low", 1024, "minimum"}, | 17 | + | {"minimal", "gpt-5.6-sol", "low", "low", "low", 1024, "minimum"}, |
| 17 | - | {"low", "low", "low", "low", 2048, "low"}, | 18 | + | {"low", "gpt-5.6-sol", "low", "low", "low", 2048, "low"}, |
| 18 | - | {"medium", "medium", "medium", "medium", 8192, "medium"}, | 19 | + | {"medium", "gpt-5.6-sol", "medium", "medium", "medium", 8192, "medium"}, |
| 19 | - | {"high", "high", "high", "high", 16384, "high"}, | 20 | + | {"high", "gpt-5.6-sol", "high", "high", "high", 16384, "high"}, |
| 20 | - | {"maximum", "high", "xhigh", "xhigh", 32768, "maximum"}, | 21 | + | {"maximum", "gpt-5.6-sol", "high", "xhigh", "xhigh", 32768, "xhigh"}, |
| 21 | - | {"max", "high", "xhigh", "xhigh", 32768, "maximum"}, | 22 | + | {"xhigh", "gpt-5.6-sol", "high", "xhigh", "xhigh", 32768, "xhigh"}, |
| 23 | + | {"max", "gpt-5.6-sol", "high", "max", "max", 32768, "max"}, | |||
| 24 | + | {"max", "gpt-5.5", "high", "max", "xhigh", 32768, "max"}, | |||
| 22 | } | 25 | } | ||
| 23 | for _, tc := range cases { | 26 | for _, tc := range cases { | ||
| 24 | if got := NormalizeReasoning(tc.level); got != tc.normalized { | 27 | if got := NormalizeReasoning(tc.level); got != tc.normalized { | ||
| @@ -30,8 +33,8 @@ func TestReasoningEffortMappings(t *testing.T) { | |||||
| 30 | if got := OpenAICompatAnthropicEffort(tc.level); got != tc.anthCompat { | 33 | if got := OpenAICompatAnthropicEffort(tc.level); got != tc.anthCompat { | ||
| 31 | t.Errorf("OpenAICompatAnthropicEffort(%q)=%q want %q", tc.level, got, tc.anthCompat) | 34 | t.Errorf("OpenAICompatAnthropicEffort(%q)=%q want %q", tc.level, got, tc.anthCompat) | ||
| 32 | } | 35 | } | ||
| 33 | - | if got := OpenAICodexReasoningEffort(tc.level); got != tc.codex { | 36 | + | if got := OpenAICodexReasoningEffort(tc.level, tc.model); got != tc.codex { |
| 34 | - | t.Errorf("OpenAICodexReasoningEffort(%q)=%q want %q", tc.level, got, tc.codex) | 37 | + | t.Errorf("OpenAICodexReasoningEffort(%q, %q)=%q want %q", tc.level, tc.model, got, tc.codex) |
| 35 | } | 38 | } | ||
| 36 | if got := ReasoningBudget(tc.level); got != tc.budget { | 39 | if got := ReasoningBudget(tc.level); got != tc.budget { | ||
| 37 | t.Errorf("ReasoningBudget(%q)=%d want %d", tc.level, got, tc.budget) | 40 | t.Errorf("ReasoningBudget(%q)=%d want %d", tc.level, got, tc.budget) | ||
M packages/tui/statusbar_test.go +16 -2
| @@ -85,12 +85,26 @@ func TestStatusBarThinkingLevelBetweenModelAndStats(t *testing.T) { | |||
| 85 | 85 | } | |
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | + | func TestStatusBarUsesThinkingMaxThemeColor(t *testing.T) { | |
| 89 | + | th := Dark | |
| 90 | + | th.ThinkingMax = 201 | |
| 91 | + | lines := StatusBar(StatusBarParams{ | |
| 92 | + | Theme: th, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "max", Cols: 200, | |
| 93 | + | }) | |
| 94 | + | if len(lines) != 1 { | |
| 95 | + | t.Fatalf("lines = %d, want 1", len(lines)) | |
| 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]) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 88 | 102 | func TestStatusBarNarrowKeepsModelAndThinkingTogetherWhenTheyFit(t *testing.T) { | |
| 89 | 103 | lines := StatusBar(StatusBarParams{ | |
| 90 | 104 | Theme: Dark, | |
| 91 | 105 | Provider: "openai-codex", | |
| 92 | 106 | Model: "gpt-5.5", | |
| 93 | - | Reasoning: "maximum", | |
| 107 | + | Reasoning: "xhigh", | |
| 94 | 108 | CWD: "/tmp/x", | |
| 95 | 109 | Usage: provider.Usage{ | |
| 96 | 110 | CostUSD: 0, | |
| @@ -107,7 +121,7 @@ func TestStatusBarNarrowKeepsModelAndThinkingTogetherWhenTheyFit(t *testing.T) { | |||
| 107 | 121 | for i, line := range lines { | |
| 108 | 122 | plain[i] = stripANSI(line) | |
| 109 | 123 | } | |
| 110 | - | if !strings.Contains(plain[0], "(openai-codex) gpt-5.5 thinking: maximum") { | |
| 124 | + | if !strings.Contains(plain[0], "(openai-codex) gpt-5.5 thinking: xhigh") { | |
| 111 | 125 | t.Fatalf("line 1 should contain model and thinking level, got %q", plain[0]) | |
| 112 | 126 | } | |
| 113 | 127 | if !strings.Contains(plain[1], "$0.000 (sub)") || strings.Contains(plain[1], "thinking level") { | |
| @@ -85,12 +85,26 @@ func TestStatusBarThinkingLevelBetweenModelAndStats(t *testing.T) { | |||||
| 85 | } | 85 | } | ||
| 86 | } | 86 | } | ||
| 87 | 87 | ||||
| 88 | + | func TestStatusBarUsesThinkingMaxThemeColor(t *testing.T) { | |||
| 89 | + | th := Dark | |||
| 90 | + | th.ThinkingMax = 201 | |||
| 91 | + | lines := StatusBar(StatusBarParams{ | |||
| 92 | + | Theme: th, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "max", Cols: 200, | |||
| 93 | + | }) | |||
| 94 | + | if len(lines) != 1 { | |||
| 95 | + | t.Fatalf("lines = %d, want 1", len(lines)) | |||
| 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]) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 88 | func TestStatusBarNarrowKeepsModelAndThinkingTogetherWhenTheyFit(t *testing.T) { | 102 | func TestStatusBarNarrowKeepsModelAndThinkingTogetherWhenTheyFit(t *testing.T) { | ||
| 89 | lines := StatusBar(StatusBarParams{ | 103 | lines := StatusBar(StatusBarParams{ | ||
| 90 | Theme: Dark, | 104 | Theme: Dark, | ||
| 91 | Provider: "openai-codex", | 105 | Provider: "openai-codex", | ||
| 92 | Model: "gpt-5.5", | 106 | Model: "gpt-5.5", | ||
| 93 | - | Reasoning: "maximum", | 107 | + | Reasoning: "xhigh", |
| 94 | CWD: "/tmp/x", | 108 | CWD: "/tmp/x", | ||
| 95 | Usage: provider.Usage{ | 109 | Usage: provider.Usage{ | ||
| 96 | CostUSD: 0, | 110 | CostUSD: 0, | ||
| @@ -107,7 +121,7 @@ func TestStatusBarNarrowKeepsModelAndThinkingTogetherWhenTheyFit(t *testing.T) { | |||||
| 107 | for i, line := range lines { | 121 | for i, line := range lines { | ||
| 108 | plain[i] = stripANSI(line) | 122 | plain[i] = stripANSI(line) | ||
| 109 | } | 123 | } | ||
| 110 | - | if !strings.Contains(plain[0], "(openai-codex) gpt-5.5 thinking: maximum") { | 124 | + | if !strings.Contains(plain[0], "(openai-codex) gpt-5.5 thinking: xhigh") { |
| 111 | t.Fatalf("line 1 should contain model and thinking level, got %q", plain[0]) | 125 | t.Fatalf("line 1 should contain model and thinking level, got %q", plain[0]) | ||
| 112 | } | 126 | } | ||
| 113 | 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], "thinking level") { | ||
M packages/tui/theme.go +3 -0
| @@ -49,6 +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 | 53 | SelectionBG int // background for highlighted rows | |
| 53 | 54 | SelectionFG int // foreground for highlighted rows | |
| 54 | 55 | ||
| @@ -159,6 +160,7 @@ var Dark = Theme{ | |||
| 159 | 160 | Error: 203, | |
| 160 | 161 | Warning: 214, | |
| 161 | 162 | Spinner: 183, // soft purple | |
| 163 | + | ThinkingMax: 207, // vivid magenta | |
| 162 | 164 | SelectionBG: 24, // deep blue background | |
| 163 | 165 | SelectionFG: 231, // near-white foreground | |
| 164 | 166 | SpinnerFrames: defaultSpinnerFrames, | |
| @@ -181,6 +183,7 @@ var Light = Theme{ | |||
| 181 | 183 | Error: 160, | |
| 182 | 184 | Warning: 166, | |
| 183 | 185 | Spinner: 91, // purple | |
| 186 | + | ThinkingMax: 127, // deep magenta | |
| 184 | 187 | SelectionBG: 153, // light blue | |
| 185 | 188 | SelectionFG: 232, // near-black | |
| 186 | 189 | SpinnerFrames: defaultSpinnerFrames, | |
| @@ -49,6 +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 | SelectionBG int // background for highlighted rows | 53 | SelectionBG int // background for highlighted rows | ||
| 53 | SelectionFG int // foreground for highlighted rows | 54 | SelectionFG int // foreground for highlighted rows | ||
| 54 | 55 | ||||
| @@ -159,6 +160,7 @@ var Dark = Theme{ | |||||
| 159 | Error: 203, | 160 | Error: 203, | ||
| 160 | Warning: 214, | 161 | Warning: 214, | ||
| 161 | Spinner: 183, // soft purple | 162 | Spinner: 183, // soft purple | ||
| 163 | + | ThinkingMax: 207, // vivid magenta | |||
| 162 | SelectionBG: 24, // deep blue background | 164 | SelectionBG: 24, // deep blue background | ||
| 163 | SelectionFG: 231, // near-white foreground | 165 | SelectionFG: 231, // near-white foreground | ||
| 164 | SpinnerFrames: defaultSpinnerFrames, | 166 | SpinnerFrames: defaultSpinnerFrames, | ||
| @@ -181,6 +183,7 @@ var Light = Theme{ | |||||
| 181 | Error: 160, | 183 | Error: 160, | ||
| 182 | Warning: 166, | 184 | Warning: 166, | ||
| 183 | Spinner: 91, // purple | 185 | Spinner: 91, // purple | ||
| 186 | + | ThinkingMax: 127, // deep magenta | |||
| 184 | SelectionBG: 153, // light blue | 187 | SelectionBG: 153, // light blue | ||
| 185 | SelectionFG: 232, // near-black | 188 | SelectionFG: 232, // near-black | ||
| 186 | SpinnerFrames: defaultSpinnerFrames, | 189 | SpinnerFrames: defaultSpinnerFrames, | ||
M packages/tui/theme_loader.go +7 -0
| @@ -92,6 +92,8 @@ type ThemeOverrides struct { | |||
| 92 | 92 | Error *int `json:"error,omitempty"` | |
| 93 | 93 | Warning *int `json:"warning,omitempty"` | |
| 94 | 94 | Spinner *int `json:"spinner,omitempty"` | |
| 95 | + | ThinkingMax *int `json:"thinking_max,omitempty"` | |
| 96 | + | ThinkingMaxCamel *int `json:"thinkingMax,omitempty"` | |
| 95 | 97 | SelectionBG *int `json:"selection_bg,omitempty"` | |
| 96 | 98 | SelectionFG *int `json:"selection_fg,omitempty"` | |
| 97 | 99 | SpinnerFrames []string `json:"spinner_frames,omitempty"` | |
| @@ -383,6 +385,11 @@ func applyThemeOverrides(th Theme, o ThemeOverrides) Theme { | |||
| 383 | 385 | if o.Spinner != nil { | |
| 384 | 386 | th.Spinner = *o.Spinner | |
| 385 | 387 | } | |
| 388 | + | if o.ThinkingMax != nil { | |
| 389 | + | th.ThinkingMax = *o.ThinkingMax | |
| 390 | + | } else if o.ThinkingMaxCamel != nil { | |
| 391 | + | th.ThinkingMax = *o.ThinkingMaxCamel | |
| 392 | + | } | |
| 386 | 393 | if o.SelectionBG != nil { | |
| 387 | 394 | th.SelectionBG = *o.SelectionBG | |
| 388 | 395 | } | |
| @@ -92,6 +92,8 @@ type ThemeOverrides struct { | |||||
| 92 | Error *int `json:"error,omitempty"` | 92 | Error *int `json:"error,omitempty"` | ||
| 93 | Warning *int `json:"warning,omitempty"` | 93 | Warning *int `json:"warning,omitempty"` | ||
| 94 | Spinner *int `json:"spinner,omitempty"` | 94 | Spinner *int `json:"spinner,omitempty"` | ||
| 95 | + | ThinkingMax *int `json:"thinking_max,omitempty"` | |||
| 96 | + | ThinkingMaxCamel *int `json:"thinkingMax,omitempty"` | |||
| 95 | SelectionBG *int `json:"selection_bg,omitempty"` | 97 | SelectionBG *int `json:"selection_bg,omitempty"` | ||
| 96 | SelectionFG *int `json:"selection_fg,omitempty"` | 98 | SelectionFG *int `json:"selection_fg,omitempty"` | ||
| 97 | SpinnerFrames []string `json:"spinner_frames,omitempty"` | 99 | SpinnerFrames []string `json:"spinner_frames,omitempty"` | ||
| @@ -383,6 +385,11 @@ func applyThemeOverrides(th Theme, o ThemeOverrides) Theme { | |||||
| 383 | if o.Spinner != nil { | 385 | if o.Spinner != nil { | ||
| 384 | th.Spinner = *o.Spinner | 386 | th.Spinner = *o.Spinner | ||
| 385 | } | 387 | } | ||
| 388 | + | if o.ThinkingMax != nil { | |||
| 389 | + | th.ThinkingMax = *o.ThinkingMax | |||
| 390 | + | } else if o.ThinkingMaxCamel != nil { | |||
| 391 | + | th.ThinkingMax = *o.ThinkingMaxCamel | |||
| 392 | + | } | |||
| 386 | if o.SelectionBG != nil { | 393 | if o.SelectionBG != nil { | ||
| 387 | th.SelectionBG = *o.SelectionBG | 394 | th.SelectionBG = *o.SelectionBG | ||
| 388 | } | 395 | } | ||
M packages/tui/theme_loader_test.go +19 -0
| @@ -34,6 +34,25 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) { | |||
| 34 | 34 | } | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | + | func TestLoadThemeAllowsThinkingMaxOverride(t *testing.T) { | |
| 38 | + | home := t.TempDir() | |
| 39 | + | if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 42 | + | path := filepath.Join(home, "themes", "thinking.json") | |
| 43 | + | if err := os.WriteFile(path, []byte(`{"colors":{"dark":{"thinkingMax":201}}}`), 0o644); err != nil { | |
| 44 | + | t.Fatal(err) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | th, _, err := LoadThemeFromHome(home, "thinking", Dark) | |
| 48 | + | if err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | if th.ThinkingMax != 201 { | |
| 52 | + | t.Fatalf("thinking max = %d, want 201", th.ThinkingMax) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 37 | 56 | func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) { | |
| 38 | 57 | home := t.TempDir() | |
| 39 | 58 | if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil { | |
| @@ -34,6 +34,25 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) { | |||||
| 34 | } | 34 | } | ||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | + | func TestLoadThemeAllowsThinkingMaxOverride(t *testing.T) { | |||
| 38 | + | home := t.TempDir() | |||
| 39 | + | if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil { | |||
| 40 | + | t.Fatal(err) | |||
| 41 | + | } | |||
| 42 | + | path := filepath.Join(home, "themes", "thinking.json") | |||
| 43 | + | if err := os.WriteFile(path, []byte(`{"colors":{"dark":{"thinkingMax":201}}}`), 0o644); err != nil { | |||
| 44 | + | t.Fatal(err) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | th, _, err := LoadThemeFromHome(home, "thinking", Dark) | |||
| 48 | + | if err != nil { | |||
| 49 | + | t.Fatal(err) | |||
| 50 | + | } | |||
| 51 | + | if th.ThinkingMax != 201 { | |||
| 52 | + | t.Fatalf("thinking max = %d, want 201", th.ThinkingMax) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 37 | func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) { | 56 | func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) { | ||
| 38 | home := t.TempDir() | 57 | home := t.TempDir() | ||
| 39 | if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil { | 58 | if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil { | ||
M packages/tui/view.go +17 -7
| @@ -2453,8 +2453,8 @@ func StatusBar(p StatusBarParams) []string { | |||
| 2453 | 2453 | leftBuilder.WriteString(th.FG256(th.Muted, left)) | |
| 2454 | 2454 | if middle != "" { | |
| 2455 | 2455 | leftBuilder.WriteString(pad) | |
| 2456 | - | // `middle` already has colorized context segments; wrap the rest in muted. | |
| 2457 | - | leftBuilder.WriteString(th.FG256(th.Muted, middle)) | |
| 2456 | + | // Highlight the opt-in max tier; other status information stays muted. | |
| 2457 | + | leftBuilder.WriteString(th.FG256(thinkingStatusColor(th, thinkingText), middle)) | |
| 2458 | 2458 | } | |
| 2459 | 2459 | ||
| 2460 | 2460 | cwd := shortenHome(p.CWD) | |
| @@ -2489,7 +2489,7 @@ func StatusBar(p StatusBarParams) []string { | |||
| 2489 | 2489 | infoBuilder.WriteString(modelLine) | |
| 2490 | 2490 | if middle != "" { | |
| 2491 | 2491 | infoBuilder.WriteString(pad) | |
| 2492 | - | infoBuilder.WriteString(th.FG256(th.Muted, middle)) | |
| 2492 | + | infoBuilder.WriteString(th.FG256(thinkingStatusColor(th, thinkingText), middle)) | |
| 2493 | 2493 | } | |
| 2494 | 2494 | lines = append(lines, infoBuilder.String()) | |
| 2495 | 2495 | } | |
| @@ -2533,11 +2533,12 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, thinking | |||
| 2533 | 2533 | } | |
| 2534 | 2534 | ||
| 2535 | 2535 | modelThinkingPlain := pad + modelText + pad + thinkingText | |
| 2536 | + | thinkingColor := thinkingStatusColor(th, thinkingText) | |
| 2536 | 2537 | if cols <= 0 || visibleWidth(modelThinkingPlain) <= cols { | |
| 2537 | - | lines = append(lines, pad+th.FG256(th.Muted, modelText+pad+thinkingText)) | |
| 2538 | + | lines = append(lines, pad+th.FG256(thinkingColor, modelText+pad+thinkingText)) | |
| 2538 | 2539 | } else { | |
| 2539 | 2540 | lines = append(lines, modelLine) | |
| 2540 | - | lines = append(lines, pad+th.FG256(th.Muted, thinkingText)) | |
| 2541 | + | lines = append(lines, pad+th.FG256(thinkingColor, thinkingText)) | |
| 2541 | 2542 | } | |
| 2542 | 2543 | if statsText != "" { | |
| 2543 | 2544 | lines = append(lines, pad+th.FG256(th.Muted, statsText)) | |
| @@ -2545,14 +2546,23 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, thinking | |||
| 2545 | 2546 | return lines | |
| 2546 | 2547 | } | |
| 2547 | 2548 | ||
| 2549 | + | func thinkingStatusColor(th Theme, thinkingText string) int { | |
| 2550 | + | if strings.HasSuffix(thinkingText, ": max") { | |
| 2551 | + | return th.ThinkingMax | |
| 2552 | + | } | |
| 2553 | + | return th.Muted | |
| 2554 | + | } | |
| 2555 | + | ||
| 2548 | 2556 | func thinkingLevelLabel(level string) string { | |
| 2549 | 2557 | switch strings.ToLower(strings.TrimSpace(level)) { | |
| 2550 | 2558 | case "", "off", "none", "no", "false", "disabled": | |
| 2551 | 2559 | return "" | |
| 2552 | 2560 | case "minimum", "minimal", "min": | |
| 2553 | 2561 | return "minimal" | |
| 2554 | - | case "maximum", "max", "xhigh": | |
| 2555 | - | return "maximum" | |
| 2562 | + | case "maximum", "xhigh": | |
| 2563 | + | return "xhigh" | |
| 2564 | + | case "max": | |
| 2565 | + | return "max" | |
| 2556 | 2566 | default: | |
| 2557 | 2567 | return strings.ToLower(strings.TrimSpace(level)) | |
| 2558 | 2568 | } | |
| @@ -2453,8 +2453,8 @@ func StatusBar(p StatusBarParams) []string { | |||||
| 2453 | leftBuilder.WriteString(th.FG256(th.Muted, left)) | 2453 | leftBuilder.WriteString(th.FG256(th.Muted, left)) | ||
| 2454 | if middle != "" { | 2454 | if middle != "" { | ||
| 2455 | leftBuilder.WriteString(pad) | 2455 | leftBuilder.WriteString(pad) | ||
| 2456 | - | // `middle` already has colorized context segments; wrap the rest in muted. | 2456 | + | // Highlight the opt-in max tier; other status information stays muted. |
| 2457 | - | leftBuilder.WriteString(th.FG256(th.Muted, middle)) | 2457 | + | leftBuilder.WriteString(th.FG256(thinkingStatusColor(th, thinkingText), middle)) |
| 2458 | } | 2458 | } | ||
| 2459 | 2459 | ||||
| 2460 | cwd := shortenHome(p.CWD) | 2460 | cwd := shortenHome(p.CWD) | ||
| @@ -2489,7 +2489,7 @@ func StatusBar(p StatusBarParams) []string { | |||||
| 2489 | infoBuilder.WriteString(modelLine) | 2489 | infoBuilder.WriteString(modelLine) | ||
| 2490 | if middle != "" { | 2490 | if middle != "" { | ||
| 2491 | infoBuilder.WriteString(pad) | 2491 | infoBuilder.WriteString(pad) | ||
| 2492 | - | infoBuilder.WriteString(th.FG256(th.Muted, middle)) | 2492 | + | infoBuilder.WriteString(th.FG256(thinkingStatusColor(th, thinkingText), middle)) |
| 2493 | } | 2493 | } | ||
| 2494 | lines = append(lines, infoBuilder.String()) | 2494 | lines = append(lines, infoBuilder.String()) | ||
| 2495 | } | 2495 | } | ||
| @@ -2533,11 +2533,12 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, thinking | |||||
| 2533 | } | 2533 | } | ||
| 2534 | 2534 | ||||
| 2535 | modelThinkingPlain := pad + modelText + pad + thinkingText | 2535 | modelThinkingPlain := pad + modelText + pad + thinkingText | ||
| 2536 | + | thinkingColor := thinkingStatusColor(th, thinkingText) | |||
| 2536 | if cols <= 0 || visibleWidth(modelThinkingPlain) <= cols { | 2537 | if cols <= 0 || visibleWidth(modelThinkingPlain) <= cols { | ||
| 2537 | - | lines = append(lines, pad+th.FG256(th.Muted, modelText+pad+thinkingText)) | 2538 | + | lines = append(lines, pad+th.FG256(thinkingColor, modelText+pad+thinkingText)) |
| 2538 | } else { | 2539 | } else { | ||
| 2539 | lines = append(lines, modelLine) | 2540 | lines = append(lines, modelLine) | ||
| 2540 | - | lines = append(lines, pad+th.FG256(th.Muted, thinkingText)) | 2541 | + | lines = append(lines, pad+th.FG256(thinkingColor, thinkingText)) |
| 2541 | } | 2542 | } | ||
| 2542 | if statsText != "" { | 2543 | if statsText != "" { | ||
| 2543 | lines = append(lines, pad+th.FG256(th.Muted, statsText)) | 2544 | lines = append(lines, pad+th.FG256(th.Muted, statsText)) | ||
| @@ -2545,14 +2546,23 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, thinking | |||||
| 2545 | return lines | 2546 | return lines | ||
| 2546 | } | 2547 | } | ||
| 2547 | 2548 | ||||
| 2549 | + | func thinkingStatusColor(th Theme, thinkingText string) int { | |||
| 2550 | + | if strings.HasSuffix(thinkingText, ": max") { | |||
| 2551 | + | return th.ThinkingMax | |||
| 2552 | + | } | |||
| 2553 | + | return th.Muted | |||
| 2554 | + | } | |||
| 2555 | + | ||||
| 2548 | func thinkingLevelLabel(level string) string { | 2556 | func thinkingLevelLabel(level string) string { | ||
| 2549 | switch strings.ToLower(strings.TrimSpace(level)) { | 2557 | switch strings.ToLower(strings.TrimSpace(level)) { | ||
| 2550 | case "", "off", "none", "no", "false", "disabled": | 2558 | case "", "off", "none", "no", "false", "disabled": | ||
| 2551 | return "" | 2559 | return "" | ||
| 2552 | case "minimum", "minimal", "min": | 2560 | case "minimum", "minimal", "min": | ||
| 2553 | return "minimal" | 2561 | return "minimal" | ||
| 2554 | - | case "maximum", "max", "xhigh": | 2562 | + | case "maximum", "xhigh": |
| 2555 | - | return "maximum" | 2563 | + | return "xhigh" |
| 2564 | + | case "max": | |||
| 2565 | + | return "max" | |||
| 2556 | default: | 2566 | default: | ||
| 2557 | return strings.ToLower(strings.TrimSpace(level)) | 2567 | return strings.ToLower(strings.TrimSpace(level)) | ||
| 2558 | } | 2568 | } | ||