feat(provider): add configurable OpenAI fast mode
- commit
- 67c9c305a283683e09a6ea6486caca582761d070
- author
- brice · · 2026-08-04 09:16 UTC
- committer
- brice · · 2026-08-04 09:50 UTC
- parent
- 86b133e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
34 files · +879 -13
M README.md +3 -2
| @@ -137,7 +137,7 @@ All data lives under `$ZOT_HOME`: | |||
| 137 | 137 | ||
| 138 | 138 | ``` | |
| 139 | 139 | $ZOT_HOME/ | |
| 140 | - | ├── config.json # last-used provider/model/theme, saved automatically | |
| 140 | + | ├── config.json # last-used provider/model/theme and persistent settings | |
| 141 | 141 | ├── auth.json # api keys and oauth tokens (mode 0600) | |
| 142 | 142 | ├── sessions/ # jsonl transcripts, one dir per cwd | |
| 143 | 143 | ├── models-cache.json # live /v1/models discovery cache (6h ttl) | |
| @@ -289,7 +289,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||
| 289 | 289 | | `/unjail` | Allow tools to touch paths outside again. | | |
| 290 | 290 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | |
| 291 | 291 | | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. | | |
| 292 | - | | `/settings` | Change persistent settings, including inline images, terminal alerts, auto-swarm, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; takes effect immediately. | | |
| 292 | + | | `/settings` | Change persistent settings, including inline images, terminal alerts, auto-swarm, fast mode, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; takes effect immediately. | | |
| 293 | 293 | | `/clear` | Clear the chat transcript. | | |
| 294 | 294 | | `/exit` | Exit zot. | | |
| 295 | 295 | ||
| @@ -392,6 +392,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 392 | 392 | - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol. | |
| 393 | 393 | - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all. | |
| 394 | 394 | - **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 available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. 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` and [docs/subagents.md](docs/subagents.md) for details. | |
| 395 | + | - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account. | |
| 395 | 396 | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. | |
| 396 | 397 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | |
| 397 | 398 | - **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`. | |
| @@ -137,7 +137,7 @@ All data lives under `$ZOT_HOME`: | |||||
| 137 | 137 | ||||
| 138 | ``` | 138 | ``` | ||
| 139 | $ZOT_HOME/ | 139 | $ZOT_HOME/ | ||
| 140 | - | ├── config.json # last-used provider/model/theme, saved automatically | 140 | + | ├── config.json # last-used provider/model/theme and persistent settings |
| 141 | ├── auth.json # api keys and oauth tokens (mode 0600) | 141 | ├── auth.json # api keys and oauth tokens (mode 0600) | ||
| 142 | ├── sessions/ # jsonl transcripts, one dir per cwd | 142 | ├── sessions/ # jsonl transcripts, one dir per cwd | ||
| 143 | ├── models-cache.json # live /v1/models discovery cache (6h ttl) | 143 | ├── models-cache.json # live /v1/models discovery cache (6h ttl) | ||
| @@ -289,7 +289,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||||
| 289 | | `/unjail` | Allow tools to touch paths outside again. | | 289 | | `/unjail` | Allow tools to touch paths outside again. | | ||
| 290 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | 290 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | ||
| 291 | | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. | | 291 | | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. | | ||
| 292 | - | | `/settings` | Change persistent settings, including inline images, terminal alerts, auto-swarm, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; takes effect immediately. | | 292 | + | | `/settings` | Change persistent settings, including inline images, terminal alerts, auto-swarm, fast mode, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; takes effect immediately. | |
| 293 | | `/clear` | Clear the chat transcript. | | 293 | | `/clear` | Clear the chat transcript. | | ||
| 294 | | `/exit` | Exit zot. | | 294 | | `/exit` | Exit zot. | | ||
| 295 | 295 | ||||
| @@ -392,6 +392,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||||
| 392 | - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol. | 392 | - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol. | ||
| 393 | - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all. | 393 | - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all. | ||
| 394 | - **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 available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. 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` and [docs/subagents.md](docs/subagents.md) for details. | 394 | - **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 available named profiles are rendered in `[subagents_list]`. The tool accepts a profile name through `agent` and a per-child `reasoning`/`thinking` override. 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` and [docs/subagents.md](docs/subagents.md) for details. | ||
| 395 | + | - **fast mode** — request OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; changes apply on the next model call and persist as `fast_mode`. Other providers return an unsupported-provider error. Fast mode may cost more and depends on the selected OpenAI model/account. | |||
| 395 | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. | 396 | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from context-window and payload-too-large responses. | ||
| 396 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | 397 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | ||
| 397 | - **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`. | 398 | - **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`. | ||
M docs/providers.md +10 -0
| @@ -115,6 +115,16 @@ show instructions and should be configured with environment variables. | |||
| 115 | 115 | | Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` | | |
| 116 | 116 | | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` | | |
| 117 | 117 | ||
| 118 | + | ### OpenAI Fast mode | |
| 119 | + | ||
| 120 | + | OpenAI, OpenAI Responses, and OpenAI Codex requests can opt into OpenAI's | |
| 121 | + | Fast service tier from `/settings` by enabling **fast mode**. The setting is | |
| 122 | + | off by default and is stored as `fast_mode` in `$ZOT_HOME/config.json`. | |
| 123 | + | ||
| 124 | + | Fast mode is currently limited to those OpenAI provider IDs. Enabling it while | |
| 125 | + | using another provider returns an error instead of silently changing that | |
| 126 | + | provider's request. Swarm children inherit the setting from their parent. | |
| 127 | + | ||
| 118 | 128 | Example: | |
| 119 | 129 | ||
| 120 | 130 | ```bash | |
| @@ -115,6 +115,16 @@ show instructions and should be configured with environment variables. | |||||
| 115 | | Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` | | 115 | | Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` | | ||
| 116 | | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` | | 116 | | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` | | ||
| 117 | 117 | ||||
| 118 | + | ### OpenAI Fast mode | |||
| 119 | + | ||||
| 120 | + | OpenAI, OpenAI Responses, and OpenAI Codex requests can opt into OpenAI's | |||
| 121 | + | Fast service tier from `/settings` by enabling **fast mode**. The setting is | |||
| 122 | + | off by default and is stored as `fast_mode` in `$ZOT_HOME/config.json`. | |||
| 123 | + | ||||
| 124 | + | Fast mode is currently limited to those OpenAI provider IDs. Enabling it while | |||
| 125 | + | using another provider returns an error instead of silently changing that | |||
| 126 | + | provider's request. Swarm children inherit the setting from their parent. | |||
| 127 | + | ||||
| 118 | Example: | 128 | Example: | ||
| 119 | 129 | ||||
| 120 | ```bash | 130 | ```bash | ||
M docs/subagents.md +1 -1
| @@ -82,4 +82,4 @@ The interactive command also supports the same selection explicitly: | |||
| 82 | 82 | /swarm new --agent implementer --reasoning high Implement the parser change | |
| 83 | 83 | ``` | |
| 84 | 84 | ||
| 85 | - | All swarm children still share the host working directory. Named profiles change the child's instructions and configuration; they do not create a worktree, branch, or security sandbox. | |
| 85 | + | All swarm children still share the host working directory. Named profiles change the child's instructions and configuration; they do not create a worktree, branch, or security sandbox. When the host has **fast mode** enabled, every child inherits it; non-OpenAI child providers return an unsupported-provider error instead of silently ignoring the setting. | |
| @@ -82,4 +82,4 @@ The interactive command also supports the same selection explicitly: | |||||
| 82 | /swarm new --agent implementer --reasoning high Implement the parser change | 82 | /swarm new --agent implementer --reasoning high Implement the parser change | ||
| 83 | ``` | 83 | ``` | ||
| 84 | 84 | ||||
| 85 | - | All swarm children still share the host working directory. Named profiles change the child's instructions and configuration; they do not create a worktree, branch, or security sandbox. | 85 | + | All swarm children still share the host working directory. Named profiles change the child's instructions and configuration; they do not create a worktree, branch, or security sandbox. When the host has **fast mode** enabled, every child inherits it; non-OpenAI child providers return an unsupported-provider error instead of silently ignoring the setting. |
M packages/agent/args.go +14 -0
| @@ -48,6 +48,14 @@ type Args struct { | |||
| 48 | 48 | Reasoning string | |
| 49 | 49 | Temperature *float32 | |
| 50 | 50 | ||
| 51 | + | // FastMode is an internal swarm-child propagation flag. Normal users | |
| 52 | + | // enable fast mode through the persisted config /settings toggle. | |
| 53 | + | FastMode bool | |
| 54 | + | // FastModeSet distinguishes an explicit child override (including | |
| 55 | + | // --no-fast-mode) from an omitted flag so persisted swarm settings | |
| 56 | + | // survive a parent config change. | |
| 57 | + | FastModeSet bool | |
| 58 | + | ||
| 51 | 59 | Continue bool | |
| 52 | 60 | Resume bool | |
| 53 | 61 | Session string | |
| @@ -254,6 +262,12 @@ func ParseArgs(in []string) (Args, error) { | |||
| 254 | 262 | } | |
| 255 | 263 | t := float32(f) | |
| 256 | 264 | a.Temperature = &t | |
| 265 | + | case "--fast-mode": | |
| 266 | + | a.FastMode = true | |
| 267 | + | a.FastModeSet = true | |
| 268 | + | case "--no-fast-mode": | |
| 269 | + | a.FastMode = false | |
| 270 | + | a.FastModeSet = true | |
| 257 | 271 | case "--stats": | |
| 258 | 272 | v, err := want(&i, arg) | |
| 259 | 273 | if err != nil { | |
| @@ -48,6 +48,14 @@ type Args struct { | |||||
| 48 | Reasoning string | 48 | Reasoning string | ||
| 49 | Temperature *float32 | 49 | Temperature *float32 | ||
| 50 | 50 | ||||
| 51 | + | // FastMode is an internal swarm-child propagation flag. Normal users | |||
| 52 | + | // enable fast mode through the persisted config /settings toggle. | |||
| 53 | + | FastMode bool | |||
| 54 | + | // FastModeSet distinguishes an explicit child override (including | |||
| 55 | + | // --no-fast-mode) from an omitted flag so persisted swarm settings | |||
| 56 | + | // survive a parent config change. | |||
| 57 | + | FastModeSet bool | |||
| 58 | + | ||||
| 51 | Continue bool | 59 | Continue bool | ||
| 52 | Resume bool | 60 | Resume bool | ||
| 53 | Session string | 61 | Session string | ||
| @@ -254,6 +262,12 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 254 | } | 262 | } | ||
| 255 | t := float32(f) | 263 | t := float32(f) | ||
| 256 | a.Temperature = &t | 264 | a.Temperature = &t | ||
| 265 | + | case "--fast-mode": | |||
| 266 | + | a.FastMode = true | |||
| 267 | + | a.FastModeSet = true | |||
| 268 | + | case "--no-fast-mode": | |||
| 269 | + | a.FastMode = false | |||
| 270 | + | a.FastModeSet = true | |||
| 257 | case "--stats": | 271 | case "--stats": | ||
| 258 | v, err := want(&i, arg) | 272 | v, err := want(&i, arg) | ||
| 259 | if err != nil { | 273 | if err != nil { | ||
M packages/agent/build.go +9 -2
| @@ -34,6 +34,7 @@ type Resolved struct { | |||
| 34 | 34 | CWD string | |
| 35 | 35 | Reasoning string | |
| 36 | 36 | Temperature *float32 | |
| 37 | + | FastMode bool | |
| 37 | 38 | ||
| 38 | 39 | ToolRegistry core.Registry | |
| 39 | 40 | ToolSummary []ToolSummary | |
| @@ -698,6 +699,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 698 | 699 | if temperature == nil { | |
| 699 | 700 | temperature = cfg.Temperature | |
| 700 | 701 | } | |
| 702 | + | fastMode := cfg.FastMode != nil && *cfg.FastMode | |
| 703 | + | if args.FastModeSet { | |
| 704 | + | fastMode = args.FastMode | |
| 705 | + | } | |
| 701 | 706 | ||
| 702 | 707 | max := args.MaxSteps // 0 = unlimited | |
| 703 | 708 | ||
| @@ -712,6 +717,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 712 | 717 | CWD: args.CWD, | |
| 713 | 718 | Reasoning: reasoning, | |
| 714 | 719 | Temperature: temperature, | |
| 720 | + | FastMode: fastMode, | |
| 715 | 721 | ToolRegistry: reg, | |
| 716 | 722 | ToolSummary: summaries, | |
| 717 | 723 | SystemPrompt: sys, | |
| @@ -840,13 +846,13 @@ func (r Resolved) NewClient() provider.Client { | |||
| 840 | 846 | wrap := r.withHTTPClient | |
| 841 | 847 | switch r.Provider { | |
| 842 | 848 | case "ollama": | |
| 843 | - | return wrap(provider.NewOpenAI(r.Credential, r.BaseURL)) | |
| 849 | + | return wrap(provider.NewOpenAICompat("ollama", r.Credential, r.BaseURL, "")) | |
| 844 | 850 | case provider.LlamaCPPProviderID: | |
| 845 | 851 | credential := r.Credential | |
| 846 | 852 | if credential == "local" { | |
| 847 | 853 | credential = "" | |
| 848 | 854 | } | |
| 849 | - | return wrap(provider.NewOpenAI(credential, r.BaseURL)) | |
| 855 | + | return wrap(provider.NewOpenAICompat(provider.LlamaCPPProviderID, credential, r.BaseURL, "")) | |
| 850 | 856 | case "kimi": | |
| 851 | 857 | // kimi-coding speaks anthropic-messages on api.kimi.com/coding. | |
| 852 | 858 | // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client. | |
| @@ -1034,6 +1040,7 @@ func (r Resolved) NewAgent() *core.Agent { | |||
| 1034 | 1040 | a.MaxTokens = r.MaxOutput | |
| 1035 | 1041 | a.Reasoning = r.Reasoning | |
| 1036 | 1042 | a.Temperature = r.Temperature | |
| 1043 | + | a.FastMode = r.FastMode | |
| 1037 | 1044 | return a | |
| 1038 | 1045 | } | |
| 1039 | 1046 | ||
| @@ -34,6 +34,7 @@ type Resolved struct { | |||||
| 34 | CWD string | 34 | CWD string | ||
| 35 | Reasoning string | 35 | Reasoning string | ||
| 36 | Temperature *float32 | 36 | Temperature *float32 | ||
| 37 | + | FastMode bool | |||
| 37 | 38 | ||||
| 38 | ToolRegistry core.Registry | 39 | ToolRegistry core.Registry | ||
| 39 | ToolSummary []ToolSummary | 40 | ToolSummary []ToolSummary | ||
| @@ -698,6 +699,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 698 | if temperature == nil { | 699 | if temperature == nil { | ||
| 699 | temperature = cfg.Temperature | 700 | temperature = cfg.Temperature | ||
| 700 | } | 701 | } | ||
| 702 | + | fastMode := cfg.FastMode != nil && *cfg.FastMode | |||
| 703 | + | if args.FastModeSet { | |||
| 704 | + | fastMode = args.FastMode | |||
| 705 | + | } | |||
| 701 | 706 | ||||
| 702 | max := args.MaxSteps // 0 = unlimited | 707 | max := args.MaxSteps // 0 = unlimited | ||
| 703 | 708 | ||||
| @@ -712,6 +717,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 712 | CWD: args.CWD, | 717 | CWD: args.CWD, | ||
| 713 | Reasoning: reasoning, | 718 | Reasoning: reasoning, | ||
| 714 | Temperature: temperature, | 719 | Temperature: temperature, | ||
| 720 | + | FastMode: fastMode, | |||
| 715 | ToolRegistry: reg, | 721 | ToolRegistry: reg, | ||
| 716 | ToolSummary: summaries, | 722 | ToolSummary: summaries, | ||
| 717 | SystemPrompt: sys, | 723 | SystemPrompt: sys, | ||
| @@ -840,13 +846,13 @@ func (r Resolved) NewClient() provider.Client { | |||||
| 840 | wrap := r.withHTTPClient | 846 | wrap := r.withHTTPClient | ||
| 841 | switch r.Provider { | 847 | switch r.Provider { | ||
| 842 | case "ollama": | 848 | case "ollama": | ||
| 843 | - | return wrap(provider.NewOpenAI(r.Credential, r.BaseURL)) | 849 | + | return wrap(provider.NewOpenAICompat("ollama", r.Credential, r.BaseURL, "")) |
| 844 | case provider.LlamaCPPProviderID: | 850 | case provider.LlamaCPPProviderID: | ||
| 845 | credential := r.Credential | 851 | credential := r.Credential | ||
| 846 | if credential == "local" { | 852 | if credential == "local" { | ||
| 847 | credential = "" | 853 | credential = "" | ||
| 848 | } | 854 | } | ||
| 849 | - | return wrap(provider.NewOpenAI(credential, r.BaseURL)) | 855 | + | return wrap(provider.NewOpenAICompat(provider.LlamaCPPProviderID, credential, r.BaseURL, "")) |
| 850 | case "kimi": | 856 | case "kimi": | ||
| 851 | // kimi-coding speaks anthropic-messages on api.kimi.com/coding. | 857 | // kimi-coding speaks anthropic-messages on api.kimi.com/coding. | ||
| 852 | // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client. | 858 | // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client. | ||
| @@ -1034,6 +1040,7 @@ func (r Resolved) NewAgent() *core.Agent { | |||||
| 1034 | a.MaxTokens = r.MaxOutput | 1040 | a.MaxTokens = r.MaxOutput | ||
| 1035 | a.Reasoning = r.Reasoning | 1041 | a.Reasoning = r.Reasoning | ||
| 1036 | a.Temperature = r.Temperature | 1042 | a.Temperature = r.Temperature | ||
| 1043 | + | a.FastMode = r.FastMode | |||
| 1037 | return a | 1044 | return a | ||
| 1038 | } | 1045 | } | ||
| 1039 | 1046 | ||||
M packages/agent/build_test.go +2 -2
| @@ -405,8 +405,8 @@ func TestResolveLlamaCPPUsesRouterInferenceURL(t *testing.T) { | |||
| 405 | 405 | if r.Credential != "local" || !r.HasCredential() { | |
| 406 | 406 | t.Fatalf("credential = %q", r.Credential) | |
| 407 | 407 | } | |
| 408 | - | if got := r.NewClient().Name(); got != "openai" { | |
| 409 | - | t.Fatalf("client name = %q", got) | |
| 408 | + | if got := r.NewClient().Name(); got != provider.LlamaCPPProviderID { | |
| 409 | + | t.Fatalf("client name = %q, want %q", got, provider.LlamaCPPProviderID) | |
| 410 | 410 | } | |
| 411 | 411 | } | |
| 412 | 412 | ||
| @@ -405,8 +405,8 @@ func TestResolveLlamaCPPUsesRouterInferenceURL(t *testing.T) { | |||||
| 405 | if r.Credential != "local" || !r.HasCredential() { | 405 | if r.Credential != "local" || !r.HasCredential() { | ||
| 406 | t.Fatalf("credential = %q", r.Credential) | 406 | t.Fatalf("credential = %q", r.Credential) | ||
| 407 | } | 407 | } | ||
| 408 | - | if got := r.NewClient().Name(); got != "openai" { | 408 | + | if got := r.NewClient().Name(); got != provider.LlamaCPPProviderID { |
| 409 | - | t.Fatalf("client name = %q", got) | 409 | + | t.Fatalf("client name = %q, want %q", got, provider.LlamaCPPProviderID) |
| 410 | } | 410 | } | ||
| 411 | } | 411 | } | ||
| 412 | 412 | ||||
M packages/agent/cli.go +3 -0
| @@ -743,6 +743,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 743 | 743 | swarmMgr = swarm.New(swarm.Config{ | |
| 744 | 744 | Root: filepath.Join(ZotHome(), "swarm"), | |
| 745 | 745 | RepoRoot: r.CWD, | |
| 746 | + | FastMode: r.FastMode, | |
| 746 | 747 | ResolveCredential: func(ctx context.Context, providerID string) (swarm.Credential, error) { | |
| 747 | 748 | if providerID == "ollama" { | |
| 748 | 749 | return swarm.Credential{Value: "ollama", Method: "apikey"}, nil | |
| @@ -1291,6 +1292,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1291 | 1292 | }() | |
| 1292 | 1293 | ||
| 1293 | 1294 | initialCfg, _ := LoadConfig() | |
| 1295 | + | fastMode := r.FastMode | |
| 1294 | 1296 | quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts)) | |
| 1295 | 1297 | for idx, s := range initialCfg.QuickModelShortcuts { | |
| 1296 | 1298 | quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model} | |
| @@ -1331,6 +1333,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1331 | 1333 | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | |
| 1332 | 1334 | TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled, | |
| 1333 | 1335 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | |
| 1336 | + | FastMode: &fastMode, | |
| 1334 | 1337 | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | |
| 1335 | 1338 | JailByDefault: initialCfg.JailByDefault, | |
| 1336 | 1339 | QuickModelShortcuts: quickModelShortcuts, | |
| @@ -743,6 +743,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 743 | swarmMgr = swarm.New(swarm.Config{ | 743 | swarmMgr = swarm.New(swarm.Config{ | ||
| 744 | Root: filepath.Join(ZotHome(), "swarm"), | 744 | Root: filepath.Join(ZotHome(), "swarm"), | ||
| 745 | RepoRoot: r.CWD, | 745 | RepoRoot: r.CWD, | ||
| 746 | + | FastMode: r.FastMode, | |||
| 746 | ResolveCredential: func(ctx context.Context, providerID string) (swarm.Credential, error) { | 747 | ResolveCredential: func(ctx context.Context, providerID string) (swarm.Credential, error) { | ||
| 747 | if providerID == "ollama" { | 748 | if providerID == "ollama" { | ||
| 748 | return swarm.Credential{Value: "ollama", Method: "apikey"}, nil | 749 | return swarm.Credential{Value: "ollama", Method: "apikey"}, nil | ||
| @@ -1291,6 +1292,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1291 | }() | 1292 | }() | ||
| 1292 | 1293 | ||||
| 1293 | initialCfg, _ := LoadConfig() | 1294 | initialCfg, _ := LoadConfig() | ||
| 1295 | + | fastMode := r.FastMode | |||
| 1294 | quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts)) | 1296 | quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts)) | ||
| 1295 | for idx, s := range initialCfg.QuickModelShortcuts { | 1297 | for idx, s := range initialCfg.QuickModelShortcuts { | ||
| 1296 | quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model} | 1298 | quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model} | ||
| @@ -1331,6 +1333,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1331 | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | 1333 | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | ||
| 1332 | TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled, | 1334 | TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled, | ||
| 1333 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | 1335 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | ||
| 1336 | + | FastMode: &fastMode, | |||
| 1334 | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | 1337 | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | ||
| 1335 | JailByDefault: initialCfg.JailByDefault, | 1338 | JailByDefault: initialCfg.JailByDefault, | ||
| 1336 | QuickModelShortcuts: quickModelShortcuts, | 1339 | QuickModelShortcuts: quickModelShortcuts, | ||
M packages/agent/config.go +5 -0
| @@ -30,6 +30,11 @@ type Config struct { | |||
| 30 | 30 | Temperature *float32 `json:"temperature,omitempty"` | |
| 31 | 31 | Theme string `json:"theme"` | |
| 32 | 32 | ||
| 33 | + | // FastMode requests OpenAI's fast service tier. Off by default; | |
| 34 | + | // nil/missing means disabled. Other providers reject fast-mode | |
| 35 | + | // requests at the provider boundary. | |
| 36 | + | FastMode *bool `json:"fast_mode,omitempty"` | |
| 37 | + | ||
| 33 | 38 | // ToolRender selects how tool calls are drawn in interactive mode. | |
| 34 | 39 | // "box" (default, or empty) wraps each call in a bordered panel; | |
| 35 | 40 | // "flat" drops the frame for a quiet header line plus indented, | |
| @@ -30,6 +30,11 @@ type Config struct { | |||||
| 30 | Temperature *float32 `json:"temperature,omitempty"` | 30 | Temperature *float32 `json:"temperature,omitempty"` | ||
| 31 | Theme string `json:"theme"` | 31 | Theme string `json:"theme"` | ||
| 32 | 32 | ||||
| 33 | + | // FastMode requests OpenAI's fast service tier. Off by default; | |||
| 34 | + | // nil/missing means disabled. Other providers reject fast-mode | |||
| 35 | + | // requests at the provider boundary. | |||
| 36 | + | FastMode *bool `json:"fast_mode,omitempty"` | |||
| 37 | + | ||||
| 33 | // ToolRender selects how tool calls are drawn in interactive mode. | 38 | // ToolRender selects how tool calls are drawn in interactive mode. | ||
| 34 | // "box" (default, or empty) wraps each call in a bordered panel; | 39 | // "box" (default, or empty) wraps each call in a bordered panel; | ||
| 35 | // "flat" drops the frame for a quiet header line plus indented, | 40 | // "flat" drops the frame for a quiet header line plus indented, | ||
A packages/agent/fast_mode_test.go +152 -0
| @@ -0,0 +1,152 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestParseArgsFastModeChildFlag(t *testing.T) { | |
| 13 | + | args, err := ParseArgs([]string{"--fast-mode"}) | |
| 14 | + | if err != nil { | |
| 15 | + | t.Fatalf("ParseArgs failed: %v", err) | |
| 16 | + | } | |
| 17 | + | if !args.FastMode { | |
| 18 | + | t.Fatal("FastMode = false, want true") | |
| 19 | + | } | |
| 20 | + | if !args.FastModeSet { | |
| 21 | + | t.Fatal("FastModeSet = false, want true") | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestResolveFastModeDefaultsOff(t *testing.T) { | |
| 26 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 27 | + | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5"}); err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | r, err := Resolve(Args{}, false) | |
| 32 | + | if err != nil { | |
| 33 | + | t.Fatalf("Resolve failed: %v", err) | |
| 34 | + | } | |
| 35 | + | if r.FastMode { | |
| 36 | + | t.Fatal("FastMode = true by default, want false") | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestResolveFastModeFromConfig(t *testing.T) { | |
| 41 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 42 | + | enabled := true | |
| 43 | + | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", FastMode: &enabled}); err != nil { | |
| 44 | + | t.Fatal(err) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | r, err := Resolve(Args{}, false) | |
| 48 | + | if err != nil { | |
| 49 | + | t.Fatalf("Resolve failed: %v", err) | |
| 50 | + | } | |
| 51 | + | if !r.FastMode { | |
| 52 | + | t.Fatal("FastMode = false, want true from config") | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestResolveFastModeRemainsEnabledForUnsupportedProviderUntilRequest(t *testing.T) { | |
| 57 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 58 | + | enabled := true | |
| 59 | + | if err := SaveConfig(Config{Provider: "anthropic", Model: "claude-sonnet-4-5", FastMode: &enabled}); err != nil { | |
| 60 | + | t.Fatal(err) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | r, err := Resolve(Args{}, false) | |
| 64 | + | if err != nil { | |
| 65 | + | t.Fatalf("Resolve failed: %v", err) | |
| 66 | + | } | |
| 67 | + | if !r.FastMode { | |
| 68 | + | t.Fatal("FastMode = false, want true from config") | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestResolveFastModeRequiresExplicitChildOverride(t *testing.T) { | |
| 73 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 74 | + | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5"}); err != nil { | |
| 75 | + | t.Fatal(err) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | r, err := Resolve(Args{FastMode: true}, false) | |
| 79 | + | if err != nil { | |
| 80 | + | t.Fatalf("Resolve failed: %v", err) | |
| 81 | + | } | |
| 82 | + | if r.FastMode { | |
| 83 | + | t.Fatal("FastMode = true, want unmarked programmatic value to be ignored") | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestResolveFastModeExplicitChildDisableOverridesConfig(t *testing.T) { | |
| 88 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 89 | + | enabled := true | |
| 90 | + | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", FastMode: &enabled}); err != nil { | |
| 91 | + | t.Fatal(err) | |
| 92 | + | } | |
| 93 | + | args, err := ParseArgs([]string{"--no-fast-mode"}) | |
| 94 | + | if err != nil { | |
| 95 | + | t.Fatalf("ParseArgs failed: %v", err) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | r, err := Resolve(args, false) | |
| 99 | + | if err != nil { | |
| 100 | + | t.Fatalf("Resolve failed: %v", err) | |
| 101 | + | } | |
| 102 | + | if r.FastMode { | |
| 103 | + | t.Fatal("FastMode = true, want explicit child disable to override config") | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestConfigSettingsStorePersistsFastMode(t *testing.T) { | |
| 108 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 109 | + | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |
| 110 | + | t.Fatal(err) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | if err := (configSettingsStore{}).SetFastMode(true); err != nil { | |
| 114 | + | t.Fatal(err) | |
| 115 | + | } | |
| 116 | + | cfg, err := LoadConfig() | |
| 117 | + | if err != nil { | |
| 118 | + | t.Fatal(err) | |
| 119 | + | } | |
| 120 | + | if cfg.FastMode == nil || !*cfg.FastMode { | |
| 121 | + | t.Fatalf("fast_mode = %v, want true", cfg.FastMode) | |
| 122 | + | } | |
| 123 | + | if cfg.Theme != "dark" { | |
| 124 | + | t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestFastModeConfigJSONUsesSnakeCase(t *testing.T) { | |
| 129 | + | enabled := true | |
| 130 | + | data, err := json.Marshal(Config{FastMode: &enabled}) | |
| 131 | + | if err != nil { | |
| 132 | + | t.Fatal(err) | |
| 133 | + | } | |
| 134 | + | if !strings.Contains(string(data), `"fast_mode":true`) { | |
| 135 | + | t.Fatalf("config JSON = %s, want fast_mode", data) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestResolvedOllamaClientRejectsFastMode(t *testing.T) { | |
| 140 | + | client := (Resolved{ | |
| 141 | + | Provider: "ollama", | |
| 142 | + | Credential: "ollama", | |
| 143 | + | BaseURL: "http://localhost:11434", | |
| 144 | + | }).NewClient() | |
| 145 | + | if client.Name() != "ollama" { | |
| 146 | + | t.Fatalf("client name = %q, want ollama", client.Name()) | |
| 147 | + | } | |
| 148 | + | _, err := client.Stream(context.Background(), provider.Request{Model: "local", FastMode: true}) | |
| 149 | + | if err == nil || !strings.Contains(err.Error(), "only supported for OpenAI providers") { | |
| 150 | + | t.Fatalf("Stream error = %v, want unsupported-provider error", err) | |
| 151 | + | } | |
| 152 | + | } | |
| @@ -0,0 +1,152 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestParseArgsFastModeChildFlag(t *testing.T) { | |||
| 13 | + | args, err := ParseArgs([]string{"--fast-mode"}) | |||
| 14 | + | if err != nil { | |||
| 15 | + | t.Fatalf("ParseArgs failed: %v", err) | |||
| 16 | + | } | |||
| 17 | + | if !args.FastMode { | |||
| 18 | + | t.Fatal("FastMode = false, want true") | |||
| 19 | + | } | |||
| 20 | + | if !args.FastModeSet { | |||
| 21 | + | t.Fatal("FastModeSet = false, want true") | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestResolveFastModeDefaultsOff(t *testing.T) { | |||
| 26 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 27 | + | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5"}); err != nil { | |||
| 28 | + | t.Fatal(err) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | r, err := Resolve(Args{}, false) | |||
| 32 | + | if err != nil { | |||
| 33 | + | t.Fatalf("Resolve failed: %v", err) | |||
| 34 | + | } | |||
| 35 | + | if r.FastMode { | |||
| 36 | + | t.Fatal("FastMode = true by default, want false") | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestResolveFastModeFromConfig(t *testing.T) { | |||
| 41 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 42 | + | enabled := true | |||
| 43 | + | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", FastMode: &enabled}); err != nil { | |||
| 44 | + | t.Fatal(err) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | r, err := Resolve(Args{}, false) | |||
| 48 | + | if err != nil { | |||
| 49 | + | t.Fatalf("Resolve failed: %v", err) | |||
| 50 | + | } | |||
| 51 | + | if !r.FastMode { | |||
| 52 | + | t.Fatal("FastMode = false, want true from config") | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestResolveFastModeRemainsEnabledForUnsupportedProviderUntilRequest(t *testing.T) { | |||
| 57 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 58 | + | enabled := true | |||
| 59 | + | if err := SaveConfig(Config{Provider: "anthropic", Model: "claude-sonnet-4-5", FastMode: &enabled}); err != nil { | |||
| 60 | + | t.Fatal(err) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | r, err := Resolve(Args{}, false) | |||
| 64 | + | if err != nil { | |||
| 65 | + | t.Fatalf("Resolve failed: %v", err) | |||
| 66 | + | } | |||
| 67 | + | if !r.FastMode { | |||
| 68 | + | t.Fatal("FastMode = false, want true from config") | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestResolveFastModeRequiresExplicitChildOverride(t *testing.T) { | |||
| 73 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 74 | + | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5"}); err != nil { | |||
| 75 | + | t.Fatal(err) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | r, err := Resolve(Args{FastMode: true}, false) | |||
| 79 | + | if err != nil { | |||
| 80 | + | t.Fatalf("Resolve failed: %v", err) | |||
| 81 | + | } | |||
| 82 | + | if r.FastMode { | |||
| 83 | + | t.Fatal("FastMode = true, want unmarked programmatic value to be ignored") | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestResolveFastModeExplicitChildDisableOverridesConfig(t *testing.T) { | |||
| 88 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 89 | + | enabled := true | |||
| 90 | + | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", FastMode: &enabled}); err != nil { | |||
| 91 | + | t.Fatal(err) | |||
| 92 | + | } | |||
| 93 | + | args, err := ParseArgs([]string{"--no-fast-mode"}) | |||
| 94 | + | if err != nil { | |||
| 95 | + | t.Fatalf("ParseArgs failed: %v", err) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | r, err := Resolve(args, false) | |||
| 99 | + | if err != nil { | |||
| 100 | + | t.Fatalf("Resolve failed: %v", err) | |||
| 101 | + | } | |||
| 102 | + | if r.FastMode { | |||
| 103 | + | t.Fatal("FastMode = true, want explicit child disable to override config") | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestConfigSettingsStorePersistsFastMode(t *testing.T) { | |||
| 108 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 109 | + | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |||
| 110 | + | t.Fatal(err) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | if err := (configSettingsStore{}).SetFastMode(true); err != nil { | |||
| 114 | + | t.Fatal(err) | |||
| 115 | + | } | |||
| 116 | + | cfg, err := LoadConfig() | |||
| 117 | + | if err != nil { | |||
| 118 | + | t.Fatal(err) | |||
| 119 | + | } | |||
| 120 | + | if cfg.FastMode == nil || !*cfg.FastMode { | |||
| 121 | + | t.Fatalf("fast_mode = %v, want true", cfg.FastMode) | |||
| 122 | + | } | |||
| 123 | + | if cfg.Theme != "dark" { | |||
| 124 | + | t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestFastModeConfigJSONUsesSnakeCase(t *testing.T) { | |||
| 129 | + | enabled := true | |||
| 130 | + | data, err := json.Marshal(Config{FastMode: &enabled}) | |||
| 131 | + | if err != nil { | |||
| 132 | + | t.Fatal(err) | |||
| 133 | + | } | |||
| 134 | + | if !strings.Contains(string(data), `"fast_mode":true`) { | |||
| 135 | + | t.Fatalf("config JSON = %s, want fast_mode", data) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestResolvedOllamaClientRejectsFastMode(t *testing.T) { | |||
| 140 | + | client := (Resolved{ | |||
| 141 | + | Provider: "ollama", | |||
| 142 | + | Credential: "ollama", | |||
| 143 | + | BaseURL: "http://localhost:11434", | |||
| 144 | + | }).NewClient() | |||
| 145 | + | if client.Name() != "ollama" { | |||
| 146 | + | t.Fatalf("client name = %q, want ollama", client.Name()) | |||
| 147 | + | } | |||
| 148 | + | _, err := client.Stream(context.Background(), provider.Request{Model: "local", FastMode: true}) | |||
| 149 | + | if err == nil || !strings.Contains(err.Error(), "only supported for OpenAI providers") { | |||
| 150 | + | t.Fatalf("Stream error = %v, want unsupported-provider error", err) | |||
| 151 | + | } | |||
| 152 | + | } | |||
M packages/agent/modes/btw_dialog.go +1 -0
| @@ -276,6 +276,7 @@ func newBtwAgent(main *core.Agent, system, model string) *core.Agent { | |||
| 276 | 276 | agent.MaxSteps = main.MaxSteps | |
| 277 | 277 | agent.Reasoning = main.Reasoning | |
| 278 | 278 | agent.Temperature = main.Temperature | |
| 279 | + | agent.FastMode = main.FastModeEnabled() | |
| 279 | 280 | agent.MaxTokens = main.MaxTokens | |
| 280 | 281 | agent.BeforeToolExecute = main.BeforeToolExecute | |
| 281 | 282 | agent.BeforeTurn = main.BeforeTurn | |
| @@ -276,6 +276,7 @@ func newBtwAgent(main *core.Agent, system, model string) *core.Agent { | |||||
| 276 | agent.MaxSteps = main.MaxSteps | 276 | agent.MaxSteps = main.MaxSteps | ||
| 277 | agent.Reasoning = main.Reasoning | 277 | agent.Reasoning = main.Reasoning | ||
| 278 | agent.Temperature = main.Temperature | 278 | agent.Temperature = main.Temperature | ||
| 279 | + | agent.FastMode = main.FastModeEnabled() | |||
| 279 | agent.MaxTokens = main.MaxTokens | 280 | agent.MaxTokens = main.MaxTokens | ||
| 280 | agent.BeforeToolExecute = main.BeforeToolExecute | 281 | agent.BeforeToolExecute = main.BeforeToolExecute | ||
| 281 | agent.BeforeTurn = main.BeforeTurn | 282 | agent.BeforeTurn = main.BeforeTurn | ||
A packages/agent/modes/fast_mode_test.go +162 -0
| @@ -0,0 +1,162 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/packages/core" | |
| 10 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type recordingFastModeSettingsStore struct { | |
| 14 | + | SettingsStore | |
| 15 | + | setCalls int | |
| 16 | + | last bool | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func (s *recordingFastModeSettingsStore) SetFastMode(enabled bool) error { | |
| 20 | + | s.setCalls++ | |
| 21 | + | s.last = enabled | |
| 22 | + | return nil | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | type failingFastModeSettingsStore struct { | |
| 26 | + | SettingsStore | |
| 27 | + | err error | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (s *failingFastModeSettingsStore) SetFastMode(bool) error { return s.err } | |
| 31 | + | ||
| 32 | + | type fastModeProviderClient struct{} | |
| 33 | + | ||
| 34 | + | func (fastModeProviderClient) Name() string { return "openai" } | |
| 35 | + | func (fastModeProviderClient) Stream(_ context.Context, _ provider.Request) (<-chan provider.Event, error) { | |
| 36 | + | out := make(chan provider.Event) | |
| 37 | + | close(out) | |
| 38 | + | return out, nil | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestSettingsDialogOffersFastModeOffByDefault(t *testing.T) { | |
| 42 | + | interactive := NewInteractive(InteractiveConfig{Provider: "openai", Model: "gpt-5"}) | |
| 43 | + | interactive.openSettingsDialog() | |
| 44 | + | ||
| 45 | + | item := findSettingsItem(interactive.settingsDialog.items, "fast_mode") | |
| 46 | + | if item == nil { | |
| 47 | + | t.Fatal("settings dialog is missing fast mode") | |
| 48 | + | } | |
| 49 | + | if item.value { | |
| 50 | + | t.Fatal("fast mode is enabled by default") | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestApplyFastModeUpdatesAgentAndStore(t *testing.T) { | |
| 55 | + | store := &recordingFastModeSettingsStore{} | |
| 56 | + | agent := core.NewAgent(fastModeProviderClient{}, "gpt-5", "", nil) | |
| 57 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 58 | + | Provider: "openai", | |
| 59 | + | Model: "gpt-5", | |
| 60 | + | SettingsStore: store, | |
| 61 | + | Agent: agent, | |
| 62 | + | }) | |
| 63 | + | interactive.openSettingsDialog() | |
| 64 | + | interactive.rend = nil | |
| 65 | + | ||
| 66 | + | interactive.applySettingToggle("fast_mode", true) | |
| 67 | + | ||
| 68 | + | if interactive.cfg.FastMode == nil || !*interactive.cfg.FastMode { | |
| 69 | + | t.Fatal("interactive FastMode was not enabled") | |
| 70 | + | } | |
| 71 | + | if !agent.FastMode { | |
| 72 | + | t.Fatal("agent FastMode was not enabled") | |
| 73 | + | } | |
| 74 | + | if store.setCalls != 1 || !store.last { | |
| 75 | + | t.Fatalf("store calls = %d/%v, want one enabled write", store.setCalls, store.last) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestApplyFastModeRejectsUnsupportedProvider(t *testing.T) { | |
| 80 | + | store := &recordingFastModeSettingsStore{} | |
| 81 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 82 | + | Provider: "anthropic", | |
| 83 | + | Model: "claude-sonnet-4-5", | |
| 84 | + | SettingsStore: store, | |
| 85 | + | }) | |
| 86 | + | interactive.openSettingsDialog() | |
| 87 | + | interactive.rend = nil | |
| 88 | + | ||
| 89 | + | interactive.applySettingToggle("fast_mode", true) | |
| 90 | + | ||
| 91 | + | if interactive.cfg.FastMode != nil && *interactive.cfg.FastMode { | |
| 92 | + | t.Fatal("unsupported fast mode was enabled") | |
| 93 | + | } | |
| 94 | + | if store.setCalls != 0 { | |
| 95 | + | t.Fatalf("store calls = %d, want none", store.setCalls) | |
| 96 | + | } | |
| 97 | + | if !strings.Contains(interactive.statusErr, "only supported for OpenAI providers") { | |
| 98 | + | t.Fatalf("status error = %q, want unsupported-provider error", interactive.statusErr) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func TestApplyFastModeCanDisableUnsupportedProvider(t *testing.T) { | |
| 103 | + | enabled := true | |
| 104 | + | store := &recordingFastModeSettingsStore{} | |
| 105 | + | agent := core.NewAgent(fastModeProviderClient{}, "claude-sonnet-4-5", "", nil) | |
| 106 | + | agent.FastMode = true | |
| 107 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 108 | + | Provider: "anthropic", | |
| 109 | + | Model: "claude-sonnet-4-5", | |
| 110 | + | FastMode: &enabled, | |
| 111 | + | SettingsStore: store, | |
| 112 | + | Agent: agent, | |
| 113 | + | }) | |
| 114 | + | interactive.openSettingsDialog() | |
| 115 | + | interactive.rend = nil | |
| 116 | + | ||
| 117 | + | interactive.applySettingToggle("fast_mode", false) | |
| 118 | + | ||
| 119 | + | if interactive.cfg.FastMode == nil || *interactive.cfg.FastMode { | |
| 120 | + | t.Fatal("fast mode was not disabled") | |
| 121 | + | } | |
| 122 | + | if agent.FastMode { | |
| 123 | + | t.Fatal("agent fast mode was not disabled") | |
| 124 | + | } | |
| 125 | + | if store.setCalls != 1 || store.last { | |
| 126 | + | t.Fatalf("store calls = %d/%v, want one disabled write", store.setCalls, store.last) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestApplyFastModeLeavesLiveStateWhenPersistenceFails(t *testing.T) { | |
| 131 | + | store := &failingFastModeSettingsStore{err: errors.New("disk full")} | |
| 132 | + | agent := core.NewAgent(fastModeProviderClient{}, "gpt-5", "", nil) | |
| 133 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 134 | + | Provider: "openai", | |
| 135 | + | Model: "gpt-5", | |
| 136 | + | SettingsStore: store, | |
| 137 | + | Agent: agent, | |
| 138 | + | }) | |
| 139 | + | interactive.openSettingsDialog() | |
| 140 | + | interactive.rend = nil | |
| 141 | + | ||
| 142 | + | interactive.applySettingToggle("fast_mode", true) | |
| 143 | + | ||
| 144 | + | if interactive.cfg.FastMode != nil && *interactive.cfg.FastMode { | |
| 145 | + | t.Fatal("fast mode changed despite persistence failure") | |
| 146 | + | } | |
| 147 | + | if agent.FastMode { | |
| 148 | + | t.Fatal("agent fast mode changed despite persistence failure") | |
| 149 | + | } | |
| 150 | + | if !strings.Contains(interactive.statusErr, "disk full") { | |
| 151 | + | t.Fatalf("status error = %q, want persistence error", interactive.statusErr) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func findSettingsItem(items []settingsItem, key string) *settingsItem { | |
| 156 | + | for idx := range items { | |
| 157 | + | if items[idx].key == key { | |
| 158 | + | return &items[idx] | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | return nil | |
| 162 | + | } | |
| @@ -0,0 +1,162 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 10 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | type recordingFastModeSettingsStore struct { | |||
| 14 | + | SettingsStore | |||
| 15 | + | setCalls int | |||
| 16 | + | last bool | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func (s *recordingFastModeSettingsStore) SetFastMode(enabled bool) error { | |||
| 20 | + | s.setCalls++ | |||
| 21 | + | s.last = enabled | |||
| 22 | + | return nil | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | type failingFastModeSettingsStore struct { | |||
| 26 | + | SettingsStore | |||
| 27 | + | err error | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func (s *failingFastModeSettingsStore) SetFastMode(bool) error { return s.err } | |||
| 31 | + | ||||
| 32 | + | type fastModeProviderClient struct{} | |||
| 33 | + | ||||
| 34 | + | func (fastModeProviderClient) Name() string { return "openai" } | |||
| 35 | + | func (fastModeProviderClient) Stream(_ context.Context, _ provider.Request) (<-chan provider.Event, error) { | |||
| 36 | + | out := make(chan provider.Event) | |||
| 37 | + | close(out) | |||
| 38 | + | return out, nil | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestSettingsDialogOffersFastModeOffByDefault(t *testing.T) { | |||
| 42 | + | interactive := NewInteractive(InteractiveConfig{Provider: "openai", Model: "gpt-5"}) | |||
| 43 | + | interactive.openSettingsDialog() | |||
| 44 | + | ||||
| 45 | + | item := findSettingsItem(interactive.settingsDialog.items, "fast_mode") | |||
| 46 | + | if item == nil { | |||
| 47 | + | t.Fatal("settings dialog is missing fast mode") | |||
| 48 | + | } | |||
| 49 | + | if item.value { | |||
| 50 | + | t.Fatal("fast mode is enabled by default") | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestApplyFastModeUpdatesAgentAndStore(t *testing.T) { | |||
| 55 | + | store := &recordingFastModeSettingsStore{} | |||
| 56 | + | agent := core.NewAgent(fastModeProviderClient{}, "gpt-5", "", nil) | |||
| 57 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 58 | + | Provider: "openai", | |||
| 59 | + | Model: "gpt-5", | |||
| 60 | + | SettingsStore: store, | |||
| 61 | + | Agent: agent, | |||
| 62 | + | }) | |||
| 63 | + | interactive.openSettingsDialog() | |||
| 64 | + | interactive.rend = nil | |||
| 65 | + | ||||
| 66 | + | interactive.applySettingToggle("fast_mode", true) | |||
| 67 | + | ||||
| 68 | + | if interactive.cfg.FastMode == nil || !*interactive.cfg.FastMode { | |||
| 69 | + | t.Fatal("interactive FastMode was not enabled") | |||
| 70 | + | } | |||
| 71 | + | if !agent.FastMode { | |||
| 72 | + | t.Fatal("agent FastMode was not enabled") | |||
| 73 | + | } | |||
| 74 | + | if store.setCalls != 1 || !store.last { | |||
| 75 | + | t.Fatalf("store calls = %d/%v, want one enabled write", store.setCalls, store.last) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestApplyFastModeRejectsUnsupportedProvider(t *testing.T) { | |||
| 80 | + | store := &recordingFastModeSettingsStore{} | |||
| 81 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 82 | + | Provider: "anthropic", | |||
| 83 | + | Model: "claude-sonnet-4-5", | |||
| 84 | + | SettingsStore: store, | |||
| 85 | + | }) | |||
| 86 | + | interactive.openSettingsDialog() | |||
| 87 | + | interactive.rend = nil | |||
| 88 | + | ||||
| 89 | + | interactive.applySettingToggle("fast_mode", true) | |||
| 90 | + | ||||
| 91 | + | if interactive.cfg.FastMode != nil && *interactive.cfg.FastMode { | |||
| 92 | + | t.Fatal("unsupported fast mode was enabled") | |||
| 93 | + | } | |||
| 94 | + | if store.setCalls != 0 { | |||
| 95 | + | t.Fatalf("store calls = %d, want none", store.setCalls) | |||
| 96 | + | } | |||
| 97 | + | if !strings.Contains(interactive.statusErr, "only supported for OpenAI providers") { | |||
| 98 | + | t.Fatalf("status error = %q, want unsupported-provider error", interactive.statusErr) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func TestApplyFastModeCanDisableUnsupportedProvider(t *testing.T) { | |||
| 103 | + | enabled := true | |||
| 104 | + | store := &recordingFastModeSettingsStore{} | |||
| 105 | + | agent := core.NewAgent(fastModeProviderClient{}, "claude-sonnet-4-5", "", nil) | |||
| 106 | + | agent.FastMode = true | |||
| 107 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 108 | + | Provider: "anthropic", | |||
| 109 | + | Model: "claude-sonnet-4-5", | |||
| 110 | + | FastMode: &enabled, | |||
| 111 | + | SettingsStore: store, | |||
| 112 | + | Agent: agent, | |||
| 113 | + | }) | |||
| 114 | + | interactive.openSettingsDialog() | |||
| 115 | + | interactive.rend = nil | |||
| 116 | + | ||||
| 117 | + | interactive.applySettingToggle("fast_mode", false) | |||
| 118 | + | ||||
| 119 | + | if interactive.cfg.FastMode == nil || *interactive.cfg.FastMode { | |||
| 120 | + | t.Fatal("fast mode was not disabled") | |||
| 121 | + | } | |||
| 122 | + | if agent.FastMode { | |||
| 123 | + | t.Fatal("agent fast mode was not disabled") | |||
| 124 | + | } | |||
| 125 | + | if store.setCalls != 1 || store.last { | |||
| 126 | + | t.Fatalf("store calls = %d/%v, want one disabled write", store.setCalls, store.last) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestApplyFastModeLeavesLiveStateWhenPersistenceFails(t *testing.T) { | |||
| 131 | + | store := &failingFastModeSettingsStore{err: errors.New("disk full")} | |||
| 132 | + | agent := core.NewAgent(fastModeProviderClient{}, "gpt-5", "", nil) | |||
| 133 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 134 | + | Provider: "openai", | |||
| 135 | + | Model: "gpt-5", | |||
| 136 | + | SettingsStore: store, | |||
| 137 | + | Agent: agent, | |||
| 138 | + | }) | |||
| 139 | + | interactive.openSettingsDialog() | |||
| 140 | + | interactive.rend = nil | |||
| 141 | + | ||||
| 142 | + | interactive.applySettingToggle("fast_mode", true) | |||
| 143 | + | ||||
| 144 | + | if interactive.cfg.FastMode != nil && *interactive.cfg.FastMode { | |||
| 145 | + | t.Fatal("fast mode changed despite persistence failure") | |||
| 146 | + | } | |||
| 147 | + | if agent.FastMode { | |||
| 148 | + | t.Fatal("agent fast mode changed despite persistence failure") | |||
| 149 | + | } | |||
| 150 | + | if !strings.Contains(interactive.statusErr, "disk full") { | |||
| 151 | + | t.Fatalf("status error = %q, want persistence error", interactive.statusErr) | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func findSettingsItem(items []settingsItem, key string) *settingsItem { | |||
| 156 | + | for idx := range items { | |||
| 157 | + | if items[idx].key == key { | |||
| 158 | + | return &items[idx] | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | return nil | |||
| 162 | + | } | |||
M packages/agent/modes/interactive.go +64 -0
| @@ -68,6 +68,11 @@ type InteractiveConfig struct { | |||
| 68 | 68 | // re-reading config.json on every open. | |
| 69 | 69 | AutoSwarmEnabled *bool | |
| 70 | 70 | ||
| 71 | + | // FastMode mirrors the persisted OpenAI fast-mode flag at startup. | |
| 72 | + | // nil/missing means disabled. Unsupported providers reject attempts | |
| 73 | + | // to enable it and reject requests when it remains enabled. | |
| 74 | + | FastMode *bool | |
| 75 | + | ||
| 71 | 76 | // AutoCompactThreshold is the context-window percentage that triggers | |
| 72 | 77 | // automatic compaction. nil means 85; zero disables percentage-based | |
| 73 | 78 | // triggers while preserving payload-too-large recovery. | |
| @@ -370,6 +375,10 @@ type autoCompactThresholdSettingsStore interface { | |||
| 370 | 375 | SetAutoCompactThreshold(percent int) error | |
| 371 | 376 | } | |
| 372 | 377 | ||
| 378 | + | type fastModeSettingsStore interface { | |
| 379 | + | SetFastMode(enabled bool) error | |
| 380 | + | } | |
| 381 | + | ||
| 373 | 382 | type Interactive struct { | |
| 374 | 383 | cfg InteractiveConfig | |
| 375 | 384 | view *tui.View | |
| @@ -3411,6 +3420,12 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3411 | 3420 | autoSwarmHint = "swarm supervisor not available in this mode" | |
| 3412 | 3421 | } | |
| 3413 | 3422 | ||
| 3423 | + | fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode | |
| 3424 | + | fastModeHint := "OpenAI service tier" | |
| 3425 | + | if !provider.SupportsFastMode(i.cfg.Provider) { | |
| 3426 | + | fastModeHint = "only supported for OpenAI providers" | |
| 3427 | + | } | |
| 3428 | + | ||
| 3414 | 3429 | jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault | |
| 3415 | 3430 | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | |
| 3416 | 3431 | respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore | |
| @@ -3523,6 +3538,13 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3523 | 3538 | disabled: autoSwarmDisabled, | |
| 3524 | 3539 | hint: autoSwarmHint, | |
| 3525 | 3540 | }, | |
| 3541 | + | { | |
| 3542 | + | key: "fast_mode", | |
| 3543 | + | label: "fast mode", | |
| 3544 | + | desc: "request OpenAI's fast service tier; unsupported providers return an error", | |
| 3545 | + | value: fastMode, | |
| 3546 | + | hint: fastModeHint, | |
| 3547 | + | }, | |
| 3526 | 3548 | { | |
| 3527 | 3549 | key: "auto_compact_threshold", | |
| 3528 | 3550 | label: "auto-compact threshold", | |
| @@ -3821,6 +3843,18 @@ func (i *Interactive) refreshQuickModelSettingsItem(slot int) { | |||
| 3821 | 3843 | } | |
| 3822 | 3844 | } | |
| 3823 | 3845 | ||
| 3846 | + | func (i *Interactive) resetSettingsToggle(key string, value bool) { | |
| 3847 | + | if i.settingsDialog == nil { | |
| 3848 | + | return | |
| 3849 | + | } | |
| 3850 | + | for idx := range i.settingsDialog.items { | |
| 3851 | + | if i.settingsDialog.items[idx].key == key { | |
| 3852 | + | i.settingsDialog.items[idx].value = value | |
| 3853 | + | return | |
| 3854 | + | } | |
| 3855 | + | } | |
| 3856 | + | } | |
| 3857 | + | ||
| 3824 | 3858 | func (i *Interactive) applySettingToggle(key string, value bool) { | |
| 3825 | 3859 | // Every setting toggle forces a full repaint at the end — same | |
| 3826 | 3860 | // effect as the user pressing Ctrl+L — so any per-setting visual | |
| @@ -3891,6 +3925,36 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 3891 | 3925 | i.statusOK = "auto-swarm " + onOff(value) | |
| 3892 | 3926 | i.statusErr = "" | |
| 3893 | 3927 | i.mu.Unlock() | |
| 3928 | + | case "fast_mode": | |
| 3929 | + | previous := i.cfg.FastMode != nil && *i.cfg.FastMode | |
| 3930 | + | if err := provider.ValidateFastMode(i.cfg.Provider, value); err != nil { | |
| 3931 | + | i.resetSettingsToggle(key, previous) | |
| 3932 | + | i.mu.Lock() | |
| 3933 | + | i.statusErr = "settings: " + err.Error() | |
| 3934 | + | i.mu.Unlock() | |
| 3935 | + | return | |
| 3936 | + | } | |
| 3937 | + | if store, ok := i.cfg.SettingsStore.(fastModeSettingsStore); ok { | |
| 3938 | + | if err := store.SetFastMode(value); err != nil { | |
| 3939 | + | i.resetSettingsToggle(key, previous) | |
| 3940 | + | i.mu.Lock() | |
| 3941 | + | i.statusErr = "settings: " + err.Error() | |
| 3942 | + | i.mu.Unlock() | |
| 3943 | + | return | |
| 3944 | + | } | |
| 3945 | + | } | |
| 3946 | + | val := value | |
| 3947 | + | i.cfg.FastMode = &val | |
| 3948 | + | if i.agent != nil { | |
| 3949 | + | i.agent.SetFastMode(value) | |
| 3950 | + | } | |
| 3951 | + | if i.cfg.Swarm != nil { | |
| 3952 | + | i.cfg.Swarm.SetFastMode(value) | |
| 3953 | + | } | |
| 3954 | + | i.mu.Lock() | |
| 3955 | + | i.statusOK = "fast mode " + onOff(value) | |
| 3956 | + | i.statusErr = "" | |
| 3957 | + | i.mu.Unlock() | |
| 3894 | 3958 | case "jail_by_default": | |
| 3895 | 3959 | val := value | |
| 3896 | 3960 | i.cfg.JailByDefault = &val | |
| @@ -68,6 +68,11 @@ type InteractiveConfig struct { | |||||
| 68 | // re-reading config.json on every open. | 68 | // re-reading config.json on every open. | ||
| 69 | AutoSwarmEnabled *bool | 69 | AutoSwarmEnabled *bool | ||
| 70 | 70 | ||||
| 71 | + | // FastMode mirrors the persisted OpenAI fast-mode flag at startup. | |||
| 72 | + | // nil/missing means disabled. Unsupported providers reject attempts | |||
| 73 | + | // to enable it and reject requests when it remains enabled. | |||
| 74 | + | FastMode *bool | |||
| 75 | + | ||||
| 71 | // AutoCompactThreshold is the context-window percentage that triggers | 76 | // AutoCompactThreshold is the context-window percentage that triggers | ||
| 72 | // automatic compaction. nil means 85; zero disables percentage-based | 77 | // automatic compaction. nil means 85; zero disables percentage-based | ||
| 73 | // triggers while preserving payload-too-large recovery. | 78 | // triggers while preserving payload-too-large recovery. | ||
| @@ -370,6 +375,10 @@ type autoCompactThresholdSettingsStore interface { | |||||
| 370 | SetAutoCompactThreshold(percent int) error | 375 | SetAutoCompactThreshold(percent int) error | ||
| 371 | } | 376 | } | ||
| 372 | 377 | ||||
| 378 | + | type fastModeSettingsStore interface { | |||
| 379 | + | SetFastMode(enabled bool) error | |||
| 380 | + | } | |||
| 381 | + | ||||
| 373 | type Interactive struct { | 382 | type Interactive struct { | ||
| 374 | cfg InteractiveConfig | 383 | cfg InteractiveConfig | ||
| 375 | view *tui.View | 384 | view *tui.View | ||
| @@ -3411,6 +3420,12 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 3411 | autoSwarmHint = "swarm supervisor not available in this mode" | 3420 | autoSwarmHint = "swarm supervisor not available in this mode" | ||
| 3412 | } | 3421 | } | ||
| 3413 | 3422 | ||||
| 3423 | + | fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode | |||
| 3424 | + | fastModeHint := "OpenAI service tier" | |||
| 3425 | + | if !provider.SupportsFastMode(i.cfg.Provider) { | |||
| 3426 | + | fastModeHint = "only supported for OpenAI providers" | |||
| 3427 | + | } | |||
| 3428 | + | ||||
| 3414 | jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault | 3429 | jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault | ||
| 3415 | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | 3430 | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | ||
| 3416 | respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore | 3431 | respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore | ||
| @@ -3523,6 +3538,13 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 3523 | disabled: autoSwarmDisabled, | 3538 | disabled: autoSwarmDisabled, | ||
| 3524 | hint: autoSwarmHint, | 3539 | hint: autoSwarmHint, | ||
| 3525 | }, | 3540 | }, | ||
| 3541 | + | { | |||
| 3542 | + | key: "fast_mode", | |||
| 3543 | + | label: "fast mode", | |||
| 3544 | + | desc: "request OpenAI's fast service tier; unsupported providers return an error", | |||
| 3545 | + | value: fastMode, | |||
| 3546 | + | hint: fastModeHint, | |||
| 3547 | + | }, | |||
| 3526 | { | 3548 | { | ||
| 3527 | key: "auto_compact_threshold", | 3549 | key: "auto_compact_threshold", | ||
| 3528 | label: "auto-compact threshold", | 3550 | label: "auto-compact threshold", | ||
| @@ -3821,6 +3843,18 @@ func (i *Interactive) refreshQuickModelSettingsItem(slot int) { | |||||
| 3821 | } | 3843 | } | ||
| 3822 | } | 3844 | } | ||
| 3823 | 3845 | ||||
| 3846 | + | func (i *Interactive) resetSettingsToggle(key string, value bool) { | |||
| 3847 | + | if i.settingsDialog == nil { | |||
| 3848 | + | return | |||
| 3849 | + | } | |||
| 3850 | + | for idx := range i.settingsDialog.items { | |||
| 3851 | + | if i.settingsDialog.items[idx].key == key { | |||
| 3852 | + | i.settingsDialog.items[idx].value = value | |||
| 3853 | + | return | |||
| 3854 | + | } | |||
| 3855 | + | } | |||
| 3856 | + | } | |||
| 3857 | + | ||||
| 3824 | func (i *Interactive) applySettingToggle(key string, value bool) { | 3858 | func (i *Interactive) applySettingToggle(key string, value bool) { | ||
| 3825 | // Every setting toggle forces a full repaint at the end — same | 3859 | // Every setting toggle forces a full repaint at the end — same | ||
| 3826 | // effect as the user pressing Ctrl+L — so any per-setting visual | 3860 | // effect as the user pressing Ctrl+L — so any per-setting visual | ||
| @@ -3891,6 +3925,36 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||||
| 3891 | i.statusOK = "auto-swarm " + onOff(value) | 3925 | i.statusOK = "auto-swarm " + onOff(value) | ||
| 3892 | i.statusErr = "" | 3926 | i.statusErr = "" | ||
| 3893 | i.mu.Unlock() | 3927 | i.mu.Unlock() | ||
| 3928 | + | case "fast_mode": | |||
| 3929 | + | previous := i.cfg.FastMode != nil && *i.cfg.FastMode | |||
| 3930 | + | if err := provider.ValidateFastMode(i.cfg.Provider, value); err != nil { | |||
| 3931 | + | i.resetSettingsToggle(key, previous) | |||
| 3932 | + | i.mu.Lock() | |||
| 3933 | + | i.statusErr = "settings: " + err.Error() | |||
| 3934 | + | i.mu.Unlock() | |||
| 3935 | + | return | |||
| 3936 | + | } | |||
| 3937 | + | if store, ok := i.cfg.SettingsStore.(fastModeSettingsStore); ok { | |||
| 3938 | + | if err := store.SetFastMode(value); err != nil { | |||
| 3939 | + | i.resetSettingsToggle(key, previous) | |||
| 3940 | + | i.mu.Lock() | |||
| 3941 | + | i.statusErr = "settings: " + err.Error() | |||
| 3942 | + | i.mu.Unlock() | |||
| 3943 | + | return | |||
| 3944 | + | } | |||
| 3945 | + | } | |||
| 3946 | + | val := value | |||
| 3947 | + | i.cfg.FastMode = &val | |||
| 3948 | + | if i.agent != nil { | |||
| 3949 | + | i.agent.SetFastMode(value) | |||
| 3950 | + | } | |||
| 3951 | + | if i.cfg.Swarm != nil { | |||
| 3952 | + | i.cfg.Swarm.SetFastMode(value) | |||
| 3953 | + | } | |||
| 3954 | + | i.mu.Lock() | |||
| 3955 | + | i.statusOK = "fast mode " + onOff(value) | |||
| 3956 | + | i.statusErr = "" | |||
| 3957 | + | i.mu.Unlock() | |||
| 3894 | case "jail_by_default": | 3958 | case "jail_by_default": | ||
| 3895 | val := value | 3959 | val := value | ||
| 3896 | i.cfg.JailByDefault = &val | 3960 | i.cfg.JailByDefault = &val | ||
M packages/agent/settings_store.go +9 -0
| @@ -59,6 +59,15 @@ func (configSettingsStore) SetAutoSwarm(enabled bool) error { | |||
| 59 | 59 | return SaveConfig(cfg) | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | + | func (configSettingsStore) SetFastMode(enabled bool) error { | |
| 63 | + | cfg, err := LoadConfig() | |
| 64 | + | if err != nil { | |
| 65 | + | return err | |
| 66 | + | } | |
| 67 | + | cfg.FastMode = &enabled | |
| 68 | + | return SaveConfig(cfg) | |
| 69 | + | } | |
| 70 | + | ||
| 62 | 71 | func (configSettingsStore) SetAutoCompactThreshold(percent int) error { | |
| 63 | 72 | cfg, err := LoadConfig() | |
| 64 | 73 | if err != nil { | |
| @@ -59,6 +59,15 @@ func (configSettingsStore) SetAutoSwarm(enabled bool) error { | |||||
| 59 | return SaveConfig(cfg) | 59 | return SaveConfig(cfg) | ||
| 60 | } | 60 | } | ||
| 61 | 61 | ||||
| 62 | + | func (configSettingsStore) SetFastMode(enabled bool) error { | |||
| 63 | + | cfg, err := LoadConfig() | |||
| 64 | + | if err != nil { | |||
| 65 | + | return err | |||
| 66 | + | } | |||
| 67 | + | cfg.FastMode = &enabled | |||
| 68 | + | return SaveConfig(cfg) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 62 | func (configSettingsStore) SetAutoCompactThreshold(percent int) error { | 71 | func (configSettingsStore) SetAutoCompactThreshold(percent int) error { | ||
| 63 | cfg, err := LoadConfig() | 72 | cfg, err := LoadConfig() | ||
| 64 | if err != nil { | 73 | if err != nil { | ||
M packages/agent/swarm/agent.go +1 -0
| @@ -25,6 +25,7 @@ type Agent struct { | |||
| 25 | 25 | Model string | |
| 26 | 26 | Provider string | |
| 27 | 27 | Reasoning string | |
| 28 | + | FastMode bool | |
| 28 | 29 | Subagent string | |
| 29 | 30 | ||
| 30 | 31 | // SessionID, when non-empty, scopes the agent to a particular | |
| @@ -25,6 +25,7 @@ type Agent struct { | |||||
| 25 | Model string | 25 | Model string | ||
| 26 | Provider string | 26 | Provider string | ||
| 27 | Reasoning string | 27 | Reasoning string | ||
| 28 | + | FastMode bool | |||
| 28 | Subagent string | 29 | Subagent string | ||
| 29 | 30 | ||||
| 30 | // SessionID, when non-empty, scopes the agent to a particular | 31 | // SessionID, when non-empty, scopes the agent to a particular | ||
A packages/agent/swarm/fast_mode_test.go +136 -0
| @@ -0,0 +1,136 @@ | |||
| 1 | + | package swarm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestSwarmFastModePropagatesToChild(t *testing.T) { | |
| 10 | + | root := t.TempDir() | |
| 11 | + | f := New(Config{ | |
| 12 | + | Root: root, | |
| 13 | + | RepoRoot: root, | |
| 14 | + | FastMode: true, | |
| 15 | + | NewRunner: func(a *Agent) Runner { | |
| 16 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 17 | + | <-ctx.Done() | |
| 18 | + | return ctx.Err() | |
| 19 | + | }) | |
| 20 | + | }, | |
| 21 | + | }) | |
| 22 | + | ||
| 23 | + | a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "x"}) | |
| 24 | + | if err != nil { | |
| 25 | + | t.Fatalf("spawn: %v", err) | |
| 26 | + | } | |
| 27 | + | cleanedUp := false | |
| 28 | + | t.Cleanup(func() { | |
| 29 | + | if cleanedUp { | |
| 30 | + | return | |
| 31 | + | } | |
| 32 | + | if err := f.Stop(a.ID); err != nil { | |
| 33 | + | t.Errorf("cleanup stop: %v", err) | |
| 34 | + | } | |
| 35 | + | a.Wait() | |
| 36 | + | }) | |
| 37 | + | if !a.FastMode { | |
| 38 | + | t.Fatal("spawned agent FastMode = false, want true") | |
| 39 | + | } | |
| 40 | + | args := defaultChildArgs("/zot", a, "/session", "/inbox") | |
| 41 | + | if !containsArg(args, "--fast-mode") { | |
| 42 | + | t.Fatalf("child args = %v, want --fast-mode", args) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | if err := f.Stop(a.ID); err != nil { | |
| 46 | + | t.Fatalf("stop: %v", err) | |
| 47 | + | } | |
| 48 | + | a.Wait() | |
| 49 | + | cleanedUp = true | |
| 50 | + | ||
| 51 | + | g := New(Config{Root: root, RepoRoot: root}) | |
| 52 | + | if loaded, errs := g.Reload(); loaded != 1 || len(errs) != 0 { | |
| 53 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |
| 54 | + | } | |
| 55 | + | reloaded := g.Get(a.ID) | |
| 56 | + | if reloaded == nil || !reloaded.FastMode { | |
| 57 | + | t.Fatalf("reloaded FastMode = %v, want true", reloaded != nil && reloaded.FastMode) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestSwarmReloadPreservesExplicitFastModeOff(t *testing.T) { | |
| 62 | + | root := t.TempDir() | |
| 63 | + | newSwarm := func(fastMode bool) *Swarm { | |
| 64 | + | return New(Config{ | |
| 65 | + | Root: root, | |
| 66 | + | RepoRoot: root, | |
| 67 | + | FastMode: fastMode, | |
| 68 | + | NewRunner: func(a *Agent) Runner { | |
| 69 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |
| 70 | + | <-ctx.Done() | |
| 71 | + | return ctx.Err() | |
| 72 | + | }) | |
| 73 | + | }, | |
| 74 | + | }) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | f := newSwarm(false) | |
| 78 | + | a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "x"}) | |
| 79 | + | if err != nil { | |
| 80 | + | t.Fatalf("spawn: %v", err) | |
| 81 | + | } | |
| 82 | + | if a.FastMode { | |
| 83 | + | t.Fatal("spawned agent FastMode = true, want false") | |
| 84 | + | } | |
| 85 | + | if err := f.Stop(a.ID); err != nil { | |
| 86 | + | t.Fatalf("stop: %v", err) | |
| 87 | + | } | |
| 88 | + | a.Wait() | |
| 89 | + | ||
| 90 | + | g := newSwarm(true) | |
| 91 | + | if loaded, errs := g.Reload(); loaded != 1 || len(errs) != 0 { | |
| 92 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |
| 93 | + | } | |
| 94 | + | if reloaded := g.Get(a.ID); reloaded == nil || reloaded.FastMode { | |
| 95 | + | t.Fatalf("reloaded FastMode = %v, want false", reloaded != nil && reloaded.FastMode) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestSwarmAgentArgsOmitFastModeWhenUnset(t *testing.T) { | |
| 100 | + | args := swarmAgentArgs(swarmAgentArgsOpts{Exe: "/zot", Dir: "/wt", SessionPath: "/s", InboxPath: "/i"}) | |
| 101 | + | if strings.Contains(strings.Join(args, " "), "--fast-mode") || strings.Contains(strings.Join(args, " "), "--no-fast-mode") { | |
| 102 | + | t.Fatalf("child args = %v, want fast mode omitted when unset", args) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestSwarmAgentArgsExplicitlyDisablesFastMode(t *testing.T) { | |
| 107 | + | args := swarmAgentArgs(swarmAgentArgsOpts{ | |
| 108 | + | Exe: "/zot", | |
| 109 | + | Dir: "/wt", | |
| 110 | + | SessionPath: "/s", | |
| 111 | + | InboxPath: "/i", | |
| 112 | + | FastModeSet: true, | |
| 113 | + | }) | |
| 114 | + | if !containsArg(args, "--no-fast-mode") { | |
| 115 | + | t.Fatalf("child args = %v, want --no-fast-mode", args) | |
| 116 | + | } | |
| 117 | + | if containsArg(args, "--fast-mode") { | |
| 118 | + | t.Fatalf("child args = %v, want no positive fast-mode flag", args) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestDefaultChildArgsExplicitlyDisablesFastMode(t *testing.T) { | |
| 123 | + | args := defaultChildArgs("/zot", &Agent{Dir: "/wt"}, "/s", "/i") | |
| 124 | + | if !containsArg(args, "--no-fast-mode") { | |
| 125 | + | t.Fatalf("child args = %v, want --no-fast-mode", args) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func containsArg(args []string, want string) bool { | |
| 130 | + | for _, arg := range args { | |
| 131 | + | if arg == want { | |
| 132 | + | return true | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | return false | |
| 136 | + | } | |
| @@ -0,0 +1,136 @@ | |||||
| 1 | + | package swarm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestSwarmFastModePropagatesToChild(t *testing.T) { | |||
| 10 | + | root := t.TempDir() | |||
| 11 | + | f := New(Config{ | |||
| 12 | + | Root: root, | |||
| 13 | + | RepoRoot: root, | |||
| 14 | + | FastMode: true, | |||
| 15 | + | NewRunner: func(a *Agent) Runner { | |||
| 16 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |||
| 17 | + | <-ctx.Done() | |||
| 18 | + | return ctx.Err() | |||
| 19 | + | }) | |||
| 20 | + | }, | |||
| 21 | + | }) | |||
| 22 | + | ||||
| 23 | + | a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "x"}) | |||
| 24 | + | if err != nil { | |||
| 25 | + | t.Fatalf("spawn: %v", err) | |||
| 26 | + | } | |||
| 27 | + | cleanedUp := false | |||
| 28 | + | t.Cleanup(func() { | |||
| 29 | + | if cleanedUp { | |||
| 30 | + | return | |||
| 31 | + | } | |||
| 32 | + | if err := f.Stop(a.ID); err != nil { | |||
| 33 | + | t.Errorf("cleanup stop: %v", err) | |||
| 34 | + | } | |||
| 35 | + | a.Wait() | |||
| 36 | + | }) | |||
| 37 | + | if !a.FastMode { | |||
| 38 | + | t.Fatal("spawned agent FastMode = false, want true") | |||
| 39 | + | } | |||
| 40 | + | args := defaultChildArgs("/zot", a, "/session", "/inbox") | |||
| 41 | + | if !containsArg(args, "--fast-mode") { | |||
| 42 | + | t.Fatalf("child args = %v, want --fast-mode", args) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | if err := f.Stop(a.ID); err != nil { | |||
| 46 | + | t.Fatalf("stop: %v", err) | |||
| 47 | + | } | |||
| 48 | + | a.Wait() | |||
| 49 | + | cleanedUp = true | |||
| 50 | + | ||||
| 51 | + | g := New(Config{Root: root, RepoRoot: root}) | |||
| 52 | + | if loaded, errs := g.Reload(); loaded != 1 || len(errs) != 0 { | |||
| 53 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |||
| 54 | + | } | |||
| 55 | + | reloaded := g.Get(a.ID) | |||
| 56 | + | if reloaded == nil || !reloaded.FastMode { | |||
| 57 | + | t.Fatalf("reloaded FastMode = %v, want true", reloaded != nil && reloaded.FastMode) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestSwarmReloadPreservesExplicitFastModeOff(t *testing.T) { | |||
| 62 | + | root := t.TempDir() | |||
| 63 | + | newSwarm := func(fastMode bool) *Swarm { | |||
| 64 | + | return New(Config{ | |||
| 65 | + | Root: root, | |||
| 66 | + | RepoRoot: root, | |||
| 67 | + | FastMode: fastMode, | |||
| 68 | + | NewRunner: func(a *Agent) Runner { | |||
| 69 | + | return RunnerFunc(func(ctx context.Context, _ Sink) error { | |||
| 70 | + | <-ctx.Done() | |||
| 71 | + | return ctx.Err() | |||
| 72 | + | }) | |||
| 73 | + | }, | |||
| 74 | + | }) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | f := newSwarm(false) | |||
| 78 | + | a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "x"}) | |||
| 79 | + | if err != nil { | |||
| 80 | + | t.Fatalf("spawn: %v", err) | |||
| 81 | + | } | |||
| 82 | + | if a.FastMode { | |||
| 83 | + | t.Fatal("spawned agent FastMode = true, want false") | |||
| 84 | + | } | |||
| 85 | + | if err := f.Stop(a.ID); err != nil { | |||
| 86 | + | t.Fatalf("stop: %v", err) | |||
| 87 | + | } | |||
| 88 | + | a.Wait() | |||
| 89 | + | ||||
| 90 | + | g := newSwarm(true) | |||
| 91 | + | if loaded, errs := g.Reload(); loaded != 1 || len(errs) != 0 { | |||
| 92 | + | t.Fatalf("reload loaded=%d errs=%v", loaded, errs) | |||
| 93 | + | } | |||
| 94 | + | if reloaded := g.Get(a.ID); reloaded == nil || reloaded.FastMode { | |||
| 95 | + | t.Fatalf("reloaded FastMode = %v, want false", reloaded != nil && reloaded.FastMode) | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestSwarmAgentArgsOmitFastModeWhenUnset(t *testing.T) { | |||
| 100 | + | args := swarmAgentArgs(swarmAgentArgsOpts{Exe: "/zot", Dir: "/wt", SessionPath: "/s", InboxPath: "/i"}) | |||
| 101 | + | if strings.Contains(strings.Join(args, " "), "--fast-mode") || strings.Contains(strings.Join(args, " "), "--no-fast-mode") { | |||
| 102 | + | t.Fatalf("child args = %v, want fast mode omitted when unset", args) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestSwarmAgentArgsExplicitlyDisablesFastMode(t *testing.T) { | |||
| 107 | + | args := swarmAgentArgs(swarmAgentArgsOpts{ | |||
| 108 | + | Exe: "/zot", | |||
| 109 | + | Dir: "/wt", | |||
| 110 | + | SessionPath: "/s", | |||
| 111 | + | InboxPath: "/i", | |||
| 112 | + | FastModeSet: true, | |||
| 113 | + | }) | |||
| 114 | + | if !containsArg(args, "--no-fast-mode") { | |||
| 115 | + | t.Fatalf("child args = %v, want --no-fast-mode", args) | |||
| 116 | + | } | |||
| 117 | + | if containsArg(args, "--fast-mode") { | |||
| 118 | + | t.Fatalf("child args = %v, want no positive fast-mode flag", args) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func TestDefaultChildArgsExplicitlyDisablesFastMode(t *testing.T) { | |||
| 123 | + | args := defaultChildArgs("/zot", &Agent{Dir: "/wt"}, "/s", "/i") | |||
| 124 | + | if !containsArg(args, "--no-fast-mode") { | |||
| 125 | + | t.Fatalf("child args = %v, want --no-fast-mode", args) | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func containsArg(args []string, want string) bool { | |||
| 130 | + | for _, arg := range args { | |||
| 131 | + | if arg == want { | |||
| 132 | + | return true | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | return false | |||
| 136 | + | } | |||
M packages/agent/swarm/persist.go +12 -1
| @@ -45,6 +45,7 @@ type agentMeta struct { | |||
| 45 | 45 | Model string `json:"model,omitempty"` | |
| 46 | 46 | Provider string `json:"provider,omitempty"` | |
| 47 | 47 | Reasoning string `json:"reasoning,omitempty"` | |
| 48 | + | FastMode *bool `json:"fast_mode,omitempty"` | |
| 48 | 49 | Subagent string `json:"subagent,omitempty"` | |
| 49 | 50 | InboxPath string `json:"inbox_path"` | |
| 50 | 51 | EventLogPath string `json:"event_log_path"` | |
| @@ -66,6 +67,7 @@ func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.jso | |||
| 66 | 67 | // write is atomic (tmp + rename) so a crash mid-write can't leave a | |
| 67 | 68 | // half-parsable file that fails Reload. | |
| 68 | 69 | func writeAgentMeta(stateDir string, a *Agent) error { | |
| 70 | + | fastMode := a.FastMode | |
| 69 | 71 | m := agentMeta{ | |
| 70 | 72 | ID: a.ID, | |
| 71 | 73 | Task: a.Task, | |
| @@ -74,6 +76,7 @@ func writeAgentMeta(stateDir string, a *Agent) error { | |||
| 74 | 76 | Model: a.Model, | |
| 75 | 77 | Provider: a.Provider, | |
| 76 | 78 | Reasoning: a.Reasoning, | |
| 79 | + | FastMode: &fastMode, | |
| 77 | 80 | Subagent: a.Subagent, | |
| 78 | 81 | InboxPath: a.InboxPath, | |
| 79 | 82 | EventLogPath: a.EventLogPath, | |
| @@ -202,6 +205,10 @@ func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent { | |||
| 202 | 205 | if f.cfg.RepoRoot != "" { | |
| 203 | 206 | dir = f.cfg.RepoRoot | |
| 204 | 207 | } | |
| 208 | + | fastMode := f.cfg.FastMode | |
| 209 | + | if m.FastMode != nil { | |
| 210 | + | fastMode = *m.FastMode | |
| 211 | + | } | |
| 205 | 212 | a := &Agent{ | |
| 206 | 213 | ID: m.ID, | |
| 207 | 214 | Task: m.Task, | |
| @@ -210,6 +217,7 @@ func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent { | |||
| 210 | 217 | Model: m.Model, | |
| 211 | 218 | Provider: m.Provider, | |
| 212 | 219 | Reasoning: m.Reasoning, | |
| 220 | + | FastMode: fastMode, | |
| 213 | 221 | Subagent: m.Subagent, | |
| 214 | 222 | InboxPath: m.InboxPath, | |
| 215 | 223 | EventLogPath: m.EventLogPath, | |
| @@ -348,11 +356,13 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||
| 348 | 356 | if err != nil { | |
| 349 | 357 | return nil, fmt.Errorf("swarm inbox path: %w", err) | |
| 350 | 358 | } | |
| 359 | + | fastMode := existing.FastMode | |
| 351 | 360 | m := agentMeta{ | |
| 352 | 361 | ID: existing.ID, Task: existing.Task, | |
| 353 | 362 | Dir: existing.Dir, Started: existing.Started, | |
| 354 | 363 | Model: existing.Model, Provider: existing.Provider, | |
| 355 | - | Reasoning: existing.Reasoning, Subagent: existing.Subagent, | |
| 364 | + | Reasoning: existing.Reasoning, FastMode: &fastMode, | |
| 365 | + | Subagent: existing.Subagent, | |
| 356 | 366 | SessionID: existing.SessionID, | |
| 357 | 367 | InboxPath: inboxPath, EventLogPath: existing.EventLogPath, | |
| 358 | 368 | SessionPath: existing.SessionPath, | |
| @@ -366,6 +376,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||
| 366 | 376 | Model: m.Model, | |
| 367 | 377 | Provider: m.Provider, | |
| 368 | 378 | Reasoning: m.Reasoning, | |
| 379 | + | FastMode: fastMode, | |
| 369 | 380 | Subagent: m.Subagent, | |
| 370 | 381 | SessionID: m.SessionID, | |
| 371 | 382 | InboxPath: m.InboxPath, | |
| @@ -45,6 +45,7 @@ type agentMeta struct { | |||||
| 45 | Model string `json:"model,omitempty"` | 45 | Model string `json:"model,omitempty"` | ||
| 46 | Provider string `json:"provider,omitempty"` | 46 | Provider string `json:"provider,omitempty"` | ||
| 47 | Reasoning string `json:"reasoning,omitempty"` | 47 | Reasoning string `json:"reasoning,omitempty"` | ||
| 48 | + | FastMode *bool `json:"fast_mode,omitempty"` | |||
| 48 | Subagent string `json:"subagent,omitempty"` | 49 | Subagent string `json:"subagent,omitempty"` | ||
| 49 | InboxPath string `json:"inbox_path"` | 50 | InboxPath string `json:"inbox_path"` | ||
| 50 | EventLogPath string `json:"event_log_path"` | 51 | EventLogPath string `json:"event_log_path"` | ||
| @@ -66,6 +67,7 @@ func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.jso | |||||
| 66 | // write is atomic (tmp + rename) so a crash mid-write can't leave a | 67 | // write is atomic (tmp + rename) so a crash mid-write can't leave a | ||
| 67 | // half-parsable file that fails Reload. | 68 | // half-parsable file that fails Reload. | ||
| 68 | func writeAgentMeta(stateDir string, a *Agent) error { | 69 | func writeAgentMeta(stateDir string, a *Agent) error { | ||
| 70 | + | fastMode := a.FastMode | |||
| 69 | m := agentMeta{ | 71 | m := agentMeta{ | ||
| 70 | ID: a.ID, | 72 | ID: a.ID, | ||
| 71 | Task: a.Task, | 73 | Task: a.Task, | ||
| @@ -74,6 +76,7 @@ func writeAgentMeta(stateDir string, a *Agent) error { | |||||
| 74 | Model: a.Model, | 76 | Model: a.Model, | ||
| 75 | Provider: a.Provider, | 77 | Provider: a.Provider, | ||
| 76 | Reasoning: a.Reasoning, | 78 | Reasoning: a.Reasoning, | ||
| 79 | + | FastMode: &fastMode, | |||
| 77 | Subagent: a.Subagent, | 80 | Subagent: a.Subagent, | ||
| 78 | InboxPath: a.InboxPath, | 81 | InboxPath: a.InboxPath, | ||
| 79 | EventLogPath: a.EventLogPath, | 82 | EventLogPath: a.EventLogPath, | ||
| @@ -202,6 +205,10 @@ func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent { | |||||
| 202 | if f.cfg.RepoRoot != "" { | 205 | if f.cfg.RepoRoot != "" { | ||
| 203 | dir = f.cfg.RepoRoot | 206 | dir = f.cfg.RepoRoot | ||
| 204 | } | 207 | } | ||
| 208 | + | fastMode := f.cfg.FastMode | |||
| 209 | + | if m.FastMode != nil { | |||
| 210 | + | fastMode = *m.FastMode | |||
| 211 | + | } | |||
| 205 | a := &Agent{ | 212 | a := &Agent{ | ||
| 206 | ID: m.ID, | 213 | ID: m.ID, | ||
| 207 | Task: m.Task, | 214 | Task: m.Task, | ||
| @@ -210,6 +217,7 @@ func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent { | |||||
| 210 | Model: m.Model, | 217 | Model: m.Model, | ||
| 211 | Provider: m.Provider, | 218 | Provider: m.Provider, | ||
| 212 | Reasoning: m.Reasoning, | 219 | Reasoning: m.Reasoning, | ||
| 220 | + | FastMode: fastMode, | |||
| 213 | Subagent: m.Subagent, | 221 | Subagent: m.Subagent, | ||
| 214 | InboxPath: m.InboxPath, | 222 | InboxPath: m.InboxPath, | ||
| 215 | EventLogPath: m.EventLogPath, | 223 | EventLogPath: m.EventLogPath, | ||
| @@ -348,11 +356,13 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||||
| 348 | if err != nil { | 356 | if err != nil { | ||
| 349 | return nil, fmt.Errorf("swarm inbox path: %w", err) | 357 | return nil, fmt.Errorf("swarm inbox path: %w", err) | ||
| 350 | } | 358 | } | ||
| 359 | + | fastMode := existing.FastMode | |||
| 351 | m := agentMeta{ | 360 | m := agentMeta{ | ||
| 352 | ID: existing.ID, Task: existing.Task, | 361 | ID: existing.ID, Task: existing.Task, | ||
| 353 | Dir: existing.Dir, Started: existing.Started, | 362 | Dir: existing.Dir, Started: existing.Started, | ||
| 354 | Model: existing.Model, Provider: existing.Provider, | 363 | Model: existing.Model, Provider: existing.Provider, | ||
| 355 | - | Reasoning: existing.Reasoning, Subagent: existing.Subagent, | 364 | + | Reasoning: existing.Reasoning, FastMode: &fastMode, |
| 365 | + | Subagent: existing.Subagent, | |||
| 356 | SessionID: existing.SessionID, | 366 | SessionID: existing.SessionID, | ||
| 357 | InboxPath: inboxPath, EventLogPath: existing.EventLogPath, | 367 | InboxPath: inboxPath, EventLogPath: existing.EventLogPath, | ||
| 358 | SessionPath: existing.SessionPath, | 368 | SessionPath: existing.SessionPath, | ||
| @@ -366,6 +376,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) { | |||||
| 366 | Model: m.Model, | 376 | Model: m.Model, | ||
| 367 | Provider: m.Provider, | 377 | Provider: m.Provider, | ||
| 368 | Reasoning: m.Reasoning, | 378 | Reasoning: m.Reasoning, | ||
| 379 | + | FastMode: fastMode, | |||
| 369 | Subagent: m.Subagent, | 380 | Subagent: m.Subagent, | ||
| 370 | SessionID: m.SessionID, | 381 | SessionID: m.SessionID, | ||
| 371 | InboxPath: m.InboxPath, | 382 | InboxPath: m.InboxPath, | ||
M packages/agent/swarm/runner.go +11 -0
| @@ -67,6 +67,8 @@ type swarmAgentArgsOpts struct { | |||
| 67 | 67 | Model string | |
| 68 | 68 | Provider string | |
| 69 | 69 | Reasoning string | |
| 70 | + | FastMode bool | |
| 71 | + | FastModeSet bool | |
| 70 | 72 | Subagent string | |
| 71 | 73 | } | |
| 72 | 74 | ||
| @@ -98,6 +100,8 @@ func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []str | |||
| 98 | 100 | Model: a.Model, | |
| 99 | 101 | Provider: a.Provider, | |
| 100 | 102 | Reasoning: a.Reasoning, | |
| 103 | + | FastMode: a.FastMode, | |
| 104 | + | FastModeSet: true, | |
| 101 | 105 | Subagent: a.Subagent, | |
| 102 | 106 | }) | |
| 103 | 107 | } | |
| @@ -121,6 +125,13 @@ func swarmAgentArgs(opts swarmAgentArgsOpts) []string { | |||
| 121 | 125 | if opts.Reasoning != "" { | |
| 122 | 126 | args = append(args, "--reasoning", opts.Reasoning) | |
| 123 | 127 | } | |
| 128 | + | if opts.FastModeSet { | |
| 129 | + | if opts.FastMode { | |
| 130 | + | args = append(args, "--fast-mode") | |
| 131 | + | } else { | |
| 132 | + | args = append(args, "--no-fast-mode") | |
| 133 | + | } | |
| 134 | + | } | |
| 124 | 135 | if opts.Subagent != "" { | |
| 125 | 136 | args = append(args, "--subagent", opts.Subagent) | |
| 126 | 137 | } | |
| @@ -67,6 +67,8 @@ type swarmAgentArgsOpts struct { | |||||
| 67 | Model string | 67 | Model string | ||
| 68 | Provider string | 68 | Provider string | ||
| 69 | Reasoning string | 69 | Reasoning string | ||
| 70 | + | FastMode bool | |||
| 71 | + | FastModeSet bool | |||
| 70 | Subagent string | 72 | Subagent string | ||
| 71 | } | 73 | } | ||
| 72 | 74 | ||||
| @@ -98,6 +100,8 @@ func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []str | |||||
| 98 | Model: a.Model, | 100 | Model: a.Model, | ||
| 99 | Provider: a.Provider, | 101 | Provider: a.Provider, | ||
| 100 | Reasoning: a.Reasoning, | 102 | Reasoning: a.Reasoning, | ||
| 103 | + | FastMode: a.FastMode, | |||
| 104 | + | FastModeSet: true, | |||
| 101 | Subagent: a.Subagent, | 105 | Subagent: a.Subagent, | ||
| 102 | }) | 106 | }) | ||
| 103 | } | 107 | } | ||
| @@ -121,6 +125,13 @@ func swarmAgentArgs(opts swarmAgentArgsOpts) []string { | |||||
| 121 | if opts.Reasoning != "" { | 125 | if opts.Reasoning != "" { | ||
| 122 | args = append(args, "--reasoning", opts.Reasoning) | 126 | args = append(args, "--reasoning", opts.Reasoning) | ||
| 123 | } | 127 | } | ||
| 128 | + | if opts.FastModeSet { | |||
| 129 | + | if opts.FastMode { | |||
| 130 | + | args = append(args, "--fast-mode") | |||
| 131 | + | } else { | |||
| 132 | + | args = append(args, "--no-fast-mode") | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 124 | if opts.Subagent != "" { | 135 | if opts.Subagent != "" { | ||
| 125 | args = append(args, "--subagent", opts.Subagent) | 136 | args = append(args, "--subagent", opts.Subagent) | ||
| 126 | } | 137 | } | ||
M packages/agent/swarm/runner_test.go +3 -5
| @@ -204,14 +204,12 @@ func TestDefaultChildArgsResumeOmitsTask(t *testing.T) { | |||
| 204 | 204 | t.Fatalf("resume argv contains the task; it would re-fire as a duplicate turn\n%v", args) | |
| 205 | 205 | } | |
| 206 | 206 | } | |
| 207 | - | // And no trailing positional at all: the last arg should be a | |
| 208 | - | // flag value, not a stray empty string. | |
| 207 | + | // And no trailing positional at all: every final token must be a | |
| 208 | + | // recognized boolean flag or a value belonging to a preceding flag, | |
| 209 | + | // never a stray empty string or the original task. | |
| 209 | 210 | if got := args[len(args)-1]; got == "" { | |
| 210 | 211 | t.Fatalf("resume argv ends with an empty positional: %v", args) | |
| 211 | 212 | } | |
| 212 | - | if strings.HasPrefix(args[len(args)-1], "--") { | |
| 213 | - | t.Fatalf("resume argv ends on a bare flag (no value): %v", args) | |
| 214 | - | } | |
| 215 | 213 | } | |
| 216 | 214 | ||
| 217 | 215 | func TestNotifyPromptTurnEndIgnoresProviderToolLoopTurnEnd(t *testing.T) { | |
| @@ -204,14 +204,12 @@ func TestDefaultChildArgsResumeOmitsTask(t *testing.T) { | |||||
| 204 | t.Fatalf("resume argv contains the task; it would re-fire as a duplicate turn\n%v", args) | 204 | t.Fatalf("resume argv contains the task; it would re-fire as a duplicate turn\n%v", args) | ||
| 205 | } | 205 | } | ||
| 206 | } | 206 | } | ||
| 207 | - | // And no trailing positional at all: the last arg should be a | 207 | + | // And no trailing positional at all: every final token must be a |
| 208 | - | // flag value, not a stray empty string. | 208 | + | // recognized boolean flag or a value belonging to a preceding flag, |
| 209 | + | // never a stray empty string or the original task. | |||
| 209 | if got := args[len(args)-1]; got == "" { | 210 | if got := args[len(args)-1]; got == "" { | ||
| 210 | t.Fatalf("resume argv ends with an empty positional: %v", args) | 211 | t.Fatalf("resume argv ends with an empty positional: %v", args) | ||
| 211 | } | 212 | } | ||
| 212 | - | if strings.HasPrefix(args[len(args)-1], "--") { | |||
| 213 | - | t.Fatalf("resume argv ends on a bare flag (no value): %v", args) | |||
| 214 | - | } | |||
| 215 | } | 213 | } | ||
| 216 | 214 | ||||
| 217 | func TestNotifyPromptTurnEndIgnoresProviderToolLoopTurnEnd(t *testing.T) { | 215 | func TestNotifyPromptTurnEndIgnoresProviderToolLoopTurnEnd(t *testing.T) { | ||
M packages/agent/swarm/swarm.go +24 -0
| @@ -58,6 +58,10 @@ type Config struct { | |||
| 58 | 58 | // per-agent isolation: agents edit the host's files directly. | |
| 59 | 59 | RepoRoot string | |
| 60 | 60 | ||
| 61 | + | // FastMode is copied to every newly spawned child and persisted so | |
| 62 | + | // child subprocesses use the same OpenAI service-tier setting. | |
| 63 | + | FastMode bool | |
| 64 | + | ||
| 61 | 65 | // NewRunner produces the Runner for an Agent. If nil, the default | |
| 62 | 66 | // `zot --swarm-agent ...` exec runner is used. Tests inject a fake | |
| 63 | 67 | // here. | |
| @@ -156,6 +160,22 @@ func (f *Swarm) RepoRoot() string { | |||
| 156 | 160 | return f.cfg.RepoRoot | |
| 157 | 161 | } | |
| 158 | 162 | ||
| 163 | + | // SetFastMode updates the fast-mode setting for subsequently spawned | |
| 164 | + | // children. Existing agents keep the setting captured at their spawn | |
| 165 | + | // boundary so resumes remain deterministic. | |
| 166 | + | func (f *Swarm) SetFastMode(enabled bool) { | |
| 167 | + | f.mu.Lock() | |
| 168 | + | f.cfg.FastMode = enabled | |
| 169 | + | f.mu.Unlock() | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // FastMode reports the setting used for subsequently spawned children. | |
| 173 | + | func (f *Swarm) FastMode() bool { | |
| 174 | + | f.mu.Lock() | |
| 175 | + | defer f.mu.Unlock() | |
| 176 | + | return f.cfg.FastMode | |
| 177 | + | } | |
| 178 | + | ||
| 159 | 179 | // SetActiveSession scopes the dashboard view (and Spawn stamping) | |
| 160 | 180 | // to a particular host zot session id. Pass empty to clear the | |
| 161 | 181 | // scope and revert to "show every agent" (the original behaviour). | |
| @@ -235,6 +255,7 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) | |||
| 235 | 255 | // concurrent /cd cannot race a spawn or produce a mixed snapshot. | |
| 236 | 256 | f.mu.Lock() | |
| 237 | 257 | dir := f.cfg.RepoRoot | |
| 258 | + | fastMode := f.cfg.FastMode | |
| 238 | 259 | sessionID := f.activeSession | |
| 239 | 260 | f.mu.Unlock() | |
| 240 | 261 | ||
| @@ -260,6 +281,7 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) | |||
| 260 | 281 | Model: strings.TrimSpace(req.Model), | |
| 261 | 282 | Provider: strings.TrimSpace(req.Provider), | |
| 262 | 283 | Reasoning: strings.TrimSpace(req.Reasoning), | |
| 284 | + | FastMode: fastMode, | |
| 263 | 285 | Subagent: strings.TrimSpace(req.Subagent), | |
| 264 | 286 | SessionID: sessionID, | |
| 265 | 287 | InboxPath: inboxPath, | |
| @@ -475,6 +497,7 @@ type AgentSnapshot struct { | |||
| 475 | 497 | Model string | |
| 476 | 498 | Provider string | |
| 477 | 499 | Reasoning string | |
| 500 | + | FastMode bool | |
| 478 | 501 | Subagent string | |
| 479 | 502 | ||
| 480 | 503 | // Paths to the agent's durable state. Surface them in the | |
| @@ -506,6 +529,7 @@ func (a *Agent) Snapshot() AgentSnapshot { | |||
| 506 | 529 | Model: a.Model, | |
| 507 | 530 | Provider: a.Provider, | |
| 508 | 531 | Reasoning: a.Reasoning, | |
| 532 | + | FastMode: a.FastMode, | |
| 509 | 533 | Subagent: a.Subagent, | |
| 510 | 534 | InboxPath: a.InboxPath, | |
| 511 | 535 | EventLogPath: a.EventLogPath, | |
| @@ -58,6 +58,10 @@ type Config struct { | |||||
| 58 | // per-agent isolation: agents edit the host's files directly. | 58 | // per-agent isolation: agents edit the host's files directly. | ||
| 59 | RepoRoot string | 59 | RepoRoot string | ||
| 60 | 60 | ||||
| 61 | + | // FastMode is copied to every newly spawned child and persisted so | |||
| 62 | + | // child subprocesses use the same OpenAI service-tier setting. | |||
| 63 | + | FastMode bool | |||
| 64 | + | ||||
| 61 | // NewRunner produces the Runner for an Agent. If nil, the default | 65 | // NewRunner produces the Runner for an Agent. If nil, the default | ||
| 62 | // `zot --swarm-agent ...` exec runner is used. Tests inject a fake | 66 | // `zot --swarm-agent ...` exec runner is used. Tests inject a fake | ||
| 63 | // here. | 67 | // here. | ||
| @@ -156,6 +160,22 @@ func (f *Swarm) RepoRoot() string { | |||||
| 156 | return f.cfg.RepoRoot | 160 | return f.cfg.RepoRoot | ||
| 157 | } | 161 | } | ||
| 158 | 162 | ||||
| 163 | + | // SetFastMode updates the fast-mode setting for subsequently spawned | |||
| 164 | + | // children. Existing agents keep the setting captured at their spawn | |||
| 165 | + | // boundary so resumes remain deterministic. | |||
| 166 | + | func (f *Swarm) SetFastMode(enabled bool) { | |||
| 167 | + | f.mu.Lock() | |||
| 168 | + | f.cfg.FastMode = enabled | |||
| 169 | + | f.mu.Unlock() | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // FastMode reports the setting used for subsequently spawned children. | |||
| 173 | + | func (f *Swarm) FastMode() bool { | |||
| 174 | + | f.mu.Lock() | |||
| 175 | + | defer f.mu.Unlock() | |||
| 176 | + | return f.cfg.FastMode | |||
| 177 | + | } | |||
| 178 | + | ||||
| 159 | // SetActiveSession scopes the dashboard view (and Spawn stamping) | 179 | // SetActiveSession scopes the dashboard view (and Spawn stamping) | ||
| 160 | // to a particular host zot session id. Pass empty to clear the | 180 | // to a particular host zot session id. Pass empty to clear the | ||
| 161 | // scope and revert to "show every agent" (the original behaviour). | 181 | // scope and revert to "show every agent" (the original behaviour). | ||
| @@ -235,6 +255,7 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) | |||||
| 235 | // concurrent /cd cannot race a spawn or produce a mixed snapshot. | 255 | // concurrent /cd cannot race a spawn or produce a mixed snapshot. | ||
| 236 | f.mu.Lock() | 256 | f.mu.Lock() | ||
| 237 | dir := f.cfg.RepoRoot | 257 | dir := f.cfg.RepoRoot | ||
| 258 | + | fastMode := f.cfg.FastMode | |||
| 238 | sessionID := f.activeSession | 259 | sessionID := f.activeSession | ||
| 239 | f.mu.Unlock() | 260 | f.mu.Unlock() | ||
| 240 | 261 | ||||
| @@ -260,6 +281,7 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) | |||||
| 260 | Model: strings.TrimSpace(req.Model), | 281 | Model: strings.TrimSpace(req.Model), | ||
| 261 | Provider: strings.TrimSpace(req.Provider), | 282 | Provider: strings.TrimSpace(req.Provider), | ||
| 262 | Reasoning: strings.TrimSpace(req.Reasoning), | 283 | Reasoning: strings.TrimSpace(req.Reasoning), | ||
| 284 | + | FastMode: fastMode, | |||
| 263 | Subagent: strings.TrimSpace(req.Subagent), | 285 | Subagent: strings.TrimSpace(req.Subagent), | ||
| 264 | SessionID: sessionID, | 286 | SessionID: sessionID, | ||
| 265 | InboxPath: inboxPath, | 287 | InboxPath: inboxPath, | ||
| @@ -475,6 +497,7 @@ type AgentSnapshot struct { | |||||
| 475 | Model string | 497 | Model string | ||
| 476 | Provider string | 498 | Provider string | ||
| 477 | Reasoning string | 499 | Reasoning string | ||
| 500 | + | FastMode bool | |||
| 478 | Subagent string | 501 | Subagent string | ||
| 479 | 502 | ||||
| 480 | // Paths to the agent's durable state. Surface them in the | 503 | // Paths to the agent's durable state. Surface them in the | ||
| @@ -506,6 +529,7 @@ func (a *Agent) Snapshot() AgentSnapshot { | |||||
| 506 | Model: a.Model, | 529 | Model: a.Model, | ||
| 507 | Provider: a.Provider, | 530 | Provider: a.Provider, | ||
| 508 | Reasoning: a.Reasoning, | 531 | Reasoning: a.Reasoning, | ||
| 532 | + | FastMode: a.FastMode, | |||
| 509 | Subagent: a.Subagent, | 533 | Subagent: a.Subagent, | ||
| 510 | InboxPath: a.InboxPath, | 534 | InboxPath: a.InboxPath, | ||
| 511 | EventLogPath: a.EventLogPath, | 535 | EventLogPath: a.EventLogPath, | ||
M packages/agent/tools/swarm_spawn.go +4 -0
| @@ -202,6 +202,9 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||
| 202 | 202 | if reasoning != "" { | |
| 203 | 203 | fmt.Fprintf(&sb, "reasoning: %s\n", reasoning) | |
| 204 | 204 | } | |
| 205 | + | if agent.FastMode { | |
| 206 | + | sb.WriteString("fast mode: enabled\n") | |
| 207 | + | } | |
| 205 | 208 | sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ") | |
| 206 | 209 | sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.") | |
| 207 | 210 | return core.ToolResult{ | |
| @@ -213,6 +216,7 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||
| 213 | 216 | "model": model, | |
| 214 | 217 | "provider": providerID, | |
| 215 | 218 | "reasoning": reasoning, | |
| 219 | + | "fast_mode": agent.FastMode, | |
| 216 | 220 | }, | |
| 217 | 221 | }, nil | |
| 218 | 222 | } | |
| @@ -202,6 +202,9 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||||
| 202 | if reasoning != "" { | 202 | if reasoning != "" { | ||
| 203 | fmt.Fprintf(&sb, "reasoning: %s\n", reasoning) | 203 | fmt.Fprintf(&sb, "reasoning: %s\n", reasoning) | ||
| 204 | } | 204 | } | ||
| 205 | + | if agent.FastMode { | |||
| 206 | + | sb.WriteString("fast mode: enabled\n") | |||
| 207 | + | } | |||
| 205 | sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ") | 208 | sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ") | ||
| 206 | sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.") | 209 | sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.") | ||
| 207 | return core.ToolResult{ | 210 | return core.ToolResult{ | ||
| @@ -213,6 +216,7 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||||
| 213 | "model": model, | 216 | "model": model, | ||
| 214 | "provider": providerID, | 217 | "provider": providerID, | ||
| 215 | "reasoning": reasoning, | 218 | "reasoning": reasoning, | ||
| 219 | + | "fast_mode": agent.FastMode, | |||
| 216 | }, | 220 | }, | ||
| 217 | }, nil | 221 | }, nil | ||
| 218 | } | 222 | } | ||
M packages/core/agent.go +24 -0
| @@ -22,6 +22,9 @@ type Agent struct { | |||
| 22 | 22 | MaxSteps int | |
| 23 | 23 | Reasoning string | |
| 24 | 24 | Temperature *float32 | |
| 25 | + | // FastMode requests the OpenAI fast service tier. The provider boundary | |
| 26 | + | // rejects it for providers that do not support that contract. | |
| 27 | + | FastMode bool | |
| 25 | 28 | ||
| 26 | 29 | // MaxTokens caps the model's output tokens per turn. Zero leaves | |
| 27 | 30 | // the field unset on the provider request, letting each provider | |
| @@ -120,6 +123,22 @@ func NewAgent(client provider.Client, model, system string, tools Registry) *Age | |||
| 120 | 123 | } | |
| 121 | 124 | } | |
| 122 | 125 | ||
| 126 | + | // SetFastMode changes the service-tier preference for the next model call. | |
| 127 | + | // It is safe to use while a turn is in flight; the active turn keeps the | |
| 128 | + | // value it already captured. | |
| 129 | + | func (a *Agent) SetFastMode(enabled bool) { | |
| 130 | + | a.mu.Lock() | |
| 131 | + | a.FastMode = enabled | |
| 132 | + | a.mu.Unlock() | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // FastModeEnabled returns the service-tier preference for a model call. | |
| 136 | + | func (a *Agent) FastModeEnabled() bool { | |
| 137 | + | a.mu.Lock() | |
| 138 | + | defer a.mu.Unlock() | |
| 139 | + | return a.FastMode | |
| 140 | + | } | |
| 141 | + | ||
| 123 | 142 | // QueueMessage queues text to be injected as a user message at the | |
| 124 | 143 | // next safe boundary of the active agent loop. It is non-blocking in | |
| 125 | 144 | // the sense that it never waits for model/tool work; it only takes | |
| @@ -540,6 +559,10 @@ func (a *Agent) dropLastAssistantMessage() { | |||
| 540 | 559 | // oneTurn calls the LLM once, forwards events, returns the stop reason | |
| 541 | 560 | // and the assembled assistant message (already appended to the transcript). | |
| 542 | 561 | func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.StopReason, provider.Message, error) { | |
| 562 | + | fastMode := a.FastModeEnabled() | |
| 563 | + | if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil { | |
| 564 | + | return provider.StopError, provider.Message{}, err | |
| 565 | + | } | |
| 543 | 566 | req := provider.Request{ | |
| 544 | 567 | Model: a.Model, | |
| 545 | 568 | System: a.System, | |
| @@ -554,6 +577,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.St | |||
| 554 | 577 | Messages: repairToolUseResultPairs(a.Messages()), | |
| 555 | 578 | Tools: a.Tools.Specs(), | |
| 556 | 579 | Reasoning: a.Reasoning, | |
| 580 | + | FastMode: fastMode, | |
| 557 | 581 | MaxTokens: a.MaxTokens, | |
| 558 | 582 | Temperature: a.Temperature, | |
| 559 | 583 | } | |
| @@ -22,6 +22,9 @@ type Agent struct { | |||||
| 22 | MaxSteps int | 22 | MaxSteps int | ||
| 23 | Reasoning string | 23 | Reasoning string | ||
| 24 | Temperature *float32 | 24 | Temperature *float32 | ||
| 25 | + | // FastMode requests the OpenAI fast service tier. The provider boundary | |||
| 26 | + | // rejects it for providers that do not support that contract. | |||
| 27 | + | FastMode bool | |||
| 25 | 28 | ||||
| 26 | // MaxTokens caps the model's output tokens per turn. Zero leaves | 29 | // MaxTokens caps the model's output tokens per turn. Zero leaves | ||
| 27 | // the field unset on the provider request, letting each provider | 30 | // the field unset on the provider request, letting each provider | ||
| @@ -120,6 +123,22 @@ func NewAgent(client provider.Client, model, system string, tools Registry) *Age | |||||
| 120 | } | 123 | } | ||
| 121 | } | 124 | } | ||
| 122 | 125 | ||||
| 126 | + | // SetFastMode changes the service-tier preference for the next model call. | |||
| 127 | + | // It is safe to use while a turn is in flight; the active turn keeps the | |||
| 128 | + | // value it already captured. | |||
| 129 | + | func (a *Agent) SetFastMode(enabled bool) { | |||
| 130 | + | a.mu.Lock() | |||
| 131 | + | a.FastMode = enabled | |||
| 132 | + | a.mu.Unlock() | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // FastModeEnabled returns the service-tier preference for a model call. | |||
| 136 | + | func (a *Agent) FastModeEnabled() bool { | |||
| 137 | + | a.mu.Lock() | |||
| 138 | + | defer a.mu.Unlock() | |||
| 139 | + | return a.FastMode | |||
| 140 | + | } | |||
| 141 | + | ||||
| 123 | // QueueMessage queues text to be injected as a user message at the | 142 | // QueueMessage queues text to be injected as a user message at the | ||
| 124 | // next safe boundary of the active agent loop. It is non-blocking in | 143 | // next safe boundary of the active agent loop. It is non-blocking in | ||
| 125 | // the sense that it never waits for model/tool work; it only takes | 144 | // the sense that it never waits for model/tool work; it only takes | ||
| @@ -540,6 +559,10 @@ func (a *Agent) dropLastAssistantMessage() { | |||||
| 540 | // oneTurn calls the LLM once, forwards events, returns the stop reason | 559 | // oneTurn calls the LLM once, forwards events, returns the stop reason | ||
| 541 | // and the assembled assistant message (already appended to the transcript). | 560 | // and the assembled assistant message (already appended to the transcript). | ||
| 542 | func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.StopReason, provider.Message, error) { | 561 | func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.StopReason, provider.Message, error) { | ||
| 562 | + | fastMode := a.FastModeEnabled() | |||
| 563 | + | if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil { | |||
| 564 | + | return provider.StopError, provider.Message{}, err | |||
| 565 | + | } | |||
| 543 | req := provider.Request{ | 566 | req := provider.Request{ | ||
| 544 | Model: a.Model, | 567 | Model: a.Model, | ||
| 545 | System: a.System, | 568 | System: a.System, | ||
| @@ -554,6 +577,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.St | |||||
| 554 | Messages: repairToolUseResultPairs(a.Messages()), | 577 | Messages: repairToolUseResultPairs(a.Messages()), | ||
| 555 | Tools: a.Tools.Specs(), | 578 | Tools: a.Tools.Specs(), | ||
| 556 | Reasoning: a.Reasoning, | 579 | Reasoning: a.Reasoning, | ||
| 580 | + | FastMode: fastMode, | |||
| 557 | MaxTokens: a.MaxTokens, | 581 | MaxTokens: a.MaxTokens, | ||
| 558 | Temperature: a.Temperature, | 582 | Temperature: a.Temperature, | ||
| 559 | } | 583 | } | ||
M packages/core/compact.go +6 -0
| @@ -40,6 +40,11 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin | |||
| 40 | 40 | return "", fmt.Errorf("nothing to compact: keep-tail covers the whole transcript") | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | + | fastMode := a.FastModeEnabled() | |
| 44 | + | if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil { | |
| 45 | + | return "", err | |
| 46 | + | } | |
| 47 | + | ||
| 43 | 48 | // Serialize the summarizable transcript to text and wrap it in tags | |
| 44 | 49 | // so the model treats it as material to summarize, not to continue. | |
| 45 | 50 | transcript := serializeTranscript(summarizable) | |
| @@ -51,6 +56,7 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin | |||
| 51 | 56 | System: summarizationSystem, | |
| 52 | 57 | MaxTokens: 4096, | |
| 53 | 58 | Temperature: a.Temperature, | |
| 59 | + | FastMode: fastMode, | |
| 54 | 60 | Messages: []provider.Message{ | |
| 55 | 61 | { | |
| 56 | 62 | Role: provider.RoleUser, | |
| @@ -40,6 +40,11 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin | |||||
| 40 | return "", fmt.Errorf("nothing to compact: keep-tail covers the whole transcript") | 40 | return "", fmt.Errorf("nothing to compact: keep-tail covers the whole transcript") | ||
| 41 | } | 41 | } | ||
| 42 | 42 | ||||
| 43 | + | fastMode := a.FastModeEnabled() | |||
| 44 | + | if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil { | |||
| 45 | + | return "", err | |||
| 46 | + | } | |||
| 47 | + | ||||
| 43 | // Serialize the summarizable transcript to text and wrap it in tags | 48 | // Serialize the summarizable transcript to text and wrap it in tags | ||
| 44 | // so the model treats it as material to summarize, not to continue. | 49 | // so the model treats it as material to summarize, not to continue. | ||
| 45 | transcript := serializeTranscript(summarizable) | 50 | transcript := serializeTranscript(summarizable) | ||
| @@ -51,6 +56,7 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin | |||||
| 51 | System: summarizationSystem, | 56 | System: summarizationSystem, | ||
| 52 | MaxTokens: 4096, | 57 | MaxTokens: 4096, | ||
| 53 | Temperature: a.Temperature, | 58 | Temperature: a.Temperature, | ||
| 59 | + | FastMode: fastMode, | |||
| 54 | Messages: []provider.Message{ | 60 | Messages: []provider.Message{ | ||
| 55 | { | 61 | { | ||
| 56 | Role: provider.RoleUser, | 62 | Role: provider.RoleUser, | ||
A packages/core/fast_mode_test.go +92 -0
| @@ -0,0 +1,92 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | type fastModeTestClient struct { | |
| 12 | + | name string | |
| 13 | + | lastReq provider.Request | |
| 14 | + | called bool | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func (c *fastModeTestClient) Name() string { return c.name } | |
| 18 | + | ||
| 19 | + | func (c *fastModeTestClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 20 | + | c.called = true | |
| 21 | + | c.lastReq = req | |
| 22 | + | out := make(chan provider.Event, 2) | |
| 23 | + | out <- provider.EventTextDelta{Delta: "ok"} | |
| 24 | + | out <- provider.EventDone{Stop: provider.StopEnd} | |
| 25 | + | close(out) | |
| 26 | + | return out, nil | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestAgentPropagatesFastMode(t *testing.T) { | |
| 30 | + | client := &fastModeTestClient{name: "openai"} | |
| 31 | + | agent := NewAgent(client, "gpt-5", "", nil) | |
| 32 | + | agent.FastMode = true | |
| 33 | + | ||
| 34 | + | if err := agent.Prompt(context.Background(), "hello", nil, nil); err != nil { | |
| 35 | + | t.Fatalf("Prompt returned %v", err) | |
| 36 | + | } | |
| 37 | + | if !client.called { | |
| 38 | + | t.Fatal("provider was not called") | |
| 39 | + | } | |
| 40 | + | if !client.lastReq.FastMode { | |
| 41 | + | t.Fatal("request FastMode = false, want true") | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestAgentPropagatesFastModeToCompaction(t *testing.T) { | |
| 46 | + | client := &fastModeTestClient{name: "openai"} | |
| 47 | + | agent := NewAgent(client, "gpt-5", "", nil) | |
| 48 | + | agent.FastMode = true | |
| 49 | + | agent.SetMessages([]provider.Message{ | |
| 50 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "old"}}}, | |
| 51 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer"}}}, | |
| 52 | + | }) | |
| 53 | + | ||
| 54 | + | if _, err := agent.Compact(context.Background(), 1, nil); err != nil { | |
| 55 | + | t.Fatalf("Compact returned %v", err) | |
| 56 | + | } | |
| 57 | + | if !client.lastReq.FastMode { | |
| 58 | + | t.Fatal("compaction request FastMode = false, want true") | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestAgentRejectsFastModeForNonOpenAIProvider(t *testing.T) { | |
| 63 | + | client := &fastModeTestClient{name: "anthropic"} | |
| 64 | + | agent := NewAgent(client, "claude-sonnet-4-5", "", nil) | |
| 65 | + | agent.FastMode = true | |
| 66 | + | ||
| 67 | + | err := agent.Prompt(context.Background(), "hello", nil, nil) | |
| 68 | + | if err == nil || !strings.Contains(err.Error(), "only supported for OpenAI providers") { | |
| 69 | + | t.Fatalf("Prompt error = %v, want unsupported-provider error", err) | |
| 70 | + | } | |
| 71 | + | if client.called { | |
| 72 | + | t.Fatal("provider was called despite unsupported fast mode") | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestAgentRejectsFastModeForNonOpenAIProviderDuringCompaction(t *testing.T) { | |
| 77 | + | client := &fastModeTestClient{name: "anthropic"} | |
| 78 | + | agent := NewAgent(client, "claude-sonnet-4-5", "", nil) | |
| 79 | + | agent.FastMode = true | |
| 80 | + | agent.SetMessages([]provider.Message{ | |
| 81 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "old"}}}, | |
| 82 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer"}}}, | |
| 83 | + | }) | |
| 84 | + | ||
| 85 | + | _, err := agent.Compact(context.Background(), 1, nil) | |
| 86 | + | if err == nil || !strings.Contains(err.Error(), "only supported for OpenAI providers") { | |
| 87 | + | t.Fatalf("Compact error = %v, want unsupported-provider error", err) | |
| 88 | + | } | |
| 89 | + | if client.called { | |
| 90 | + | t.Fatal("provider was called despite unsupported fast mode") | |
| 91 | + | } | |
| 92 | + | } | |
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | type fastModeTestClient struct { | |||
| 12 | + | name string | |||
| 13 | + | lastReq provider.Request | |||
| 14 | + | called bool | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func (c *fastModeTestClient) Name() string { return c.name } | |||
| 18 | + | ||||
| 19 | + | func (c *fastModeTestClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |||
| 20 | + | c.called = true | |||
| 21 | + | c.lastReq = req | |||
| 22 | + | out := make(chan provider.Event, 2) | |||
| 23 | + | out <- provider.EventTextDelta{Delta: "ok"} | |||
| 24 | + | out <- provider.EventDone{Stop: provider.StopEnd} | |||
| 25 | + | close(out) | |||
| 26 | + | return out, nil | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestAgentPropagatesFastMode(t *testing.T) { | |||
| 30 | + | client := &fastModeTestClient{name: "openai"} | |||
| 31 | + | agent := NewAgent(client, "gpt-5", "", nil) | |||
| 32 | + | agent.FastMode = true | |||
| 33 | + | ||||
| 34 | + | if err := agent.Prompt(context.Background(), "hello", nil, nil); err != nil { | |||
| 35 | + | t.Fatalf("Prompt returned %v", err) | |||
| 36 | + | } | |||
| 37 | + | if !client.called { | |||
| 38 | + | t.Fatal("provider was not called") | |||
| 39 | + | } | |||
| 40 | + | if !client.lastReq.FastMode { | |||
| 41 | + | t.Fatal("request FastMode = false, want true") | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestAgentPropagatesFastModeToCompaction(t *testing.T) { | |||
| 46 | + | client := &fastModeTestClient{name: "openai"} | |||
| 47 | + | agent := NewAgent(client, "gpt-5", "", nil) | |||
| 48 | + | agent.FastMode = true | |||
| 49 | + | agent.SetMessages([]provider.Message{ | |||
| 50 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "old"}}}, | |||
| 51 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer"}}}, | |||
| 52 | + | }) | |||
| 53 | + | ||||
| 54 | + | if _, err := agent.Compact(context.Background(), 1, nil); err != nil { | |||
| 55 | + | t.Fatalf("Compact returned %v", err) | |||
| 56 | + | } | |||
| 57 | + | if !client.lastReq.FastMode { | |||
| 58 | + | t.Fatal("compaction request FastMode = false, want true") | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestAgentRejectsFastModeForNonOpenAIProvider(t *testing.T) { | |||
| 63 | + | client := &fastModeTestClient{name: "anthropic"} | |||
| 64 | + | agent := NewAgent(client, "claude-sonnet-4-5", "", nil) | |||
| 65 | + | agent.FastMode = true | |||
| 66 | + | ||||
| 67 | + | err := agent.Prompt(context.Background(), "hello", nil, nil) | |||
| 68 | + | if err == nil || !strings.Contains(err.Error(), "only supported for OpenAI providers") { | |||
| 69 | + | t.Fatalf("Prompt error = %v, want unsupported-provider error", err) | |||
| 70 | + | } | |||
| 71 | + | if client.called { | |||
| 72 | + | t.Fatal("provider was called despite unsupported fast mode") | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestAgentRejectsFastModeForNonOpenAIProviderDuringCompaction(t *testing.T) { | |||
| 77 | + | client := &fastModeTestClient{name: "anthropic"} | |||
| 78 | + | agent := NewAgent(client, "claude-sonnet-4-5", "", nil) | |||
| 79 | + | agent.FastMode = true | |||
| 80 | + | agent.SetMessages([]provider.Message{ | |||
| 81 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "old"}}}, | |||
| 82 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer"}}}, | |||
| 83 | + | }) | |||
| 84 | + | ||||
| 85 | + | _, err := agent.Compact(context.Background(), 1, nil) | |||
| 86 | + | if err == nil || !strings.Contains(err.Error(), "only supported for OpenAI providers") { | |||
| 87 | + | t.Fatalf("Compact error = %v, want unsupported-provider error", err) | |||
| 88 | + | } | |||
| 89 | + | if client.called { | |||
| 90 | + | t.Fatal("provider was called despite unsupported fast mode") | |||
| 91 | + | } | |||
| 92 | + | } | |||
M packages/provider/amazon_bedrock.go +3 -0
| @@ -572,6 +572,9 @@ func bedrockGeoPrefixForRegion(region string) string { | |||
| 572 | 572 | } | |
| 573 | 573 | ||
| 574 | 574 | func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 575 | + | if err := ValidateFastMode(c.Name(), req.FastMode); err != nil { | |
| 576 | + | return nil, err | |
| 577 | + | } | |
| 575 | 578 | wire, err := c.buildRequest(req) | |
| 576 | 579 | if err != nil { | |
| 577 | 580 | return nil, err | |
| @@ -572,6 +572,9 @@ func bedrockGeoPrefixForRegion(region string) string { | |||||
| 572 | } | 572 | } | ||
| 573 | 573 | ||||
| 574 | func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | 574 | func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | ||
| 575 | + | if err := ValidateFastMode(c.Name(), req.FastMode); err != nil { | |||
| 576 | + | return nil, err | |||
| 577 | + | } | |||
| 575 | wire, err := c.buildRequest(req) | 578 | wire, err := c.buildRequest(req) | ||
| 576 | if err != nil { | 579 | if err != nil { | ||
| 577 | return nil, err | 580 | return nil, err | ||
M packages/provider/anthropic.go +3 -0
| @@ -523,6 +523,9 @@ func anthBuildToolResultContent(blocks []Content) (json.RawMessage, error) { | |||
| 523 | 523 | // ---- streaming ---- | |
| 524 | 524 | ||
| 525 | 525 | func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 526 | + | if err := ValidateFastMode(c.Name(), req.FastMode); err != nil { | |
| 527 | + | return nil, err | |
| 528 | + | } | |
| 526 | 529 | wire, err := c.buildRequest(req) | |
| 527 | 530 | if err != nil { | |
| 528 | 531 | return nil, err | |
| @@ -523,6 +523,9 @@ func anthBuildToolResultContent(blocks []Content) (json.RawMessage, error) { | |||||
| 523 | // ---- streaming ---- | 523 | // ---- streaming ---- | ||
| 524 | 524 | ||||
| 525 | func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | 525 | func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | ||
| 526 | + | if err := ValidateFastMode(c.Name(), req.FastMode); err != nil { | |||
| 527 | + | return nil, err | |||
| 528 | + | } | |||
| 526 | wire, err := c.buildRequest(req) | 529 | wire, err := c.buildRequest(req) | ||
| 527 | if err != nil { | 530 | if err != nil { | ||
| 528 | return nil, err | 531 | return nil, err | ||
M packages/provider/extra_providers.go +3 -0
| @@ -233,6 +233,9 @@ type unimplementedClient struct { | |||
| 233 | 233 | func (c *unimplementedClient) Name() string { return c.name } | |
| 234 | 234 | ||
| 235 | 235 | func (c *unimplementedClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 236 | + | if err := ValidateFastMode(c.Name(), req.FastMode); err != nil { | |
| 237 | + | return nil, err | |
| 238 | + | } | |
| 236 | 239 | return nil, fmt.Errorf("provider %q not yet implemented: %s", c.name, c.hint) | |
| 237 | 240 | } | |
| 238 | 241 | ||
| @@ -233,6 +233,9 @@ type unimplementedClient struct { | |||||
| 233 | func (c *unimplementedClient) Name() string { return c.name } | 233 | func (c *unimplementedClient) Name() string { return c.name } | ||
| 234 | 234 | ||||
| 235 | func (c *unimplementedClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | 235 | func (c *unimplementedClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | ||
| 236 | + | if err := ValidateFastMode(c.Name(), req.FastMode); err != nil { | |||
| 237 | + | return nil, err | |||
| 238 | + | } | |||
| 236 | return nil, fmt.Errorf("provider %q not yet implemented: %s", c.name, c.hint) | 239 | return nil, fmt.Errorf("provider %q not yet implemented: %s", c.name, c.hint) | ||
| 237 | } | 240 | } | ||
| 238 | 241 | ||||
A packages/provider/fast_mode_test.go +62 -0
| @@ -0,0 +1,62 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestOpenAIFastModeAddsServiceTier(t *testing.T) { | |
| 10 | + | client := NewOpenAI("key", "").(*openaiClient) | |
| 11 | + | ||
| 12 | + | wire, err := client.buildRequest(Request{Model: "gpt-5", FastMode: true}) | |
| 13 | + | if err != nil { | |
| 14 | + | t.Fatal(err) | |
| 15 | + | } | |
| 16 | + | if wire.ServiceTier != fastModeServiceTier { | |
| 17 | + | t.Fatalf("service_tier = %q, want %q", wire.ServiceTier, fastModeServiceTier) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | wire, err = client.buildRequest(Request{Model: "gpt-5"}) | |
| 21 | + | if err != nil { | |
| 22 | + | t.Fatal(err) | |
| 23 | + | } | |
| 24 | + | if wire.ServiceTier != "" { | |
| 25 | + | t.Fatalf("service_tier = %q with fast mode disabled, want omitted", wire.ServiceTier) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestOpenAICodexFastModeAddsServiceTier(t *testing.T) { | |
| 30 | + | client := NewOpenAICodex("token", "account", "").(*codexClient) | |
| 31 | + | ||
| 32 | + | wire, err := client.buildRequest(Request{Model: "gpt-5.6-sol", FastMode: true}) | |
| 33 | + | if err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | if wire.ServiceTier != fastModeServiceTier { | |
| 37 | + | t.Fatalf("service_tier = %q, want %q", wire.ServiceTier, fastModeServiceTier) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestFastModeIsRestrictedToOpenAIProviders(t *testing.T) { | |
| 42 | + | for _, providerName := range []string{"openai", "openai-codex", "openai-responses"} { | |
| 43 | + | if err := ValidateFastMode(providerName, true); err != nil { | |
| 44 | + | t.Errorf("ValidateFastMode(%q, true) = %v", providerName, err) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | for _, providerName := range []string{"anthropic", "google", "openrouter", "azure-openai-responses"} { | |
| 49 | + | err := ValidateFastMode(providerName, true) | |
| 50 | + | if err == nil || !strings.Contains(err.Error(), "only supported for OpenAI providers") { | |
| 51 | + | t.Errorf("ValidateFastMode(%q, true) = %v, want unsupported-provider error", providerName, err) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestFastModeRejectedBeforeNonOpenAIRequest(t *testing.T) { | |
| 57 | + | client := NewOpenAICompat("openrouter", "key", "", "") | |
| 58 | + | _, err := client.Stream(context.Background(), Request{Model: "gpt-5", FastMode: true}) | |
| 59 | + | if err == nil || !strings.Contains(err.Error(), "only supported for OpenAI providers") { | |
| 60 | + | t.Fatalf("Stream error = %v, want unsupported-provider error", err) | |
| 61 | + | } | |
| 62 | + | } | |
| @@ -0,0 +1,62 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestOpenAIFastModeAddsServiceTier(t *testing.T) { | |||
| 10 | + | client := NewOpenAI("key", "").(*openaiClient) | |||
| 11 | + | ||||
| 12 | + | wire, err := client.buildRequest(Request{Model: "gpt-5", FastMode: true}) | |||
| 13 | + | if err != nil { | |||
| 14 | + | t.Fatal(err) | |||
| 15 | + | } | |||
| 16 | + | if wire.ServiceTier != fastModeServiceTier { | |||
| 17 | + | t.Fatalf("service_tier = %q, want %q", wire.ServiceTier, fastModeServiceTier) | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | wire, err = client.buildRequest(Request{Model: "gpt-5"}) | |||
| 21 | + | if err != nil { | |||
| 22 | + | t.Fatal(err) | |||
| 23 | + | } | |||
| 24 | + | if wire.ServiceTier != "" { | |||
| 25 | + | t.Fatalf("service_tier = %q with fast mode disabled, want omitted", wire.ServiceTier) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestOpenAICodexFastModeAddsServiceTier(t *testing.T) { | |||
| 30 | + | client := NewOpenAICodex("token", "account", "").(*codexClient) | |||
| 31 | + | ||||
| 32 | + | wire, err := client.buildRequest(Request{Model: "gpt-5.6-sol", FastMode: true}) | |||
| 33 | + | if err != nil { | |||
| 34 | + | t.Fatal(err) | |||
| 35 | + | } | |||
| 36 | + | if wire.ServiceTier != fastModeServiceTier { | |||
| 37 | + | t.Fatalf("service_tier = %q, want %q", wire.ServiceTier, fastModeServiceTier) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestFastModeIsRestrictedToOpenAIProviders(t *testing.T) { | |||
| 42 | + | for _, providerName := range []string{"openai", "openai-codex", "openai-responses"} { | |||
| 43 | + | if err := ValidateFastMode(providerName, true); err != nil { | |||
| 44 | + | t.Errorf("ValidateFastMode(%q, true) = %v", providerName, err) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | for _, providerName := range []string{"anthropic", "google", "openrouter", "azure-openai-responses"} { | |||
| 49 | + | err := ValidateFastMode(providerName, true) | |||
| 50 | + | if err == nil || !strings.Contains(err.Error(), "only supported for OpenAI providers") { | |||
| 51 | + | t.Errorf("ValidateFastMode(%q, true) = %v, want unsupported-provider error", providerName, err) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestFastModeRejectedBeforeNonOpenAIRequest(t *testing.T) { | |||
| 57 | + | client := NewOpenAICompat("openrouter", "key", "", "") | |||
| 58 | + | _, err := client.Stream(context.Background(), Request{Model: "gpt-5", FastMode: true}) | |||
| 59 | + | if err == nil || !strings.Contains(err.Error(), "only supported for OpenAI providers") { | |||
| 60 | + | t.Fatalf("Stream error = %v, want unsupported-provider error", err) | |||
| 61 | + | } | |||
| 62 | + | } | |||
M packages/provider/gemini.go +3 -0
| @@ -468,6 +468,9 @@ func geminiThinkingConfig(modelID, level string) *gemThinkingConfig { | |||
| 468 | 468 | // ---- streaming ---- | |
| 469 | 469 | ||
| 470 | 470 | func (c *geminiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 471 | + | if err := ValidateFastMode(c.Name(), req.FastMode); err != nil { | |
| 472 | + | return nil, err | |
| 473 | + | } | |
| 471 | 474 | wire, modelID, err := c.buildRequest(req) | |
| 472 | 475 | if err != nil { | |
| 473 | 476 | return nil, err | |
| @@ -468,6 +468,9 @@ func geminiThinkingConfig(modelID, level string) *gemThinkingConfig { | |||||
| 468 | // ---- streaming ---- | 468 | // ---- streaming ---- | ||
| 469 | 469 | ||||
| 470 | func (c *geminiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | 470 | func (c *geminiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | ||
| 471 | + | if err := ValidateFastMode(c.Name(), req.FastMode); err != nil { | |||
| 472 | + | return nil, err | |||
| 473 | + | } | |||
| 471 | wire, modelID, err := c.buildRequest(req) | 474 | wire, modelID, err := c.buildRequest(req) | ||
| 472 | if err != nil { | 475 | if err != nil { | ||
| 473 | return nil, err | 476 | return nil, err | ||
M packages/provider/google_vertex.go +3 -0
| @@ -367,5 +367,8 @@ type renamedClient struct { | |||
| 367 | 367 | ||
| 368 | 368 | func (r *renamedClient) Name() string { return r.name } | |
| 369 | 369 | func (r *renamedClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 370 | + | if err := ValidateFastMode(r.Name(), req.FastMode); err != nil { | |
| 371 | + | return nil, err | |
| 372 | + | } | |
| 370 | 373 | return r.inner.Stream(ctx, req) | |
| 371 | 374 | } | |
| @@ -367,5 +367,8 @@ type renamedClient struct { | |||||
| 367 | 367 | ||||
| 368 | func (r *renamedClient) Name() string { return r.name } | 368 | func (r *renamedClient) Name() string { return r.name } | ||
| 369 | func (r *renamedClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | 369 | func (r *renamedClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | ||
| 370 | + | if err := ValidateFastMode(r.Name(), req.FastMode); err != nil { | |||
| 371 | + | return nil, err | |||
| 372 | + | } | |||
| 370 | return r.inner.Stream(ctx, req) | 373 | return r.inner.Stream(ctx, req) | ||
| 371 | } | 374 | } | ||
M packages/provider/model_router.go +3 -0
| @@ -30,6 +30,9 @@ func NewModelRouter(name string, fallback Client, byAPI map[string]Client) Clien | |||
| 30 | 30 | func (c *modelRouter) Name() string { return c.name } | |
| 31 | 31 | ||
| 32 | 32 | func (c *modelRouter) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 33 | + | if err := ValidateFastMode(c.Name(), req.FastMode); err != nil { | |
| 34 | + | return nil, err | |
| 35 | + | } | |
| 33 | 36 | client := c.fallback | |
| 34 | 37 | if model, err := FindModel(c.name, req.Model); err == nil && model.API != "" { | |
| 35 | 38 | routed := c.byAPI[model.API] | |
| @@ -30,6 +30,9 @@ func NewModelRouter(name string, fallback Client, byAPI map[string]Client) Clien | |||||
| 30 | func (c *modelRouter) Name() string { return c.name } | 30 | func (c *modelRouter) Name() string { return c.name } | ||
| 31 | 31 | ||||
| 32 | func (c *modelRouter) Stream(ctx context.Context, req Request) (<-chan Event, error) { | 32 | func (c *modelRouter) Stream(ctx context.Context, req Request) (<-chan Event, error) { | ||
| 33 | + | if err := ValidateFastMode(c.Name(), req.FastMode); err != nil { | |||
| 34 | + | return nil, err | |||
| 35 | + | } | |||
| 33 | client := c.fallback | 36 | client := c.fallback | ||
| 34 | if model, err := FindModel(c.name, req.Model); err == nil && model.API != "" { | 37 | if model, err := FindModel(c.name, req.Model); err == nil && model.API != "" { | ||
| 35 | routed := c.byAPI[model.API] | 38 | routed := c.byAPI[model.API] | ||
M packages/provider/openai.go +7 -0
| @@ -176,11 +176,15 @@ type oaiRequest struct { | |||
| 176 | 176 | MaxTokens *int `json:"max_tokens,omitempty"` | |
| 177 | 177 | MaxCompletionTok *int `json:"max_completion_tokens,omitempty"` | |
| 178 | 178 | ReasoningEffort string `json:"reasoning_effort,omitempty"` | |
| 179 | + | ServiceTier string `json:"service_tier,omitempty"` | |
| 179 | 180 | } | |
| 180 | 181 | ||
| 181 | 182 | // ---- request building ---- | |
| 182 | 183 | ||
| 183 | 184 | func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |
| 185 | + | if err := ValidateFastMode(c.Name(), req.FastMode); err != nil { | |
| 186 | + | return nil, err | |
| 187 | + | } | |
| 184 | 188 | // The OpenAI wire client is shared by many providers, so prefer its own | |
| 185 | 189 | // provider namespace before falling back to a provider-agnostic lookup. | |
| 186 | 190 | m, err := FindModel(c.Name(), req.Model) | |
| @@ -203,6 +207,9 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 203 | 207 | StreamOptions: &oaiStreamOptions{IncludeUsage: true}, | |
| 204 | 208 | Temperature: req.Temperature, | |
| 205 | 209 | } | |
| 210 | + | if req.FastMode { | |
| 211 | + | out.ServiceTier = fastModeServiceTier | |
| 212 | + | } | |
| 206 | 213 | ||
| 207 | 214 | maxTok := req.MaxTokens | |
| 208 | 215 | if maxTok <= 0 { | |
| @@ -176,11 +176,15 @@ type oaiRequest struct { | |||||
| 176 | MaxTokens *int `json:"max_tokens,omitempty"` | 176 | MaxTokens *int `json:"max_tokens,omitempty"` | ||
| 177 | MaxCompletionTok *int `json:"max_completion_tokens,omitempty"` | 177 | MaxCompletionTok *int `json:"max_completion_tokens,omitempty"` | ||
| 178 | ReasoningEffort string `json:"reasoning_effort,omitempty"` | 178 | ReasoningEffort string `json:"reasoning_effort,omitempty"` | ||
| 179 | + | ServiceTier string `json:"service_tier,omitempty"` | |||
| 179 | } | 180 | } | ||
| 180 | 181 | ||||
| 181 | // ---- request building ---- | 182 | // ---- request building ---- | ||
| 182 | 183 | ||||
| 183 | func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | 184 | func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | ||
| 185 | + | if err := ValidateFastMode(c.Name(), req.FastMode); err != nil { | |||
| 186 | + | return nil, err | |||
| 187 | + | } | |||
| 184 | // The OpenAI wire client is shared by many providers, so prefer its own | 188 | // The OpenAI wire client is shared by many providers, so prefer its own | ||
| 185 | // provider namespace before falling back to a provider-agnostic lookup. | 189 | // provider namespace before falling back to a provider-agnostic lookup. | ||
| 186 | m, err := FindModel(c.Name(), req.Model) | 190 | m, err := FindModel(c.Name(), req.Model) | ||
| @@ -203,6 +207,9 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||||
| 203 | StreamOptions: &oaiStreamOptions{IncludeUsage: true}, | 207 | StreamOptions: &oaiStreamOptions{IncludeUsage: true}, | ||
| 204 | Temperature: req.Temperature, | 208 | Temperature: req.Temperature, | ||
| 205 | } | 209 | } | ||
| 210 | + | if req.FastMode { | |||
| 211 | + | out.ServiceTier = fastModeServiceTier | |||
| 212 | + | } | |||
| 206 | 213 | ||||
| 207 | maxTok := req.MaxTokens | 214 | maxTok := req.MaxTokens | ||
| 208 | if maxTok <= 0 { | 215 | if maxTok <= 0 { | ||
M packages/provider/openai_codex.go +11 -0
| @@ -163,6 +163,7 @@ type codexRequest struct { | |||
| 163 | 163 | Include []string `json:"include,omitempty"` | |
| 164 | 164 | Reasoning *codexReasoningConfig `json:"reasoning,omitempty"` | |
| 165 | 165 | PromptCacheKey string `json:"prompt_cache_key,omitempty"` | |
| 166 | + | ServiceTier string `json:"service_tier,omitempty"` | |
| 166 | 167 | } | |
| 167 | 168 | ||
| 168 | 169 | // ---- Request building ---- | |
| @@ -180,6 +181,13 @@ func (c *codexClient) findModel(id string) (Model, error) { | |||
| 180 | 181 | } | |
| 181 | 182 | ||
| 182 | 183 | func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |
| 184 | + | providerName := c.providerName | |
| 185 | + | if providerName == "" { | |
| 186 | + | providerName = "openai-codex" | |
| 187 | + | } | |
| 188 | + | if err := ValidateFastMode(providerName, req.FastMode); err != nil { | |
| 189 | + | return nil, err | |
| 190 | + | } | |
| 183 | 191 | m, err := c.findModel(req.Model) | |
| 184 | 192 | if err != nil { | |
| 185 | 193 | return nil, err | |
| @@ -194,6 +202,9 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||
| 194 | 202 | ParallelToolCalls: true, | |
| 195 | 203 | Include: []string{"reasoning.encrypted_content"}, | |
| 196 | 204 | } | |
| 205 | + | if req.FastMode { | |
| 206 | + | body.ServiceTier = fastModeServiceTier | |
| 207 | + | } | |
| 197 | 208 | if m.Reasoning { | |
| 198 | 209 | if effort := OpenAICodexReasoningEffort(reasoning, req.Model); effort != "" { | |
| 199 | 210 | body.Reasoning = &codexReasoningConfig{Effort: effort} | |
| @@ -163,6 +163,7 @@ type codexRequest struct { | |||||
| 163 | Include []string `json:"include,omitempty"` | 163 | Include []string `json:"include,omitempty"` | ||
| 164 | Reasoning *codexReasoningConfig `json:"reasoning,omitempty"` | 164 | Reasoning *codexReasoningConfig `json:"reasoning,omitempty"` | ||
| 165 | PromptCacheKey string `json:"prompt_cache_key,omitempty"` | 165 | PromptCacheKey string `json:"prompt_cache_key,omitempty"` | ||
| 166 | + | ServiceTier string `json:"service_tier,omitempty"` | |||
| 166 | } | 167 | } | ||
| 167 | 168 | ||||
| 168 | // ---- Request building ---- | 169 | // ---- Request building ---- | ||
| @@ -180,6 +181,13 @@ func (c *codexClient) findModel(id string) (Model, error) { | |||||
| 180 | } | 181 | } | ||
| 181 | 182 | ||||
| 182 | func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | 183 | func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | ||
| 184 | + | providerName := c.providerName | |||
| 185 | + | if providerName == "" { | |||
| 186 | + | providerName = "openai-codex" | |||
| 187 | + | } | |||
| 188 | + | if err := ValidateFastMode(providerName, req.FastMode); err != nil { | |||
| 189 | + | return nil, err | |||
| 190 | + | } | |||
| 183 | m, err := c.findModel(req.Model) | 191 | m, err := c.findModel(req.Model) | ||
| 184 | if err != nil { | 192 | if err != nil { | ||
| 185 | return nil, err | 193 | return nil, err | ||
| @@ -194,6 +202,9 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||||
| 194 | ParallelToolCalls: true, | 202 | ParallelToolCalls: true, | ||
| 195 | Include: []string{"reasoning.encrypted_content"}, | 203 | Include: []string{"reasoning.encrypted_content"}, | ||
| 196 | } | 204 | } | ||
| 205 | + | if req.FastMode { | |||
| 206 | + | body.ServiceTier = fastModeServiceTier | |||
| 207 | + | } | |||
| 197 | if m.Reasoning { | 208 | if m.Reasoning { | ||
| 198 | if effort := OpenAICodexReasoningEffort(reasoning, req.Model); effort != "" { | 209 | if effort := OpenAICodexReasoningEffort(reasoning, req.Model); effort != "" { | ||
| 199 | body.Reasoning = &codexReasoningConfig{Effort: effort} | 210 | body.Reasoning = &codexReasoningConfig{Effort: effort} | ||
M packages/provider/provider.go +30 -0
| @@ -8,6 +8,8 @@ package provider | |||
| 8 | 8 | import ( | |
| 9 | 9 | "context" | |
| 10 | 10 | "encoding/json" | |
| 11 | + | "fmt" | |
| 12 | + | "strings" | |
| 11 | 13 | "time" | |
| 12 | 14 | ) | |
| 13 | 15 | ||
| @@ -255,6 +257,34 @@ type Request struct { | |||
| 255 | 257 | // "max". Empty disables reasoning. The max tier is sent natively only to | |
| 256 | 258 | // models that support it and clamped elsewhere. | |
| 257 | 259 | Reasoning string | |
| 260 | + | // FastMode requests OpenAI's fast service tier. Providers that do not | |
| 261 | + | // implement the OpenAI service-tier contract reject the request. | |
| 262 | + | FastMode bool | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | // OpenAI's current Fast mode is represented as the priority service tier on | |
| 266 | + | // the wire, including the ChatGPT/Codex Responses endpoint. | |
| 267 | + | const fastModeServiceTier = "priority" | |
| 268 | + | ||
| 269 | + | // SupportsFastMode reports whether a provider uses an OpenAI request | |
| 270 | + | // contract that supports the fast service tier. | |
| 271 | + | func SupportsFastMode(providerName string) bool { | |
| 272 | + | switch strings.ToLower(strings.TrimSpace(providerName)) { | |
| 273 | + | case "openai", "openai-codex", "openai-responses": | |
| 274 | + | return true | |
| 275 | + | default: | |
| 276 | + | return false | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // ValidateFastMode rejects fast-mode requests before a provider performs | |
| 281 | + | // any network or credential work. Keep this check at the provider boundary | |
| 282 | + | // so direct Client users get the same contract as the core agent loop. | |
| 283 | + | func ValidateFastMode(providerName string, enabled bool) error { | |
| 284 | + | if !enabled || SupportsFastMode(providerName) { | |
| 285 | + | return nil | |
| 286 | + | } | |
| 287 | + | return fmt.Errorf("fast mode is only supported for OpenAI providers, not %q", providerName) | |
| 258 | 288 | } | |
| 259 | 289 | ||
| 260 | 290 | // Client is an LLM streaming client. | |
| @@ -8,6 +8,8 @@ package provider | |||||
| 8 | import ( | 8 | import ( | ||
| 9 | "context" | 9 | "context" | ||
| 10 | "encoding/json" | 10 | "encoding/json" | ||
| 11 | + | "fmt" | |||
| 12 | + | "strings" | |||
| 11 | "time" | 13 | "time" | ||
| 12 | ) | 14 | ) | ||
| 13 | 15 | ||||
| @@ -255,6 +257,34 @@ type Request struct { | |||||
| 255 | // "max". Empty disables reasoning. The max tier is sent natively only to | 257 | // "max". Empty disables reasoning. The max tier is sent natively only to | ||
| 256 | // models that support it and clamped elsewhere. | 258 | // models that support it and clamped elsewhere. | ||
| 257 | Reasoning string | 259 | Reasoning string | ||
| 260 | + | // FastMode requests OpenAI's fast service tier. Providers that do not | |||
| 261 | + | // implement the OpenAI service-tier contract reject the request. | |||
| 262 | + | FastMode bool | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | // OpenAI's current Fast mode is represented as the priority service tier on | |||
| 266 | + | // the wire, including the ChatGPT/Codex Responses endpoint. | |||
| 267 | + | const fastModeServiceTier = "priority" | |||
| 268 | + | ||||
| 269 | + | // SupportsFastMode reports whether a provider uses an OpenAI request | |||
| 270 | + | // contract that supports the fast service tier. | |||
| 271 | + | func SupportsFastMode(providerName string) bool { | |||
| 272 | + | switch strings.ToLower(strings.TrimSpace(providerName)) { | |||
| 273 | + | case "openai", "openai-codex", "openai-responses": | |||
| 274 | + | return true | |||
| 275 | + | default: | |||
| 276 | + | return false | |||
| 277 | + | } | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | // ValidateFastMode rejects fast-mode requests before a provider performs | |||
| 281 | + | // any network or credential work. Keep this check at the provider boundary | |||
| 282 | + | // so direct Client users get the same contract as the core agent loop. | |||
| 283 | + | func ValidateFastMode(providerName string, enabled bool) error { | |||
| 284 | + | if !enabled || SupportsFastMode(providerName) { | |||
| 285 | + | return nil | |||
| 286 | + | } | |||
| 287 | + | return fmt.Errorf("fast mode is only supported for OpenAI providers, not %q", providerName) | |||
| 258 | } | 288 | } | ||
| 259 | 289 | ||||
| 260 | // Client is an LLM streaming client. | 290 | // Client is an LLM streaming client. | ||
M packages/provider/refreshing.go +3 -0
| @@ -41,6 +41,9 @@ func (c *RefreshingClient) Name() string { | |||
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | 43 | func (c *RefreshingClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 44 | + | if err := ValidateFastMode(c.Name(), req.FastMode); err != nil { | |
| 45 | + | return nil, err | |
| 46 | + | } | |
| 44 | 47 | if c.refresh != nil { | |
| 45 | 48 | if newToken, err := c.refresh(ctx); err == nil && newToken != "" { | |
| 46 | 49 | c.mu.Lock() | |
| @@ -41,6 +41,9 @@ func (c *RefreshingClient) Name() string { | |||||
| 41 | } | 41 | } | ||
| 42 | 42 | ||||
| 43 | func (c *RefreshingClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | 43 | func (c *RefreshingClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | ||
| 44 | + | if err := ValidateFastMode(c.Name(), req.FastMode); err != nil { | |||
| 45 | + | return nil, err | |||
| 46 | + | } | |||
| 44 | if c.refresh != nil { | 47 | if c.refresh != nil { | ||
| 45 | if newToken, err := c.refresh(ctx); err == nil && newToken != "" { | 48 | if newToken, err := c.refresh(ctx); err == nil && newToken != "" { | ||
| 46 | c.mu.Lock() | 49 | c.mu.Lock() | ||