detached 16ec11b

Merge pull request #5 from bnema/feat/openai-fast-mode

feat(provider): add configurable OpenAI fast mode
commit
16ec11b670e3c98ee87a9fe5530d51db196cdd69
author
bnema · · 2026-08-04 09:49 UTC
committer
GitHub · · 2026-08-04 09:49 UTC
merge of
86b133e + 67c9c30 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
34 files · +879 -13
M README.md +3 -2
@@ -137,7 +137,7 @@ All data lives under `$ZOT_HOME`:
137137
138138 ```
139139 $ZOT_HOME/
140-├── config.json # last-used provider/model/theme, saved automatically
140+├── config.json # last-used provider/model/theme and persistent settings
141141 ├── auth.json # api keys and oauth tokens (mode 0600)
142142 ├── sessions/ # jsonl transcripts, one dir per cwd
143143 ├── 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
289289 | `/unjail` | Allow tools to touch paths outside again. |
290290 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
291291 | `/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. |
293293 | `/clear` | Clear the chat transcript. |
294294 | `/exit` | Exit zot. |
295295
@@ -392,6 +392,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
392392 - **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.
393393 - **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.
394394 - **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.
395396 - **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.
396397 - **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.
397398 - **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 automatically140+├── 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 cwd142 ├── 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.
115115 | Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` |
116116 | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` |
117117
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+
118128 Example:
119129
120130 ```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 ```bash130 ```bash
M docs/subagents.md +1 -1
@@ -82,4 +82,4 @@ The interactive command also supports the same selection explicitly:
8282 /swarm new --agent implementer --reasoning high Implement the parser change
8383 ```
8484
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 change82 /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 {
4848 Reasoning string
4949 Temperature *float32
5050
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+
5159 Continue bool
5260 Resume bool
5361 Session string
@@ -254,6 +262,12 @@ func ParseArgs(in []string) (Args, error) {
254262 }
255263 t := float32(f)
256264 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
257271 case "--stats":
258272 v, err := want(&i, arg)
259273 if err != nil {
@@ -48,6 +48,14 @@ type Args struct {
48 Reasoning string48 Reasoning string
49 Temperature *float3249 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 bool59 Continue bool
52 Resume bool60 Resume bool
53 Session string61 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 = &t264 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 {
3434 CWD string
3535 Reasoning string
3636 Temperature *float32
37+ FastMode bool
3738
3839 ToolRegistry core.Registry
3940 ToolSummary []ToolSummary
@@ -698,6 +699,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
698699 if temperature == nil {
699700 temperature = cfg.Temperature
700701 }
702+ fastMode := cfg.FastMode != nil && *cfg.FastMode
703+ if args.FastModeSet {
704+ fastMode = args.FastMode
705+ }
701706
702707 max := args.MaxSteps // 0 = unlimited
703708
@@ -712,6 +717,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
712717 CWD: args.CWD,
713718 Reasoning: reasoning,
714719 Temperature: temperature,
720+ FastMode: fastMode,
715721 ToolRegistry: reg,
716722 ToolSummary: summaries,
717723 SystemPrompt: sys,
@@ -840,13 +846,13 @@ func (r Resolved) NewClient() provider.Client {
840846 wrap := r.withHTTPClient
841847 switch r.Provider {
842848 case "ollama":
843- return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
849+ return wrap(provider.NewOpenAICompat("ollama", r.Credential, r.BaseURL, ""))
844850 case provider.LlamaCPPProviderID:
845851 credential := r.Credential
846852 if credential == "local" {
847853 credential = ""
848854 }
849- return wrap(provider.NewOpenAI(credential, r.BaseURL))
855+ return wrap(provider.NewOpenAICompat(provider.LlamaCPPProviderID, credential, r.BaseURL, ""))
850856 case "kimi":
851857 // kimi-coding speaks anthropic-messages on api.kimi.com/coding.
852858 // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
@@ -1034,6 +1040,7 @@ func (r Resolved) NewAgent() *core.Agent {
10341040 a.MaxTokens = r.MaxOutput
10351041 a.Reasoning = r.Reasoning
10361042 a.Temperature = r.Temperature
1043+ a.FastMode = r.FastMode
10371044 return a
10381045 }
10391046
@@ -34,6 +34,7 @@ type Resolved struct {
34 CWD string34 CWD string
35 Reasoning string35 Reasoning string
36 Temperature *float3236 Temperature *float32
37+ FastMode bool
37 38
38 ToolRegistry core.Registry39 ToolRegistry core.Registry
39 ToolSummary []ToolSummary40 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.Temperature700 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 = unlimited707 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.withHTTPClient846 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.Credential851 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.MaxOutput1040 a.MaxTokens = r.MaxOutput
1035 a.Reasoning = r.Reasoning1041 a.Reasoning = r.Reasoning
1036 a.Temperature = r.Temperature1042 a.Temperature = r.Temperature
1043+ a.FastMode = r.FastMode
1037 return a1044 return a
1038 }1045 }
1039 1046
M packages/agent/build_test.go +2 -2
@@ -405,8 +405,8 @@ func TestResolveLlamaCPPUsesRouterInferenceURL(t *testing.T) {
405405 if r.Credential != "local" || !r.HasCredential() {
406406 t.Fatalf("credential = %q", r.Credential)
407407 }
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)
410410 }
411411 }
412412
@@ -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 {
743743 swarmMgr = swarm.New(swarm.Config{
744744 Root: filepath.Join(ZotHome(), "swarm"),
745745 RepoRoot: r.CWD,
746+ FastMode: r.FastMode,
746747 ResolveCredential: func(ctx context.Context, providerID string) (swarm.Credential, error) {
747748 if providerID == "ollama" {
748749 return swarm.Credential{Value: "ollama", Method: "apikey"}, nil
@@ -1291,6 +1292,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12911292 }()
12921293
12931294 initialCfg, _ := LoadConfig()
1295+ fastMode := r.FastMode
12941296 quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts))
12951297 for idx, s := range initialCfg.QuickModelShortcuts {
12961298 quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model}
@@ -1331,6 +1333,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
13311333 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
13321334 TerminalAlertsEnabled: initialCfg.TerminalAlertsEnabled,
13331335 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
1336+ FastMode: &fastMode,
13341337 AutoCompactThreshold: initialCfg.AutoCompactThreshold,
13351338 JailByDefault: initialCfg.JailByDefault,
13361339 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"}, nil749 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 {
3030 Temperature *float32 `json:"temperature,omitempty"`
3131 Theme string `json:"theme"`
3232
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+
3338 // ToolRender selects how tool calls are drawn in interactive mode.
3439 // "box" (default, or empty) wraps each call in a bordered panel;
3540 // "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 {
276276 agent.MaxSteps = main.MaxSteps
277277 agent.Reasoning = main.Reasoning
278278 agent.Temperature = main.Temperature
279+ agent.FastMode = main.FastModeEnabled()
279280 agent.MaxTokens = main.MaxTokens
280281 agent.BeforeToolExecute = main.BeforeToolExecute
281282 agent.BeforeTurn = main.BeforeTurn
@@ -276,6 +276,7 @@ func newBtwAgent(main *core.Agent, system, model string) *core.Agent {
276 agent.MaxSteps = main.MaxSteps276 agent.MaxSteps = main.MaxSteps
277 agent.Reasoning = main.Reasoning277 agent.Reasoning = main.Reasoning
278 agent.Temperature = main.Temperature278 agent.Temperature = main.Temperature
279+ agent.FastMode = main.FastModeEnabled()
279 agent.MaxTokens = main.MaxTokens280 agent.MaxTokens = main.MaxTokens
280 agent.BeforeToolExecute = main.BeforeToolExecute281 agent.BeforeToolExecute = main.BeforeToolExecute
281 agent.BeforeTurn = main.BeforeTurn282 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 {
6868 // re-reading config.json on every open.
6969 AutoSwarmEnabled *bool
7070
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+
7176 // AutoCompactThreshold is the context-window percentage that triggers
7277 // automatic compaction. nil means 85; zero disables percentage-based
7378 // triggers while preserving payload-too-large recovery.
@@ -370,6 +375,10 @@ type autoCompactThresholdSettingsStore interface {
370375 SetAutoCompactThreshold(percent int) error
371376 }
372377
378+type fastModeSettingsStore interface {
379+ SetFastMode(enabled bool) error
380+}
381+
373382 type Interactive struct {
374383 cfg InteractiveConfig
375384 view *tui.View
@@ -3411,6 +3420,12 @@ func (i *Interactive) openSettingsDialog() {
34113420 autoSwarmHint = "swarm supervisor not available in this mode"
34123421 }
34133422
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+
34143429 jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault
34153430 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
34163431 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
@@ -3523,6 +3538,13 @@ func (i *Interactive) openSettingsDialog() {
35233538 disabled: autoSwarmDisabled,
35243539 hint: autoSwarmHint,
35253540 },
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+ },
35263548 {
35273549 key: "auto_compact_threshold",
35283550 label: "auto-compact threshold",
@@ -3821,6 +3843,18 @@ func (i *Interactive) refreshQuickModelSettingsItem(slot int) {
38213843 }
38223844 }
38233845
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+
38243858 func (i *Interactive) applySettingToggle(key string, value bool) {
38253859 // Every setting toggle forces a full repaint at the end — same
38263860 // effect as the user pressing Ctrl+L — so any per-setting visual
@@ -3891,6 +3925,36 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
38913925 i.statusOK = "auto-swarm " + onOff(value)
38923926 i.statusErr = ""
38933927 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()
38943958 case "jail_by_default":
38953959 val := value
38963960 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 *bool69 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 triggers76 // AutoCompactThreshold is the context-window percentage that triggers
72 // automatic compaction. nil means 85; zero disables percentage-based77 // 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) error375 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 InteractiveConfig383 cfg InteractiveConfig
375 view *tui.View384 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.JailByDefault3429 jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault
3415 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest3430 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
3416 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore3431 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 — same3859 // Every setting toggle forces a full repaint at the end — same
3826 // effect as the user pressing Ctrl+L — so any per-setting visual3860 // 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 := value3959 val := value
3896 i.cfg.JailByDefault = &val3960 i.cfg.JailByDefault = &val
M packages/agent/settings_store.go +9 -0
@@ -59,6 +59,15 @@ func (configSettingsStore) SetAutoSwarm(enabled bool) error {
5959 return SaveConfig(cfg)
6060 }
6161
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+
6271 func (configSettingsStore) SetAutoCompactThreshold(percent int) error {
6372 cfg, err := LoadConfig()
6473 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 {
2525 Model string
2626 Provider string
2727 Reasoning string
28+ FastMode bool
2829 Subagent string
2930
3031 // SessionID, when non-empty, scopes the agent to a particular
@@ -25,6 +25,7 @@ type Agent struct {
25 Model string25 Model string
26 Provider string26 Provider string
27 Reasoning string27 Reasoning string
28+ FastMode bool
28 Subagent string29 Subagent string
29 30
30 // SessionID, when non-empty, scopes the agent to a particular31 // 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 {
4545 Model string `json:"model,omitempty"`
4646 Provider string `json:"provider,omitempty"`
4747 Reasoning string `json:"reasoning,omitempty"`
48+ FastMode *bool `json:"fast_mode,omitempty"`
4849 Subagent string `json:"subagent,omitempty"`
4950 InboxPath string `json:"inbox_path"`
5051 EventLogPath string `json:"event_log_path"`
@@ -66,6 +67,7 @@ func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.jso
6667 // write is atomic (tmp + rename) so a crash mid-write can't leave a
6768 // half-parsable file that fails Reload.
6869 func writeAgentMeta(stateDir string, a *Agent) error {
70+ fastMode := a.FastMode
6971 m := agentMeta{
7072 ID: a.ID,
7173 Task: a.Task,
@@ -74,6 +76,7 @@ func writeAgentMeta(stateDir string, a *Agent) error {
7476 Model: a.Model,
7577 Provider: a.Provider,
7678 Reasoning: a.Reasoning,
79+ FastMode: &fastMode,
7780 Subagent: a.Subagent,
7881 InboxPath: a.InboxPath,
7982 EventLogPath: a.EventLogPath,
@@ -202,6 +205,10 @@ func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent {
202205 if f.cfg.RepoRoot != "" {
203206 dir = f.cfg.RepoRoot
204207 }
208+ fastMode := f.cfg.FastMode
209+ if m.FastMode != nil {
210+ fastMode = *m.FastMode
211+ }
205212 a := &Agent{
206213 ID: m.ID,
207214 Task: m.Task,
@@ -210,6 +217,7 @@ func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent {
210217 Model: m.Model,
211218 Provider: m.Provider,
212219 Reasoning: m.Reasoning,
220+ FastMode: fastMode,
213221 Subagent: m.Subagent,
214222 InboxPath: m.InboxPath,
215223 EventLogPath: m.EventLogPath,
@@ -348,11 +356,13 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
348356 if err != nil {
349357 return nil, fmt.Errorf("swarm inbox path: %w", err)
350358 }
359+ fastMode := existing.FastMode
351360 m := agentMeta{
352361 ID: existing.ID, Task: existing.Task,
353362 Dir: existing.Dir, Started: existing.Started,
354363 Model: existing.Model, Provider: existing.Provider,
355- Reasoning: existing.Reasoning, Subagent: existing.Subagent,
364+ Reasoning: existing.Reasoning, FastMode: &fastMode,
365+ Subagent: existing.Subagent,
356366 SessionID: existing.SessionID,
357367 InboxPath: inboxPath, EventLogPath: existing.EventLogPath,
358368 SessionPath: existing.SessionPath,
@@ -366,6 +376,7 @@ func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
366376 Model: m.Model,
367377 Provider: m.Provider,
368378 Reasoning: m.Reasoning,
379+ FastMode: fastMode,
369380 Subagent: m.Subagent,
370381 SessionID: m.SessionID,
371382 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 a67 // 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.RepoRoot206 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 {
6767 Model string
6868 Provider string
6969 Reasoning string
70+ FastMode bool
71+ FastModeSet bool
7072 Subagent string
7173 }
7274
@@ -98,6 +100,8 @@ func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []str
98100 Model: a.Model,
99101 Provider: a.Provider,
100102 Reasoning: a.Reasoning,
103+ FastMode: a.FastMode,
104+ FastModeSet: true,
101105 Subagent: a.Subagent,
102106 })
103107 }
@@ -121,6 +125,13 @@ func swarmAgentArgs(opts swarmAgentArgsOpts) []string {
121125 if opts.Reasoning != "" {
122126 args = append(args, "--reasoning", opts.Reasoning)
123127 }
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+ }
124135 if opts.Subagent != "" {
125136 args = append(args, "--subagent", opts.Subagent)
126137 }
@@ -67,6 +67,8 @@ type swarmAgentArgsOpts struct {
67 Model string67 Model string
68 Provider string68 Provider string
69 Reasoning string69 Reasoning string
70+ FastMode bool
71+ FastModeSet bool
70 Subagent string72 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) {
204204 t.Fatalf("resume argv contains the task; it would re-fire as a duplicate turn\n%v", args)
205205 }
206206 }
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.
209210 if got := args[len(args)-1]; got == "" {
210211 t.Fatalf("resume argv ends with an empty positional: %v", args)
211212 }
212- if strings.HasPrefix(args[len(args)-1], "--") {
213- t.Fatalf("resume argv ends on a bare flag (no value): %v", args)
214- }
215213 }
216214
217215 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 a207+ // 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 {
5858 // per-agent isolation: agents edit the host's files directly.
5959 RepoRoot string
6060
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+
6165 // NewRunner produces the Runner for an Agent. If nil, the default
6266 // `zot --swarm-agent ...` exec runner is used. Tests inject a fake
6367 // here.
@@ -156,6 +160,22 @@ func (f *Swarm) RepoRoot() string {
156160 return f.cfg.RepoRoot
157161 }
158162
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+
159179 // SetActiveSession scopes the dashboard view (and Spawn stamping)
160180 // to a particular host zot session id. Pass empty to clear the
161181 // 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)
235255 // concurrent /cd cannot race a spawn or produce a mixed snapshot.
236256 f.mu.Lock()
237257 dir := f.cfg.RepoRoot
258+ fastMode := f.cfg.FastMode
238259 sessionID := f.activeSession
239260 f.mu.Unlock()
240261
@@ -260,6 +281,7 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error)
260281 Model: strings.TrimSpace(req.Model),
261282 Provider: strings.TrimSpace(req.Provider),
262283 Reasoning: strings.TrimSpace(req.Reasoning),
284+ FastMode: fastMode,
263285 Subagent: strings.TrimSpace(req.Subagent),
264286 SessionID: sessionID,
265287 InboxPath: inboxPath,
@@ -475,6 +497,7 @@ type AgentSnapshot struct {
475497 Model string
476498 Provider string
477499 Reasoning string
500+ FastMode bool
478501 Subagent string
479502
480503 // Paths to the agent's durable state. Surface them in the
@@ -506,6 +529,7 @@ func (a *Agent) Snapshot() AgentSnapshot {
506529 Model: a.Model,
507530 Provider: a.Provider,
508531 Reasoning: a.Reasoning,
532+ FastMode: a.FastMode,
509533 Subagent: a.Subagent,
510534 InboxPath: a.InboxPath,
511535 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 string59 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 default65 // NewRunner produces the Runner for an Agent. If nil, the default
62 // `zot --swarm-agent ...` exec runner is used. Tests inject a fake66 // `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.RepoRoot160 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 the180 // 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.RepoRoot257 dir := f.cfg.RepoRoot
258+ fastMode := f.cfg.FastMode
238 sessionID := f.activeSession259 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 string497 Model string
476 Provider string498 Provider string
477 Reasoning string499 Reasoning string
500+ FastMode bool
478 Subagent string501 Subagent string
479 502
480 // Paths to the agent's durable state. Surface them in the503 // 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
202202 if reasoning != "" {
203203 fmt.Fprintf(&sb, "reasoning: %s\n", reasoning)
204204 }
205+ if agent.FastMode {
206+ sb.WriteString("fast mode: enabled\n")
207+ }
205208 sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ")
206209 sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.")
207210 return core.ToolResult{
@@ -213,6 +216,7 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
213216 "model": model,
214217 "provider": providerID,
215218 "reasoning": reasoning,
219+ "fast_mode": agent.FastMode,
216220 },
217221 }, nil
218222 }
@@ -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 }, nil221 }, nil
218 }222 }
M packages/core/agent.go +24 -0
@@ -22,6 +22,9 @@ type Agent struct {
2222 MaxSteps int
2323 Reasoning string
2424 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
2528
2629 // MaxTokens caps the model's output tokens per turn. Zero leaves
2730 // 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
120123 }
121124 }
122125
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+
123142 // QueueMessage queues text to be injected as a user message at the
124143 // next safe boundary of the active agent loop. It is non-blocking in
125144 // the sense that it never waits for model/tool work; it only takes
@@ -540,6 +559,10 @@ func (a *Agent) dropLastAssistantMessage() {
540559 // oneTurn calls the LLM once, forwards events, returns the stop reason
541560 // and the assembled assistant message (already appended to the transcript).
542561 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+ }
543566 req := provider.Request{
544567 Model: a.Model,
545568 System: a.System,
@@ -554,6 +577,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.St
554577 Messages: repairToolUseResultPairs(a.Messages()),
555578 Tools: a.Tools.Specs(),
556579 Reasoning: a.Reasoning,
580+ FastMode: fastMode,
557581 MaxTokens: a.MaxTokens,
558582 Temperature: a.Temperature,
559583 }
@@ -22,6 +22,9 @@ type Agent struct {
22 MaxSteps int22 MaxSteps int
23 Reasoning string23 Reasoning string
24 Temperature *float3224 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 leaves29 // MaxTokens caps the model's output tokens per turn. Zero leaves
27 // the field unset on the provider request, letting each provider30 // 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 the142 // 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 in143 // 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 takes144 // 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 reason559 // 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
4040 return "", fmt.Errorf("nothing to compact: keep-tail covers the whole transcript")
4141 }
4242
43+ fastMode := a.FastModeEnabled()
44+ if err := provider.ValidateFastMode(a.Client.Name(), fastMode); err != nil {
45+ return "", err
46+ }
47+
4348 // Serialize the summarizable transcript to text and wrap it in tags
4449 // so the model treats it as material to summarize, not to continue.
4550 transcript := serializeTranscript(summarizable)
@@ -51,6 +56,7 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin
5156 System: summarizationSystem,
5257 MaxTokens: 4096,
5358 Temperature: a.Temperature,
59+ FastMode: fastMode,
5460 Messages: []provider.Message{
5561 {
5662 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 tags48 // 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 {
572572 }
573573
574574 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+ }
575578 wire, err := c.buildRequest(req)
576579 if err != nil {
577580 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, err580 return nil, err
M packages/provider/anthropic.go +3 -0
@@ -523,6 +523,9 @@ func anthBuildToolResultContent(blocks []Content) (json.RawMessage, error) {
523523 // ---- streaming ----
524524
525525 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+ }
526529 wire, err := c.buildRequest(req)
527530 if err != nil {
528531 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, err531 return nil, err
M packages/provider/extra_providers.go +3 -0
@@ -233,6 +233,9 @@ type unimplementedClient struct {
233233 func (c *unimplementedClient) Name() string { return c.name }
234234
235235 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+ }
236239 return nil, fmt.Errorf("provider %q not yet implemented: %s", c.name, c.hint)
237240 }
238241
@@ -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 {
468468 // ---- streaming ----
469469
470470 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+ }
471474 wire, modelID, err := c.buildRequest(req)
472475 if err != nil {
473476 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, err476 return nil, err
M packages/provider/google_vertex.go +3 -0
@@ -367,5 +367,8 @@ type renamedClient struct {
367367
368368 func (r *renamedClient) Name() string { return r.name }
369369 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+ }
370373 return r.inner.Stream(ctx, req)
371374 }
@@ -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
3030 func (c *modelRouter) Name() string { return c.name }
3131
3232 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+ }
3336 client := c.fallback
3437 if model, err := FindModel(c.name, req.Model); err == nil && model.API != "" {
3538 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.fallback36 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 {
176176 MaxTokens *int `json:"max_tokens,omitempty"`
177177 MaxCompletionTok *int `json:"max_completion_tokens,omitempty"`
178178 ReasoningEffort string `json:"reasoning_effort,omitempty"`
179+ ServiceTier string `json:"service_tier,omitempty"`
179180 }
180181
181182 // ---- request building ----
182183
183184 func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
185+ if err := ValidateFastMode(c.Name(), req.FastMode); err != nil {
186+ return nil, err
187+ }
184188 // The OpenAI wire client is shared by many providers, so prefer its own
185189 // provider namespace before falling back to a provider-agnostic lookup.
186190 m, err := FindModel(c.Name(), req.Model)
@@ -203,6 +207,9 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
203207 StreamOptions: &oaiStreamOptions{IncludeUsage: true},
204208 Temperature: req.Temperature,
205209 }
210+ if req.FastMode {
211+ out.ServiceTier = fastModeServiceTier
212+ }
206213
207214 maxTok := req.MaxTokens
208215 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 own188 // 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.MaxTokens214 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 {
163163 Include []string `json:"include,omitempty"`
164164 Reasoning *codexReasoningConfig `json:"reasoning,omitempty"`
165165 PromptCacheKey string `json:"prompt_cache_key,omitempty"`
166+ ServiceTier string `json:"service_tier,omitempty"`
166167 }
167168
168169 // ---- Request building ----
@@ -180,6 +181,13 @@ func (c *codexClient) findModel(id string) (Model, error) {
180181 }
181182
182183 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+ }
183191 m, err := c.findModel(req.Model)
184192 if err != nil {
185193 return nil, err
@@ -194,6 +202,9 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
194202 ParallelToolCalls: true,
195203 Include: []string{"reasoning.encrypted_content"},
196204 }
205+ if req.FastMode {
206+ body.ServiceTier = fastModeServiceTier
207+ }
197208 if m.Reasoning {
198209 if effort := OpenAICodexReasoningEffort(reasoning, req.Model); effort != "" {
199210 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, err193 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
88 import (
99 "context"
1010 "encoding/json"
11+ "fmt"
12+ "strings"
1113 "time"
1214 )
1315
@@ -255,6 +257,34 @@ type Request struct {
255257 // "max". Empty disables reasoning. The max tier is sent natively only to
256258 // models that support it and clamped elsewhere.
257259 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)
258288 }
259289
260290 // 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 to257 // "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 string259 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 {
4141 }
4242
4343 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+ }
4447 if c.refresh != nil {
4548 if newToken, err := c.refresh(ctx); err == nil && newToken != "" {
4649 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()

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close