detached 4441e91

Add models, presets, and server tools for OpenRouter (#152)

* feat: openrouter - models, presets, server tools

* Fix OpenRouter server tool safety

---------

Co-authored-by: Patric Eckhart <mail@patriceckhart.com>
commit
4441e914b4c44273f19489cc95660ca14d326cc8
author
Davi · · 2026-08-31 14:20 UTC
committer
GitHub · · 2026-08-31 14:20 UTC
parent
356583d
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.3.54
30 files · +1479 -113
M README.md +1 -0
@@ -403,6 +403,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
403403 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
404404 - **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.
405405 - **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.
406+- **OpenRouter Server Tools**: opt in to OpenRouter's beta web search, web fetch, advisor, subagent, fusion, image generation, datetime, and model-search tools. Off by default and available only with the OpenRouter provider. zot limits the remote loop to four server-tool calls per turn. `--no-tools` always disables them. Presets keep their own tool configuration and are not overridden by this setting. Server tools run remotely without zot's local tool confirmation dialog and may incur additional OpenRouter charges.
406407 - **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`.
407408 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
408409 - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields).
@@ -403,6 +403,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
403 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.403 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
404 - **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.404 - **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.
405 - **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.405 - **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.
406+- **OpenRouter Server Tools**: opt in to OpenRouter's beta web search, web fetch, advisor, subagent, fusion, image generation, datetime, and model-search tools. Off by default and available only with the OpenRouter provider. zot limits the remote loop to four server-tool calls per turn. `--no-tools` always disables them. Presets keep their own tool configuration and are not overridden by this setting. Server tools run remotely without zot's local tool confirmation dialog and may incur additional OpenRouter charges.
406 - **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`.407 - **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`.
407 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.408 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
408 - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields).409 - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields).
M packages/agent/botcmd.go +4 -0
@@ -304,10 +304,14 @@ func openOrCreateSessionForBot(args Args, r Resolved, ag *core.Agent, version st
304304 return nil, nil, err
305305 }
306306 ag.SetMessages(msgs)
307+ bindAgentSession(ag, s)
307308 return s, nil, nil
308309 }
309310 }
310311 s, err := core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)
312+ if err == nil {
313+ bindAgentSession(ag, s)
314+ }
311315 return s, nil, err
312316 }
313317
@@ -304,10 +304,14 @@ func openOrCreateSessionForBot(args Args, r Resolved, ag *core.Agent, version st
304 return nil, nil, err304 return nil, nil, err
305 }305 }
306 ag.SetMessages(msgs)306 ag.SetMessages(msgs)
307+ bindAgentSession(ag, s)
307 return s, nil, nil308 return s, nil, nil
308 }309 }
309 }310 }
310 s, err := core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)311 s, err := core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)
312+ if err == nil {
313+ bindAgentSession(ag, s)
314+ }
311 return s, nil, err315 return s, nil, err
312 }316 }
313 317
M packages/agent/build.go +40 -0
@@ -574,6 +574,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
574574 sandbox.SetPermissions(args.PermissionSet)
575575 }
576576 reg := buildToolRegistry(args, args.CWD, sandbox)
577+ if !args.NoTools && provName == "openrouter" && !isOpenRouterPresetModel(model) && OpenRouterServerToolsEnabled() {
578+ reg = withOpenRouterServerTools(reg)
579+ }
577580
578581 docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
579582
@@ -984,9 +987,46 @@ func (r Resolved) NewAgent() *core.Agent {
984987 a.MaxTokens = r.MaxOutput
985988 a.Reasoning = r.Reasoning
986989 a.Temperature = r.Temperature
990+ if hasOpenRouterServerTools(r.ToolRegistry) {
991+ a.MaxToolCalls = tools.OpenRouterServerToolCallLimit
992+ }
987993 return a
988994 }
989995
996+// OpenRouterServerToolsEnabled reports the persisted preference.
997+// nil/missing and unreadable configuration fail closed to disabled.
998+func OpenRouterServerToolsEnabled() bool {
999+ cfg, err := LoadConfig()
1000+ if err != nil {
1001+ return false
1002+ }
1003+ return cfg.OpenRouterServerToolsEnabled != nil && *cfg.OpenRouterServerToolsEnabled
1004+}
1005+
1006+func isOpenRouterPresetModel(model string) bool {
1007+ return strings.Contains(strings.ToLower(model), "@preset/")
1008+}
1009+
1010+func hasOpenRouterServerTools(reg core.Registry) bool {
1011+ for name := range reg {
1012+ if tools.IsOpenRouterServerTool(name) {
1013+ return true
1014+ }
1015+ }
1016+ return false
1017+}
1018+
1019+func withOpenRouterServerTools(reg core.Registry) core.Registry {
1020+ out := core.Registry{}
1021+ for name, t := range reg {
1022+ out[name] = t
1023+ }
1024+ for _, t := range tools.OpenRouterServerTools() {
1025+ out[t.Name()] = t
1026+ }
1027+ return out
1028+}
1029+
9901030 func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
9911031 if args.NoTools {
9921032 return core.Registry{}
@@ -574,6 +574,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
574 sandbox.SetPermissions(args.PermissionSet)574 sandbox.SetPermissions(args.PermissionSet)
575 }575 }
576 reg := buildToolRegistry(args, args.CWD, sandbox)576 reg := buildToolRegistry(args, args.CWD, sandbox)
577+ if !args.NoTools && provName == "openrouter" && !isOpenRouterPresetModel(model) && OpenRouterServerToolsEnabled() {
578+ reg = withOpenRouterServerTools(reg)
579+ }
577 580
578 docsDir, _ := zotdocs.EnsureInstalled(ZotHome())581 docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
579 582
@@ -984,9 +987,46 @@ func (r Resolved) NewAgent() *core.Agent {
984 a.MaxTokens = r.MaxOutput987 a.MaxTokens = r.MaxOutput
985 a.Reasoning = r.Reasoning988 a.Reasoning = r.Reasoning
986 a.Temperature = r.Temperature989 a.Temperature = r.Temperature
990+ if hasOpenRouterServerTools(r.ToolRegistry) {
991+ a.MaxToolCalls = tools.OpenRouterServerToolCallLimit
992+ }
987 return a993 return a
988 }994 }
989 995
996+// OpenRouterServerToolsEnabled reports the persisted preference.
997+// nil/missing and unreadable configuration fail closed to disabled.
998+func OpenRouterServerToolsEnabled() bool {
999+ cfg, err := LoadConfig()
1000+ if err != nil {
1001+ return false
1002+ }
1003+ return cfg.OpenRouterServerToolsEnabled != nil && *cfg.OpenRouterServerToolsEnabled
1004+}
1005+
1006+func isOpenRouterPresetModel(model string) bool {
1007+ return strings.Contains(strings.ToLower(model), "@preset/")
1008+}
1009+
1010+func hasOpenRouterServerTools(reg core.Registry) bool {
1011+ for name := range reg {
1012+ if tools.IsOpenRouterServerTool(name) {
1013+ return true
1014+ }
1015+ }
1016+ return false
1017+}
1018+
1019+func withOpenRouterServerTools(reg core.Registry) core.Registry {
1020+ out := core.Registry{}
1021+ for name, t := range reg {
1022+ out[name] = t
1023+ }
1024+ for _, t := range tools.OpenRouterServerTools() {
1025+ out[t.Name()] = t
1026+ }
1027+ return out
1028+}
1029+
990 func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {1030 func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
991 if args.NoTools {1031 if args.NoTools {
992 return core.Registry{}1032 return core.Registry{}
M packages/agent/build_test.go +94 -0
@@ -9,6 +9,7 @@ import (
99 "strings"
1010 "testing"
1111
12+ "github.com/patriceckhart/zot/packages/agent/tools"
1213 "github.com/patriceckhart/zot/packages/provider"
1314 )
1415
@@ -218,6 +219,99 @@ func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
218219 }
219220 }
220221
222+func TestNewAgentInjectsOpenRouterServerToolsWhenSettingOn(t *testing.T) {
223+ t.Setenv("ZOT_HOME", t.TempDir())
224+ t.Setenv("OPENROUTER_API_KEY", "test-key")
225+ on := true
226+ if err := SaveConfig(Config{Provider: "openrouter", Model: "openai/gpt-4.1", OpenRouterServerToolsEnabled: &on}); err != nil {
227+ t.Fatal(err)
228+ }
229+ r, err := Resolve(Args{NoSkill: true}, true)
230+ if err != nil {
231+ t.Fatal(err)
232+ }
233+ ag := r.NewAgent()
234+ for _, name := range []string{
235+ "openrouter:web_search",
236+ "openrouter:web_fetch",
237+ "openrouter:advisor",
238+ "openrouter:subagent",
239+ "openrouter:fusion",
240+ "openrouter:image_generation",
241+ "openrouter:datetime",
242+ "openrouter:experimental__search_models",
243+ } {
244+ if _, err := ag.Tools.Get(name); err != nil {
245+ t.Errorf("missing advertised server tool %q: %v", name, err)
246+ }
247+ }
248+ for _, name := range []string{"openrouter:shell", "openrouter:bash", "openrouter:apply_patch", "openrouter:tool_search"} {
249+ if _, err := ag.Tools.Get(name); err == nil {
250+ t.Errorf("chat-completions-incompatible tool %q should not be advertised", name)
251+ }
252+ }
253+ if ag.MaxToolCalls != tools.OpenRouterServerToolCallLimit {
254+ t.Fatalf("MaxToolCalls = %d; want %d", ag.MaxToolCalls, tools.OpenRouterServerToolCallLimit)
255+ }
256+
257+ refreshAgentToolsAndPrompt(Args{NoSkill: true}, nil, nil, ag, nil)
258+ if _, err := ag.Tools.Get("openrouter:web_search"); err != nil {
259+ t.Fatalf("tool refresh removed enabled OpenRouter server tools: %v", err)
260+ }
261+}
262+
263+func TestResolveOmitsOpenRouterServerToolsWithNoTools(t *testing.T) {
264+ t.Setenv("ZOT_HOME", t.TempDir())
265+ t.Setenv("OPENROUTER_API_KEY", "test-key")
266+ on := true
267+ if err := SaveConfig(Config{Provider: "openrouter", Model: "openai/gpt-4.1", OpenRouterServerToolsEnabled: &on}); err != nil {
268+ t.Fatal(err)
269+ }
270+ r, err := Resolve(Args{NoTools: true, NoSkill: true}, true)
271+ if err != nil {
272+ t.Fatal(err)
273+ }
274+ if len(r.ToolRegistry) != 0 {
275+ t.Fatalf("--no-tools registry has %d tools; want none", len(r.ToolRegistry))
276+ }
277+ if r.NewAgent().MaxToolCalls != 0 {
278+ t.Fatal("--no-tools agent retained a server-tool call limit")
279+ }
280+}
281+
282+func TestResolveDoesNotOverrideOpenRouterPresetTools(t *testing.T) {
283+ t.Setenv("ZOT_HOME", t.TempDir())
284+ t.Setenv("OPENROUTER_API_KEY", "test-key")
285+ on := true
286+ if err := SaveConfig(Config{Provider: "openrouter", Model: "@preset/flash", OpenRouterServerToolsEnabled: &on}); err != nil {
287+ t.Fatal(err)
288+ }
289+ r, err := Resolve(Args{NoSkill: true}, true)
290+ if err != nil {
291+ t.Fatal(err)
292+ }
293+ if hasOpenRouterServerTools(r.ToolRegistry) {
294+ t.Fatal("request tools would override tools configured by the preset")
295+ }
296+}
297+
298+func TestNewAgentOmitsOpenRouterServerToolsWhenSettingOff(t *testing.T) {
299+ t.Setenv("ZOT_HOME", t.TempDir())
300+ t.Setenv("OPENROUTER_API_KEY", "test-key")
301+ off := false
302+ if err := SaveConfig(Config{Provider: "openrouter", Model: "openai/gpt-4.1", OpenRouterServerToolsEnabled: &off}); err != nil {
303+ t.Fatal(err)
304+ }
305+ r, err := Resolve(Args{NoSkill: true}, true)
306+ if err != nil {
307+ t.Fatal(err)
308+ }
309+ ag := r.NewAgent()
310+ if _, err := ag.Tools.Get("openrouter:web_search"); err == nil {
311+ t.Fatal("web_search advertised while setting is off")
312+ }
313+}
314+
221315 func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) {
222316 t.Setenv("ZOT_HOME", t.TempDir())
223317 t.Setenv("OPENROUTER_API_KEY", "test-key")
@@ -9,6 +9,7 @@ import (
9 "strings"9 "strings"
10 "testing"10 "testing"
11 11
12+ "github.com/patriceckhart/zot/packages/agent/tools"
12 "github.com/patriceckhart/zot/packages/provider"13 "github.com/patriceckhart/zot/packages/provider"
13 )14 )
14 15
@@ -218,6 +219,99 @@ func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
218 }219 }
219 }220 }
220 221
222+func TestNewAgentInjectsOpenRouterServerToolsWhenSettingOn(t *testing.T) {
223+ t.Setenv("ZOT_HOME", t.TempDir())
224+ t.Setenv("OPENROUTER_API_KEY", "test-key")
225+ on := true
226+ if err := SaveConfig(Config{Provider: "openrouter", Model: "openai/gpt-4.1", OpenRouterServerToolsEnabled: &on}); err != nil {
227+ t.Fatal(err)
228+ }
229+ r, err := Resolve(Args{NoSkill: true}, true)
230+ if err != nil {
231+ t.Fatal(err)
232+ }
233+ ag := r.NewAgent()
234+ for _, name := range []string{
235+ "openrouter:web_search",
236+ "openrouter:web_fetch",
237+ "openrouter:advisor",
238+ "openrouter:subagent",
239+ "openrouter:fusion",
240+ "openrouter:image_generation",
241+ "openrouter:datetime",
242+ "openrouter:experimental__search_models",
243+ } {
244+ if _, err := ag.Tools.Get(name); err != nil {
245+ t.Errorf("missing advertised server tool %q: %v", name, err)
246+ }
247+ }
248+ for _, name := range []string{"openrouter:shell", "openrouter:bash", "openrouter:apply_patch", "openrouter:tool_search"} {
249+ if _, err := ag.Tools.Get(name); err == nil {
250+ t.Errorf("chat-completions-incompatible tool %q should not be advertised", name)
251+ }
252+ }
253+ if ag.MaxToolCalls != tools.OpenRouterServerToolCallLimit {
254+ t.Fatalf("MaxToolCalls = %d; want %d", ag.MaxToolCalls, tools.OpenRouterServerToolCallLimit)
255+ }
256+
257+ refreshAgentToolsAndPrompt(Args{NoSkill: true}, nil, nil, ag, nil)
258+ if _, err := ag.Tools.Get("openrouter:web_search"); err != nil {
259+ t.Fatalf("tool refresh removed enabled OpenRouter server tools: %v", err)
260+ }
261+}
262+
263+func TestResolveOmitsOpenRouterServerToolsWithNoTools(t *testing.T) {
264+ t.Setenv("ZOT_HOME", t.TempDir())
265+ t.Setenv("OPENROUTER_API_KEY", "test-key")
266+ on := true
267+ if err := SaveConfig(Config{Provider: "openrouter", Model: "openai/gpt-4.1", OpenRouterServerToolsEnabled: &on}); err != nil {
268+ t.Fatal(err)
269+ }
270+ r, err := Resolve(Args{NoTools: true, NoSkill: true}, true)
271+ if err != nil {
272+ t.Fatal(err)
273+ }
274+ if len(r.ToolRegistry) != 0 {
275+ t.Fatalf("--no-tools registry has %d tools; want none", len(r.ToolRegistry))
276+ }
277+ if r.NewAgent().MaxToolCalls != 0 {
278+ t.Fatal("--no-tools agent retained a server-tool call limit")
279+ }
280+}
281+
282+func TestResolveDoesNotOverrideOpenRouterPresetTools(t *testing.T) {
283+ t.Setenv("ZOT_HOME", t.TempDir())
284+ t.Setenv("OPENROUTER_API_KEY", "test-key")
285+ on := true
286+ if err := SaveConfig(Config{Provider: "openrouter", Model: "@preset/flash", OpenRouterServerToolsEnabled: &on}); err != nil {
287+ t.Fatal(err)
288+ }
289+ r, err := Resolve(Args{NoSkill: true}, true)
290+ if err != nil {
291+ t.Fatal(err)
292+ }
293+ if hasOpenRouterServerTools(r.ToolRegistry) {
294+ t.Fatal("request tools would override tools configured by the preset")
295+ }
296+}
297+
298+func TestNewAgentOmitsOpenRouterServerToolsWhenSettingOff(t *testing.T) {
299+ t.Setenv("ZOT_HOME", t.TempDir())
300+ t.Setenv("OPENROUTER_API_KEY", "test-key")
301+ off := false
302+ if err := SaveConfig(Config{Provider: "openrouter", Model: "openai/gpt-4.1", OpenRouterServerToolsEnabled: &off}); err != nil {
303+ t.Fatal(err)
304+ }
305+ r, err := Resolve(Args{NoSkill: true}, true)
306+ if err != nil {
307+ t.Fatal(err)
308+ }
309+ ag := r.NewAgent()
310+ if _, err := ag.Tools.Get("openrouter:web_search"); err == nil {
311+ t.Fatal("web_search advertised while setting is off")
312+ }
313+}
314+
221 func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) {315 func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) {
222 t.Setenv("ZOT_HOME", t.TempDir())316 t.Setenv("ZOT_HOME", t.TempDir())
223 t.Setenv("OPENROUTER_API_KEY", "test-key")317 t.Setenv("OPENROUTER_API_KEY", "test-key")
M packages/agent/cli.go +81 -49
@@ -978,20 +978,32 @@ func runInteractive(ctx context.Context, args Args, version string) error {
978978 // (login, /model swap to a different provider) also gets the
979979 // persistence hooks. Without this, switching provider would
980980 // silently revert to the old in-memory-only behaviour.
981+ bindLiveAgentSession := func(a *core.Agent) {
982+ persistMu.Lock()
983+ s := sess
984+ persistMu.Unlock()
985+ bindAgentSession(a, s)
986+ }
981987 baseBuildAgent := buildAgent
982988 buildAgent = func() (*core.Agent, string, string, error) {
983989 a, p, m, err := baseBuildAgent()
984- return wireAgentPersist(a), p, m, err
990+ a = wireAgentPersist(a)
991+ bindLiveAgentSession(a)
992+ return a, p, m, err
985993 }
986994 baseBuildAgentFor := buildAgentFor
987995 buildAgentFor = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
988996 a, p, m, err := baseBuildAgentFor(providerOverride, modelOverride)
989- return wireAgentPersist(a), p, m, err
997+ a = wireAgentPersist(a)
998+ bindLiveAgentSession(a)
999+ return a, p, m, err
9901000 }
9911001 baseBuildAgentForRescue := buildAgentForRescue
9921002 buildAgentForRescue = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
9931003 a, p, m, err := baseBuildAgentForRescue(providerOverride, modelOverride)
994- return wireAgentPersist(a), p, m, err
1004+ a = wireAgentPersist(a)
1005+ bindLiveAgentSession(a)
1006+ return a, p, m, err
9951007 }
9961008
9971009 // loadSession replaces the current session with the one at path and
@@ -1019,6 +1031,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
10191031 }
10201032 sess = newSess
10211033 currentAg.SetMessages(msgs)
1034+ bindAgentSession(currentAg, sess)
10221035 if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil {
10231036 currentAg.SeedCost(cum)
10241037 currentAg.SeedLastTurnUsage(last)
@@ -1152,6 +1165,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11521165 sess = newSess
11531166 sessBaselineMsgs = 0
11541167 persistMu.Unlock()
1168+ bindAgentSession(newAg, newSess)
11551169 }
11561170
11571171 // Push the new state into the running Interactive.
@@ -1250,48 +1264,50 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12501264 }
12511265
12521266 iv = modes.NewInteractive(modes.InteractiveConfig{
1253- Terminal: term,
1254- Theme: theme,
1255- InlineImagesEnabled: initialCfg.InlineImagesEnabled,
1256- AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
1257- AutoCompactThreshold: initialCfg.AutoCompactThreshold,
1258- JailByDefault: initialCfg.JailByDefault,
1259- QuickModelShortcuts: quickModelShortcuts,
1260- RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
1261- RespectGitignore: initialCfg.RespectGitignore,
1262- CompactMode: initialCfg.CompactMode,
1263- TUIInputStyle: initialCfg.TUIInputStyle,
1264- TUIStatusPosition: initialCfg.TUIStatusPosition,
1265- TUIWorkingPosition: initialCfg.TUIWorkingPosition,
1266- ThemeName: initialCfg.Theme,
1267- FlatTools: initialCfg.FlatToolRender(),
1268- CompactUser: initialCfg.CompactUserInput(),
1269- ExtensionThemes: extMgr.ThemeOptions,
1270- AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
1271- SettingsStore: configSettingsStore{},
1272- Model: r.Model,
1273- Provider: r.Provider,
1274- AuthMethod: r.AuthMethod,
1275- BaseURL: r.BaseURL,
1276- Reasoning: r.Reasoning,
1277- SystemPrompt: r.SystemPrompt,
1278- Tools: r.ToolRegistry,
1279- MaxSteps: r.MaxSteps,
1280- CWD: r.CWD,
1281- StartupAgentName: args.AgentName,
1282- StartupContextPaths: instructionContextPaths(r.ContextFiles),
1283- StartupExtensionNames: startupExtensionNames(extMgr.All()),
1284- StartupExtensionErrors: startupExtensionErrors,
1285- StartupSkillNames: startupSkillNames(startupSkills),
1286- ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
1287- ZotHome: ZotHome(),
1288- SessionsRoot: agentSessionsRoot(ZotHome(), args),
1289- Version: version,
1290- UpdateInfoChan: updateCh,
1291- Sandbox: sharedSandbox,
1292- Agent: ag,
1293- InitialInput: args.Prompt,
1294- StartupPre: args.StartupPre,
1267+ Terminal: term,
1268+ Theme: theme,
1269+ InlineImagesEnabled: initialCfg.InlineImagesEnabled,
1270+ AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
1271+ AutoCompactThreshold: initialCfg.AutoCompactThreshold,
1272+ JailByDefault: initialCfg.JailByDefault,
1273+ OpenRouterServerToolsEnabled: initialCfg.OpenRouterServerToolsEnabled,
1274+ NoTools: args.NoTools,
1275+ QuickModelShortcuts: quickModelShortcuts,
1276+ RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
1277+ RespectGitignore: initialCfg.RespectGitignore,
1278+ CompactMode: initialCfg.CompactMode,
1279+ TUIInputStyle: initialCfg.TUIInputStyle,
1280+ TUIStatusPosition: initialCfg.TUIStatusPosition,
1281+ TUIWorkingPosition: initialCfg.TUIWorkingPosition,
1282+ ThemeName: initialCfg.Theme,
1283+ FlatTools: initialCfg.FlatToolRender(),
1284+ CompactUser: initialCfg.CompactUserInput(),
1285+ ExtensionThemes: extMgr.ThemeOptions,
1286+ AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
1287+ SettingsStore: configSettingsStore{},
1288+ Model: r.Model,
1289+ Provider: r.Provider,
1290+ AuthMethod: r.AuthMethod,
1291+ BaseURL: r.BaseURL,
1292+ Reasoning: r.Reasoning,
1293+ SystemPrompt: r.SystemPrompt,
1294+ Tools: r.ToolRegistry,
1295+ MaxSteps: r.MaxSteps,
1296+ CWD: r.CWD,
1297+ StartupAgentName: args.AgentName,
1298+ StartupContextPaths: instructionContextPaths(r.ContextFiles),
1299+ StartupExtensionNames: startupExtensionNames(extMgr.All()),
1300+ StartupExtensionErrors: startupExtensionErrors,
1301+ StartupSkillNames: startupSkillNames(startupSkills),
1302+ ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
1303+ ZotHome: ZotHome(),
1304+ SessionsRoot: agentSessionsRoot(ZotHome(), args),
1305+ Version: version,
1306+ UpdateInfoChan: updateCh,
1307+ Sandbox: sharedSandbox,
1308+ Agent: ag,
1309+ InitialInput: args.Prompt,
1310+ StartupPre: args.StartupPre,
12951311 OnStartupPreDone: func() {
12961312 // entry.pre often installs skills/extensions; rediscover them
12971313 // before the user starts a model turn.
@@ -1475,7 +1491,8 @@ func agentSessionsRoot(root string, args Args) string {
14751491 }
14761492
14771493 // openOrCreateSession returns a session for the run. sess may be nil
1478-// with a nil error if session persistence is disabled.
1494+// with a nil error if session persistence is disabled. When a session
1495+// exists, its id is bound onto ag so providers can sticky-route.
14791496 func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
14801497 if args.NoSess {
14811498 return nil, nil
@@ -1522,15 +1539,20 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
15221539 if err != nil {
15231540 return nil, err
15241541 }
1525- if s != nil {
1542+ if s == nil {
1543+ s, err = core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
1544+ if err != nil {
1545+ return nil, err
1546+ }
1547+ } else {
15261548 ag.SetMessages(msgs)
15271549 if cum, last, uerr := core.SessionUsageDetail(s.Path); uerr == nil {
15281550 ag.SeedCost(cum)
15291551 ag.SeedLastTurnUsage(last)
15301552 }
1531- return s, nil
15321553 }
1533- return core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
1554+ bindAgentSession(ag, s)
1555+ return s, nil
15341556 }
15351557
15361558 func pickSession(root, cwd string) (string, error) {
@@ -1561,6 +1583,16 @@ func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) {
15611583 writeNewTranscriptLocked(ag, sess, from)
15621584 }
15631585
1586+// bindAgentSession stamps the zot session id onto the agent so providers
1587+// that support sticky routing (OpenRouter session_id) can reuse it.
1588+// No-op when persistence is disabled.
1589+func bindAgentSession(ag *core.Agent, sess *core.Session) {
1590+ if ag == nil || sess == nil {
1591+ return
1592+ }
1593+ ag.SessionID = sess.ID
1594+}
1595+
15641596 // writeNewTranscriptLocked is the same as WriteNewTranscript. The
15651597 // suffix marks that interactive callers must hold persistMu when
15661598 // invoking it so concurrent appends from the agent loop don't race
@@ -978,20 +978,32 @@ func runInteractive(ctx context.Context, args Args, version string) error {
978 // (login, /model swap to a different provider) also gets the978 // (login, /model swap to a different provider) also gets the
979 // persistence hooks. Without this, switching provider would979 // persistence hooks. Without this, switching provider would
980 // silently revert to the old in-memory-only behaviour.980 // silently revert to the old in-memory-only behaviour.
981+ bindLiveAgentSession := func(a *core.Agent) {
982+ persistMu.Lock()
983+ s := sess
984+ persistMu.Unlock()
985+ bindAgentSession(a, s)
986+ }
981 baseBuildAgent := buildAgent987 baseBuildAgent := buildAgent
982 buildAgent = func() (*core.Agent, string, string, error) {988 buildAgent = func() (*core.Agent, string, string, error) {
983 a, p, m, err := baseBuildAgent()989 a, p, m, err := baseBuildAgent()
984- return wireAgentPersist(a), p, m, err990+ a = wireAgentPersist(a)
991+ bindLiveAgentSession(a)
992+ return a, p, m, err
985 }993 }
986 baseBuildAgentFor := buildAgentFor994 baseBuildAgentFor := buildAgentFor
987 buildAgentFor = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {995 buildAgentFor = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
988 a, p, m, err := baseBuildAgentFor(providerOverride, modelOverride)996 a, p, m, err := baseBuildAgentFor(providerOverride, modelOverride)
989- return wireAgentPersist(a), p, m, err997+ a = wireAgentPersist(a)
998+ bindLiveAgentSession(a)
999+ return a, p, m, err
990 }1000 }
991 baseBuildAgentForRescue := buildAgentForRescue1001 baseBuildAgentForRescue := buildAgentForRescue
992 buildAgentForRescue = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {1002 buildAgentForRescue = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
993 a, p, m, err := baseBuildAgentForRescue(providerOverride, modelOverride)1003 a, p, m, err := baseBuildAgentForRescue(providerOverride, modelOverride)
994- return wireAgentPersist(a), p, m, err1004+ a = wireAgentPersist(a)
1005+ bindLiveAgentSession(a)
1006+ return a, p, m, err
995 }1007 }
996 1008
997 // loadSession replaces the current session with the one at path and1009 // loadSession replaces the current session with the one at path and
@@ -1019,6 +1031,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1019 }1031 }
1020 sess = newSess1032 sess = newSess
1021 currentAg.SetMessages(msgs)1033 currentAg.SetMessages(msgs)
1034+ bindAgentSession(currentAg, sess)
1022 if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil {1035 if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil {
1023 currentAg.SeedCost(cum)1036 currentAg.SeedCost(cum)
1024 currentAg.SeedLastTurnUsage(last)1037 currentAg.SeedLastTurnUsage(last)
@@ -1152,6 +1165,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1152 sess = newSess1165 sess = newSess
1153 sessBaselineMsgs = 01166 sessBaselineMsgs = 0
1154 persistMu.Unlock()1167 persistMu.Unlock()
1168+ bindAgentSession(newAg, newSess)
1155 }1169 }
1156 1170
1157 // Push the new state into the running Interactive.1171 // Push the new state into the running Interactive.
@@ -1250,48 +1264,50 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1250 }1264 }
1251 1265
1252 iv = modes.NewInteractive(modes.InteractiveConfig{1266 iv = modes.NewInteractive(modes.InteractiveConfig{
1253- Terminal: term,1267+ Terminal: term,
1254- Theme: theme,1268+ Theme: theme,
1255- InlineImagesEnabled: initialCfg.InlineImagesEnabled,1269+ InlineImagesEnabled: initialCfg.InlineImagesEnabled,
1256- AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,1270+ AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
1257- AutoCompactThreshold: initialCfg.AutoCompactThreshold,1271+ AutoCompactThreshold: initialCfg.AutoCompactThreshold,
1258- JailByDefault: initialCfg.JailByDefault,1272+ JailByDefault: initialCfg.JailByDefault,
1259- QuickModelShortcuts: quickModelShortcuts,1273+ OpenRouterServerToolsEnabled: initialCfg.OpenRouterServerToolsEnabled,
1260- RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,1274+ NoTools: args.NoTools,
1261- RespectGitignore: initialCfg.RespectGitignore,1275+ QuickModelShortcuts: quickModelShortcuts,
1262- CompactMode: initialCfg.CompactMode,1276+ RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
1263- TUIInputStyle: initialCfg.TUIInputStyle,1277+ RespectGitignore: initialCfg.RespectGitignore,
1264- TUIStatusPosition: initialCfg.TUIStatusPosition,1278+ CompactMode: initialCfg.CompactMode,
1265- TUIWorkingPosition: initialCfg.TUIWorkingPosition,1279+ TUIInputStyle: initialCfg.TUIInputStyle,
1266- ThemeName: initialCfg.Theme,1280+ TUIStatusPosition: initialCfg.TUIStatusPosition,
1267- FlatTools: initialCfg.FlatToolRender(),1281+ TUIWorkingPosition: initialCfg.TUIWorkingPosition,
1268- CompactUser: initialCfg.CompactUserInput(),1282+ ThemeName: initialCfg.Theme,
1269- ExtensionThemes: extMgr.ThemeOptions,1283+ FlatTools: initialCfg.FlatToolRender(),
1270- AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,1284+ CompactUser: initialCfg.CompactUserInput(),
1271- SettingsStore: configSettingsStore{},1285+ ExtensionThemes: extMgr.ThemeOptions,
1272- Model: r.Model,1286+ AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
1273- Provider: r.Provider,1287+ SettingsStore: configSettingsStore{},
1274- AuthMethod: r.AuthMethod,1288+ Model: r.Model,
1275- BaseURL: r.BaseURL,1289+ Provider: r.Provider,
1276- Reasoning: r.Reasoning,1290+ AuthMethod: r.AuthMethod,
1277- SystemPrompt: r.SystemPrompt,1291+ BaseURL: r.BaseURL,
1278- Tools: r.ToolRegistry,1292+ Reasoning: r.Reasoning,
1279- MaxSteps: r.MaxSteps,1293+ SystemPrompt: r.SystemPrompt,
1280- CWD: r.CWD,1294+ Tools: r.ToolRegistry,
1281- StartupAgentName: args.AgentName,1295+ MaxSteps: r.MaxSteps,
1282- StartupContextPaths: instructionContextPaths(r.ContextFiles),1296+ CWD: r.CWD,
1283- StartupExtensionNames: startupExtensionNames(extMgr.All()),1297+ StartupAgentName: args.AgentName,
1284- StartupExtensionErrors: startupExtensionErrors,1298+ StartupContextPaths: instructionContextPaths(r.ContextFiles),
1285- StartupSkillNames: startupSkillNames(startupSkills),1299+ StartupExtensionNames: startupExtensionNames(extMgr.All()),
1286- ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,1300+ StartupExtensionErrors: startupExtensionErrors,
1287- ZotHome: ZotHome(),1301+ StartupSkillNames: startupSkillNames(startupSkills),
1288- SessionsRoot: agentSessionsRoot(ZotHome(), args),1302+ ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
1289- Version: version,1303+ ZotHome: ZotHome(),
1290- UpdateInfoChan: updateCh,1304+ SessionsRoot: agentSessionsRoot(ZotHome(), args),
1291- Sandbox: sharedSandbox,1305+ Version: version,
1292- Agent: ag,1306+ UpdateInfoChan: updateCh,
1293- InitialInput: args.Prompt,1307+ Sandbox: sharedSandbox,
1294- StartupPre: args.StartupPre,1308+ Agent: ag,
1309+ InitialInput: args.Prompt,
1310+ StartupPre: args.StartupPre,
1295 OnStartupPreDone: func() {1311 OnStartupPreDone: func() {
1296 // entry.pre often installs skills/extensions; rediscover them1312 // entry.pre often installs skills/extensions; rediscover them
1297 // before the user starts a model turn.1313 // before the user starts a model turn.
@@ -1475,7 +1491,8 @@ func agentSessionsRoot(root string, args Args) string {
1475 }1491 }
1476 1492
1477 // openOrCreateSession returns a session for the run. sess may be nil1493 // openOrCreateSession returns a session for the run. sess may be nil
1478-// with a nil error if session persistence is disabled.1494+// with a nil error if session persistence is disabled. When a session
1495+// exists, its id is bound onto ag so providers can sticky-route.
1479 func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {1496 func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
1480 if args.NoSess {1497 if args.NoSess {
1481 return nil, nil1498 return nil, nil
@@ -1522,15 +1539,20 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
1522 if err != nil {1539 if err != nil {
1523 return nil, err1540 return nil, err
1524 }1541 }
1525- if s != nil {1542+ if s == nil {
1543+ s, err = core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
1544+ if err != nil {
1545+ return nil, err
1546+ }
1547+ } else {
1526 ag.SetMessages(msgs)1548 ag.SetMessages(msgs)
1527 if cum, last, uerr := core.SessionUsageDetail(s.Path); uerr == nil {1549 if cum, last, uerr := core.SessionUsageDetail(s.Path); uerr == nil {
1528 ag.SeedCost(cum)1550 ag.SeedCost(cum)
1529 ag.SeedLastTurnUsage(last)1551 ag.SeedLastTurnUsage(last)
1530 }1552 }
1531- return s, nil
1532 }1553 }
1533- return core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)1554+ bindAgentSession(ag, s)
1555+ return s, nil
1534 }1556 }
1535 1557
1536 func pickSession(root, cwd string) (string, error) {1558 func pickSession(root, cwd string) (string, error) {
@@ -1561,6 +1583,16 @@ func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) {
1561 writeNewTranscriptLocked(ag, sess, from)1583 writeNewTranscriptLocked(ag, sess, from)
1562 }1584 }
1563 1585
1586+// bindAgentSession stamps the zot session id onto the agent so providers
1587+// that support sticky routing (OpenRouter session_id) can reuse it.
1588+// No-op when persistence is disabled.
1589+func bindAgentSession(ag *core.Agent, sess *core.Session) {
1590+ if ag == nil || sess == nil {
1591+ return
1592+ }
1593+ ag.SessionID = sess.ID
1594+}
1595+
1564 // writeNewTranscriptLocked is the same as WriteNewTranscript. The1596 // writeNewTranscriptLocked is the same as WriteNewTranscript. The
1565 // suffix marks that interactive callers must hold persistMu when1597 // suffix marks that interactive callers must hold persistMu when
1566 // invoking it so concurrent appends from the agent loop don't race1598 // invoking it so concurrent appends from the agent loop don't race
M packages/agent/cli_session_test.go +16 -0
@@ -32,6 +32,22 @@ func TestLiveInteractiveAgentUsesReplacementAgentForSessionResume(t *testing.T)
3232 }
3333 }
3434
35+func TestBindAgentSession(t *testing.T) {
36+ ag := core.NewAgent(nil, "@preset/flash", "", nil)
37+ bindAgentSession(ag, &core.Session{ID: "sess-xyz"})
38+ if ag.SessionID != "sess-xyz" {
39+ t.Fatalf("SessionID = %q; want sess-xyz", ag.SessionID)
40+ }
41+ bindAgentSession(ag, &core.Session{ID: "other"})
42+ if ag.SessionID != "other" {
43+ t.Fatalf("SessionID = %q; want other after rebind", ag.SessionID)
44+ }
45+ bindAgentSession(ag, nil)
46+ if ag.SessionID != "other" {
47+ t.Fatalf("nil session mutated SessionID to %q", ag.SessionID)
48+ }
49+}
50+
3551 func TestLiveInteractiveAgentFallsBackBeforeInteractiveConstruction(t *testing.T) {
3652 startup := core.NewAgent(nil, "startup-model", "", nil)
3753 if got := liveInteractiveAgent(nil, startup); got != startup {
@@ -32,6 +32,22 @@ func TestLiveInteractiveAgentUsesReplacementAgentForSessionResume(t *testing.T)
32 }32 }
33 }33 }
34 34
35+func TestBindAgentSession(t *testing.T) {
36+ ag := core.NewAgent(nil, "@preset/flash", "", nil)
37+ bindAgentSession(ag, &core.Session{ID: "sess-xyz"})
38+ if ag.SessionID != "sess-xyz" {
39+ t.Fatalf("SessionID = %q; want sess-xyz", ag.SessionID)
40+ }
41+ bindAgentSession(ag, &core.Session{ID: "other"})
42+ if ag.SessionID != "other" {
43+ t.Fatalf("SessionID = %q; want other after rebind", ag.SessionID)
44+ }
45+ bindAgentSession(ag, nil)
46+ if ag.SessionID != "other" {
47+ t.Fatalf("nil session mutated SessionID to %q", ag.SessionID)
48+ }
49+}
50+
35 func TestLiveInteractiveAgentFallsBackBeforeInteractiveConstruction(t *testing.T) {51 func TestLiveInteractiveAgentFallsBackBeforeInteractiveConstruction(t *testing.T) {
36 startup := core.NewAgent(nil, "startup-model", "", nil)52 startup := core.NewAgent(nil, "startup-model", "", nil)
37 if got := liveInteractiveAgent(nil, startup); got != startup {53 if got := liveInteractiveAgent(nil, startup); got != startup {
M packages/agent/config.go +5 -0
@@ -68,6 +68,11 @@ type Config struct {
6868 // The live session can still override this with /jail or /unjail.
6969 JailByDefault *bool `json:"jail_by_default,omitempty"`
7070
71+ // OpenRouterServerToolsEnabled advertises OpenRouter server tools
72+ // (web search, datetime, advisor, and others) when the provider is OpenRouter.
73+ // Default off: users explicitly opt in from /settings.
74+ OpenRouterServerToolsEnabled *bool `json:"openrouter_server_tools_enabled,omitempty"`
75+
7176 // RecursiveFileSuggest controls the @-mention file picker. When true
7277 // the picker fuzzy-searches the whole project tree below the working
7378 // directory; nil/missing/false keeps the default directory-by-
@@ -68,6 +68,11 @@ type Config struct {
68 // The live session can still override this with /jail or /unjail.68 // The live session can still override this with /jail or /unjail.
69 JailByDefault *bool `json:"jail_by_default,omitempty"`69 JailByDefault *bool `json:"jail_by_default,omitempty"`
70 70
71+ // OpenRouterServerToolsEnabled advertises OpenRouter server tools
72+ // (web search, datetime, advisor, and others) when the provider is OpenRouter.
73+ // Default off: users explicitly opt in from /settings.
74+ OpenRouterServerToolsEnabled *bool `json:"openrouter_server_tools_enabled,omitempty"`
75+
71 // RecursiveFileSuggest controls the @-mention file picker. When true76 // RecursiveFileSuggest controls the @-mention file picker. When true
72 // the picker fuzzy-searches the whole project tree below the working77 // the picker fuzzy-searches the whole project tree below the working
73 // directory; nil/missing/false keeps the default directory-by-78 // directory; nil/missing/false keeps the default directory-by-
M packages/agent/modelsync.go +73 -36
@@ -174,54 +174,71 @@ func refreshLlamaCPPModels(ctx context.Context, commandMode apiKeyCommandMode) e
174174
175175 func refreshModels() {
176176 cached, _ := provider.LoadCache(ModelCachePath())
177- if cached.IsFresh() {
178- return
179- }
177+ cacheFresh := cached.IsFresh()
180178
181179 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
182180 defer cancel()
183181
184182 var all []provider.Model
183+ var openrouterCred string
184+ var haveOpenRouter bool
185+ if cred, _, err := resolveCredentialForBackground(ctx, "openrouter"); err == nil {
186+ openrouterCred = cred
187+ haveOpenRouter = true
188+ }
185189
186- if cred, method, err := resolveCredentialForBackground(ctx, "anthropic"); err == nil && method == "apikey" {
187- // /v1/models on Anthropic is API-key only; OAuth tokens can
188- // also list models via the bearer header, but we skip OAuth
189- // here to avoid surprise rate-limit hits on subscription keys.
190- if live, err := provider.DiscoverAnthropic(ctx, cred, ""); err == nil {
191- all = append(all, live...)
190+ if cacheFresh {
191+ all = append(all, cached.Models...)
192+ } else {
193+ if cred, method, err := resolveCredentialForBackground(ctx, "anthropic"); err == nil && method == "apikey" {
194+ // /v1/models on Anthropic is API-key only; OAuth tokens can
195+ // also list models via the bearer header, but we skip OAuth
196+ // here to avoid surprise rate-limit hits on subscription keys.
197+ if live, err := provider.DiscoverAnthropic(ctx, cred, ""); err == nil {
198+ all = append(all, live...)
199+ }
192200 }
193- }
194- if cred, method, err := resolveCredentialForBackground(ctx, "openai"); err == nil && method == "apikey" {
195- if live, err := provider.DiscoverOpenAI(ctx, cred, ""); err == nil {
196- all = append(all, live...)
201+ if cred, method, err := resolveCredentialForBackground(ctx, "openai"); err == nil && method == "apikey" {
202+ if live, err := provider.DiscoverOpenAI(ctx, cred, ""); err == nil {
203+ all = append(all, live...)
204+ }
197205 }
198- }
199- if cred, method, err := resolveCredentialForBackground(ctx, "kimi"); err == nil && method == "apikey" {
200- if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil {
201- for i := range live {
202- live[i].Provider = "kimi"
203- live[i].Source = "live"
206+ if cred, method, err := resolveCredentialForBackground(ctx, "kimi"); err == nil && method == "apikey" {
207+ if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil {
208+ for i := range live {
209+ live[i].Provider = "kimi"
210+ live[i].Source = "live"
211+ }
212+ all = append(all, live...)
204213 }
205- all = append(all, live...)
206214 }
207- }
208- if cred, method, err := resolveCredentialForBackground(ctx, "google"); err == nil && method == "apikey" {
209- if live, err := provider.DiscoverGoogle(ctx, cred, ""); err == nil {
210- all = append(all, live...)
215+ if cred, method, err := resolveCredentialForBackground(ctx, "google"); err == nil && method == "apikey" {
216+ if live, err := provider.DiscoverGoogle(ctx, cred, ""); err == nil {
217+ all = append(all, live...)
218+ }
211219 }
212- }
213- if _, _, err := resolveCredentialForBackground(ctx, "openrouter"); err == nil {
214- // /models is public; gate on a credential so the picker only
215- // fills with OpenRouter's hundreds of routes for users who use it.
216- if live, err := provider.DiscoverOpenRouter(ctx, ""); err == nil {
217- all = append(all, live...)
220+ if haveOpenRouter {
221+ // /models is public; gate on a credential so the picker only
222+ // fills with OpenRouter's hundreds of routes for users who use it.
223+ if live, err := provider.DiscoverOpenRouter(ctx, ""); err == nil {
224+ all = append(all, live...)
225+ }
226+ }
227+ if _, _, err := resolveCredentialForBackground(ctx, "gondola"); err == nil {
228+ // Gondola's catalog is also public. Gate discovery on a credential so
229+ // its text models only fill the picker for users of the provider.
230+ if live, err := provider.DiscoverGondola(ctx, ""); err == nil {
231+ all = append(all, live...)
232+ }
218233 }
219234 }
220- if _, _, err := resolveCredentialForBackground(ctx, "gondola"); err == nil {
221- // Gondola's catalog is also public. Gate discovery on a credential so
222- // its text models only fill the picker for users of the provider.
223- if live, err := provider.DiscoverGondola(ctx, ""); err == nil {
224- all = append(all, live...)
235+
236+ // Presets are per-account and require auth. Fetch them even when the
237+ // public /models cache is still fresh so @preset/{slug} shows up in
238+ // the picker without waiting out CacheTTL.
239+ if haveOpenRouter {
240+ if presets, err := provider.DiscoverOpenRouterPresets(ctx, openrouterCred, ""); err == nil {
241+ all = mergeOpenRouterPresets(all, presets)
225242 }
226243 }
227244
@@ -230,7 +247,27 @@ func refreshModels() {
230247 }
231248 provider.SetLiveModels(all)
232249 _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{
233- FetchedAt: time.Now().UTC(),
250+ FetchedAt: modelCacheFetchedAt(cached, !cacheFresh, time.Now().UTC()),
234251 Models: all,
235252 })
236253 }
254+
255+func modelCacheFetchedAt(cached provider.ModelCache, catalogRefreshed bool, now time.Time) time.Time {
256+ if catalogRefreshed {
257+ return now
258+ }
259+ return cached.FetchedAt
260+}
261+
262+// mergeOpenRouterPresets replaces any previously cached OpenRouter preset
263+// models with the freshly listed set, keeping every non-preset entry.
264+func mergeOpenRouterPresets(existing, presets []provider.Model) []provider.Model {
265+ out := make([]provider.Model, 0, len(existing)+len(presets))
266+ for _, m := range existing {
267+ if m.Provider == "openrouter" && strings.HasPrefix(m.ID, "@preset/") {
268+ continue
269+ }
270+ out = append(out, m)
271+ }
272+ return append(out, presets...)
273+}
@@ -174,54 +174,71 @@ func refreshLlamaCPPModels(ctx context.Context, commandMode apiKeyCommandMode) e
174 174
175 func refreshModels() {175 func refreshModels() {
176 cached, _ := provider.LoadCache(ModelCachePath())176 cached, _ := provider.LoadCache(ModelCachePath())
177- if cached.IsFresh() {177+ cacheFresh := cached.IsFresh()
178- return
179- }
180 178
181 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)179 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
182 defer cancel()180 defer cancel()
183 181
184 var all []provider.Model182 var all []provider.Model
183+ var openrouterCred string
184+ var haveOpenRouter bool
185+ if cred, _, err := resolveCredentialForBackground(ctx, "openrouter"); err == nil {
186+ openrouterCred = cred
187+ haveOpenRouter = true
188+ }
185 189
186- if cred, method, err := resolveCredentialForBackground(ctx, "anthropic"); err == nil && method == "apikey" {190+ if cacheFresh {
187- // /v1/models on Anthropic is API-key only; OAuth tokens can191+ all = append(all, cached.Models...)
188- // also list models via the bearer header, but we skip OAuth192+ } else {
189- // here to avoid surprise rate-limit hits on subscription keys.193+ if cred, method, err := resolveCredentialForBackground(ctx, "anthropic"); err == nil && method == "apikey" {
190- if live, err := provider.DiscoverAnthropic(ctx, cred, ""); err == nil {194+ // /v1/models on Anthropic is API-key only; OAuth tokens can
191- all = append(all, live...)195+ // also list models via the bearer header, but we skip OAuth
196+ // here to avoid surprise rate-limit hits on subscription keys.
197+ if live, err := provider.DiscoverAnthropic(ctx, cred, ""); err == nil {
198+ all = append(all, live...)
199+ }
192 }200 }
193- }201+ if cred, method, err := resolveCredentialForBackground(ctx, "openai"); err == nil && method == "apikey" {
194- if cred, method, err := resolveCredentialForBackground(ctx, "openai"); err == nil && method == "apikey" {202+ if live, err := provider.DiscoverOpenAI(ctx, cred, ""); err == nil {
195- if live, err := provider.DiscoverOpenAI(ctx, cred, ""); err == nil {203+ all = append(all, live...)
196- all = append(all, live...)204+ }
197 }205 }
198- }206+ if cred, method, err := resolveCredentialForBackground(ctx, "kimi"); err == nil && method == "apikey" {
199- if cred, method, err := resolveCredentialForBackground(ctx, "kimi"); err == nil && method == "apikey" {207+ if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil {
200- if live, err := provider.DiscoverOpenAI(ctx, cred, "https://api.kimi.com/coding/v1"); err == nil {208+ for i := range live {
201- for i := range live {209+ live[i].Provider = "kimi"
202- live[i].Provider = "kimi"210+ live[i].Source = "live"
203- live[i].Source = "live"211+ }
212+ all = append(all, live...)
204 }213 }
205- all = append(all, live...)
206 }214 }
207- }215+ if cred, method, err := resolveCredentialForBackground(ctx, "google"); err == nil && method == "apikey" {
208- if cred, method, err := resolveCredentialForBackground(ctx, "google"); err == nil && method == "apikey" {216+ if live, err := provider.DiscoverGoogle(ctx, cred, ""); err == nil {
209- if live, err := provider.DiscoverGoogle(ctx, cred, ""); err == nil {217+ all = append(all, live...)
210- all = append(all, live...)218+ }
211 }219 }
212- }220+ if haveOpenRouter {
213- if _, _, err := resolveCredentialForBackground(ctx, "openrouter"); err == nil {221+ // /models is public; gate on a credential so the picker only
214- // /models is public; gate on a credential so the picker only222+ // fills with OpenRouter's hundreds of routes for users who use it.
215- // fills with OpenRouter's hundreds of routes for users who use it.223+ if live, err := provider.DiscoverOpenRouter(ctx, ""); err == nil {
216- if live, err := provider.DiscoverOpenRouter(ctx, ""); err == nil {224+ all = append(all, live...)
217- all = append(all, live...)225+ }
226+ }
227+ if _, _, err := resolveCredentialForBackground(ctx, "gondola"); err == nil {
228+ // Gondola's catalog is also public. Gate discovery on a credential so
229+ // its text models only fill the picker for users of the provider.
230+ if live, err := provider.DiscoverGondola(ctx, ""); err == nil {
231+ all = append(all, live...)
232+ }
218 }233 }
219 }234 }
220- if _, _, err := resolveCredentialForBackground(ctx, "gondola"); err == nil {235+
221- // Gondola's catalog is also public. Gate discovery on a credential so236+ // Presets are per-account and require auth. Fetch them even when the
222- // its text models only fill the picker for users of the provider.237+ // public /models cache is still fresh so @preset/{slug} shows up in
223- if live, err := provider.DiscoverGondola(ctx, ""); err == nil {238+ // the picker without waiting out CacheTTL.
224- all = append(all, live...)239+ if haveOpenRouter {
240+ if presets, err := provider.DiscoverOpenRouterPresets(ctx, openrouterCred, ""); err == nil {
241+ all = mergeOpenRouterPresets(all, presets)
225 }242 }
226 }243 }
227 244
@@ -230,7 +247,27 @@ func refreshModels() {
230 }247 }
231 provider.SetLiveModels(all)248 provider.SetLiveModels(all)
232 _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{249 _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{
233- FetchedAt: time.Now().UTC(),250+ FetchedAt: modelCacheFetchedAt(cached, !cacheFresh, time.Now().UTC()),
234 Models: all,251 Models: all,
235 })252 })
236 }253 }
254+
255+func modelCacheFetchedAt(cached provider.ModelCache, catalogRefreshed bool, now time.Time) time.Time {
256+ if catalogRefreshed {
257+ return now
258+ }
259+ return cached.FetchedAt
260+}
261+
262+// mergeOpenRouterPresets replaces any previously cached OpenRouter preset
263+// models with the freshly listed set, keeping every non-preset entry.
264+func mergeOpenRouterPresets(existing, presets []provider.Model) []provider.Model {
265+ out := make([]provider.Model, 0, len(existing)+len(presets))
266+ for _, m := range existing {
267+ if m.Provider == "openrouter" && strings.HasPrefix(m.ID, "@preset/") {
268+ continue
269+ }
270+ out = append(out, m)
271+ }
272+ return append(out, presets...)
273+}
M packages/agent/modelsync_test.go +39 -0
@@ -9,6 +9,7 @@ import (
99 "os"
1010 "path/filepath"
1111 "testing"
12+ "time"
1213
1314 "github.com/patriceckhart/zot/packages/provider"
1415 )
@@ -140,6 +141,44 @@ func TestValidateAndRepairConfig_DuplicateModelIDValidForConfiguredProvider(t *t
140141 }
141142 }
142143
144+func TestModelCacheFetchedAtPreservesCatalogFreshness(t *testing.T) {
145+ cachedAt := time.Date(2026, time.August, 30, 12, 0, 0, 0, time.UTC)
146+ now := cachedAt.Add(time.Hour)
147+ cached := provider.ModelCache{FetchedAt: cachedAt}
148+
149+ if got := modelCacheFetchedAt(cached, false, now); !got.Equal(cachedAt) {
150+ t.Fatalf("fresh cache timestamp = %v; want %v", got, cachedAt)
151+ }
152+ if got := modelCacheFetchedAt(cached, true, now); !got.Equal(now) {
153+ t.Fatalf("refreshed cache timestamp = %v; want %v", got, now)
154+ }
155+}
156+
157+func TestMergeOpenRouterPresetsReplacesCachedPresets(t *testing.T) {
158+ existing := []provider.Model{
159+ {Provider: "openrouter", ID: "deepseek/deepseek-v4-flash"},
160+ {Provider: "openrouter", ID: "@preset/old"},
161+ {Provider: "anthropic", ID: "claude-sonnet-4-5"},
162+ }
163+ presets := []provider.Model{
164+ {Provider: "openrouter", ID: "@preset/flash", DisplayName: "Flash (preset)", ContextWindow: 1000000},
165+ }
166+ out := mergeOpenRouterPresets(existing, presets)
167+ if len(out) != 3 {
168+ t.Fatalf("got %d models; want 3", len(out))
169+ }
170+ var ids []string
171+ for _, m := range out {
172+ ids = append(ids, m.Provider+"/"+m.ID)
173+ }
174+ want := []string{"openrouter/deepseek/deepseek-v4-flash", "anthropic/claude-sonnet-4-5", "openrouter/@preset/flash"}
175+ for i, id := range want {
176+ if ids[i] != id {
177+ t.Errorf("ids[%d] = %q; want %q", i, ids[i], id)
178+ }
179+ }
180+}
181+
143182 func TestValidateAndRepairConfig_OpenRouterPreservesRoutedModelID(t *testing.T) {
144183 home := t.TempDir()
145184 t.Setenv("ZOT_HOME", home)
@@ -9,6 +9,7 @@ import (
9 "os"9 "os"
10 "path/filepath"10 "path/filepath"
11 "testing"11 "testing"
12+ "time"
12 13
13 "github.com/patriceckhart/zot/packages/provider"14 "github.com/patriceckhart/zot/packages/provider"
14 )15 )
@@ -140,6 +141,44 @@ func TestValidateAndRepairConfig_DuplicateModelIDValidForConfiguredProvider(t *t
140 }141 }
141 }142 }
142 143
144+func TestModelCacheFetchedAtPreservesCatalogFreshness(t *testing.T) {
145+ cachedAt := time.Date(2026, time.August, 30, 12, 0, 0, 0, time.UTC)
146+ now := cachedAt.Add(time.Hour)
147+ cached := provider.ModelCache{FetchedAt: cachedAt}
148+
149+ if got := modelCacheFetchedAt(cached, false, now); !got.Equal(cachedAt) {
150+ t.Fatalf("fresh cache timestamp = %v; want %v", got, cachedAt)
151+ }
152+ if got := modelCacheFetchedAt(cached, true, now); !got.Equal(now) {
153+ t.Fatalf("refreshed cache timestamp = %v; want %v", got, now)
154+ }
155+}
156+
157+func TestMergeOpenRouterPresetsReplacesCachedPresets(t *testing.T) {
158+ existing := []provider.Model{
159+ {Provider: "openrouter", ID: "deepseek/deepseek-v4-flash"},
160+ {Provider: "openrouter", ID: "@preset/old"},
161+ {Provider: "anthropic", ID: "claude-sonnet-4-5"},
162+ }
163+ presets := []provider.Model{
164+ {Provider: "openrouter", ID: "@preset/flash", DisplayName: "Flash (preset)", ContextWindow: 1000000},
165+ }
166+ out := mergeOpenRouterPresets(existing, presets)
167+ if len(out) != 3 {
168+ t.Fatalf("got %d models; want 3", len(out))
169+ }
170+ var ids []string
171+ for _, m := range out {
172+ ids = append(ids, m.Provider+"/"+m.ID)
173+ }
174+ want := []string{"openrouter/deepseek/deepseek-v4-flash", "anthropic/claude-sonnet-4-5", "openrouter/@preset/flash"}
175+ for i, id := range want {
176+ if ids[i] != id {
177+ t.Errorf("ids[%d] = %q; want %q", i, ids[i], id)
178+ }
179+ }
180+}
181+
143 func TestValidateAndRepairConfig_OpenRouterPreservesRoutedModelID(t *testing.T) {182 func TestValidateAndRepairConfig_OpenRouterPreservesRoutedModelID(t *testing.T) {
144 home := t.TempDir()183 home := t.TempDir()
145 t.Setenv("ZOT_HOME", home)184 t.Setenv("ZOT_HOME", home)
M packages/agent/modes/interactive.go +74 -0
@@ -72,6 +72,13 @@ type InteractiveConfig struct {
7272 // The current sandbox may differ after a session-scoped /jail or /unjail.
7373 JailByDefault *bool
7474
75+ // OpenRouterServerToolsEnabled mirrors the persisted preference.
76+ // nil means the default, off.
77+ OpenRouterServerToolsEnabled *bool
78+
79+ // NoTools preserves the launch-time promise that all tools stay disabled.
80+ NoTools bool
81+
7582 // RecursiveFileSuggest mirrors the persisted recursive_file_suggest
7683 // flag at startup. When true the @-mention picker fuzzy-searches the
7784 // whole project tree instead of browsing one directory at a time.
@@ -334,6 +341,7 @@ type SettingsStore interface {
334341 SetInlineImages(enabled bool) error
335342 SetAutoSwarm(enabled bool) error
336343 SetJailByDefault(enabled bool) error
344+ SetOpenRouterServerTools(enabled bool) error
337345 SetRecursiveFileSuggest(enabled bool) error
338346 SetRespectGitignore(enabled bool) error
339347 SetCompactMode(enabled bool) error
@@ -678,6 +686,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
678686 if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {
679687 i.applyAutoSwarmTool(true)
680688 }
689+ i.applyOpenRouterServerTools(i.openRouterServerToolsEnabled())
681690 return i
682691 }
683692
@@ -3383,6 +3392,19 @@ func (i *Interactive) openSettingsDialog() {
33833392 }
33843393
33853394 jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault
3395+ openRouterServerTools := i.openRouterServerToolsEnabled()
3396+ openRouterServerToolsDisabled := true
3397+ openRouterServerToolsHint := ""
3398+ switch {
3399+ case i.cfg.Provider != "openrouter":
3400+ openRouterServerToolsHint = "only available when the current provider is OpenRouter"
3401+ case i.cfg.NoTools:
3402+ openRouterServerToolsHint = "disabled for this run by --no-tools"
3403+ case strings.Contains(strings.ToLower(i.cfg.Model), "@preset/"):
3404+ openRouterServerToolsHint = "this preset owns its server-tool configuration"
3405+ default:
3406+ openRouterServerToolsDisabled = false
3407+ }
33863408 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
33873409 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
33883410 compactMode := i.compactModeEnabled()
@@ -3501,6 +3523,14 @@ func (i *Interactive) openSettingsDialog() {
35013523 desc: "confine tools to the session working directory unless /unjail is used",
35023524 value: jailByDefault,
35033525 },
3526+ {
3527+ key: "openrouter_server_tools_enabled",
3528+ label: "OpenRouter Server Tools",
3529+ desc: "advertise beta remote tools such as web search, datetime, and advisor; additional charges may apply",
3530+ value: openRouterServerTools && !openRouterServerToolsDisabled,
3531+ disabled: openRouterServerToolsDisabled,
3532+ hint: openRouterServerToolsHint,
3533+ },
35043534 {
35053535 key: "recursive_file_suggest",
35063536 label: "recursive @-file search",
@@ -3861,6 +3891,22 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
38613891 i.statusOK = "jail by default " + onOff(value)
38623892 i.statusErr = ""
38633893 i.mu.Unlock()
3894+ case "openrouter_server_tools_enabled":
3895+ val := value
3896+ i.cfg.OpenRouterServerToolsEnabled = &val
3897+ if i.cfg.SettingsStore != nil {
3898+ if err := i.cfg.SettingsStore.SetOpenRouterServerTools(value); err != nil {
3899+ i.mu.Lock()
3900+ i.statusErr = "settings: " + err.Error()
3901+ i.mu.Unlock()
3902+ return
3903+ }
3904+ }
3905+ i.applyOpenRouterServerTools(value)
3906+ i.mu.Lock()
3907+ i.statusOK = "OpenRouter server tools " + onOff(value)
3908+ i.statusErr = ""
3909+ i.mu.Unlock()
38643910 case "recursive_file_suggest":
38653911 val := value
38663912 i.cfg.RecursiveFileSuggest = &val
@@ -5365,6 +5411,7 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string)
53655411 // helpers are no-ops when their feature is inactive, so the
53665412 // cross-provider path still works on a vanilla setup.
53675413 i.applyAutoSwarmTool(i.autoSwarmEnabled())
5414+ i.applyOpenRouterServerTools(i.openRouterServerToolsEnabled())
53685415 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
53695416 if i.cfg.PersistModel != nil {
53705417 i.cfg.PersistModel(p, md)
@@ -5394,6 +5441,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
53945441 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
53955442 i.mu.Unlock()
53965443 i.applyAutoSwarmTool(i.autoSwarmEnabled())
5444+ i.applyOpenRouterServerTools(i.openRouterServerToolsEnabled())
53975445 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
53985446 i.dialog.ShowResult(true, "")
53995447 }
@@ -6575,6 +6623,32 @@ func (i *Interactive) autoSwarmEnabled() bool {
65756623 return i.cfg.AutoSwarmEnabled != nil && *i.cfg.AutoSwarmEnabled
65766624 }
65776625
6626+func (i *Interactive) openRouterServerToolsEnabled() bool {
6627+ return i.cfg.OpenRouterServerToolsEnabled != nil && *i.cfg.OpenRouterServerToolsEnabled
6628+}
6629+
6630+func (i *Interactive) applyOpenRouterServerTools(active bool) {
6631+ if i.agent == nil {
6632+ return
6633+ }
6634+ current := i.agent.Tools
6635+ next := core.Registry{}
6636+ for name, t := range current {
6637+ if tools.IsOpenRouterServerTool(name) {
6638+ continue
6639+ }
6640+ next[name] = t
6641+ }
6642+ i.agent.MaxToolCalls = 0
6643+ if active && !i.cfg.NoTools && i.cfg.Provider == "openrouter" && !strings.Contains(strings.ToLower(i.cfg.Model), "@preset/") {
6644+ for _, t := range tools.OpenRouterServerTools() {
6645+ next[t.Name()] = t
6646+ }
6647+ i.agent.MaxToolCalls = tools.OpenRouterServerToolCallLimit
6648+ }
6649+ i.agent.SetTools(next)
6650+}
6651+
65786652 func (i *Interactive) applyAutoSwarmTool(active bool) {
65796653 if i.agent == nil {
65806654 return
@@ -72,6 +72,13 @@ type InteractiveConfig struct {
72 // The current sandbox may differ after a session-scoped /jail or /unjail.72 // The current sandbox may differ after a session-scoped /jail or /unjail.
73 JailByDefault *bool73 JailByDefault *bool
74 74
75+ // OpenRouterServerToolsEnabled mirrors the persisted preference.
76+ // nil means the default, off.
77+ OpenRouterServerToolsEnabled *bool
78+
79+ // NoTools preserves the launch-time promise that all tools stay disabled.
80+ NoTools bool
81+
75 // RecursiveFileSuggest mirrors the persisted recursive_file_suggest82 // RecursiveFileSuggest mirrors the persisted recursive_file_suggest
76 // flag at startup. When true the @-mention picker fuzzy-searches the83 // flag at startup. When true the @-mention picker fuzzy-searches the
77 // whole project tree instead of browsing one directory at a time.84 // whole project tree instead of browsing one directory at a time.
@@ -334,6 +341,7 @@ type SettingsStore interface {
334 SetInlineImages(enabled bool) error341 SetInlineImages(enabled bool) error
335 SetAutoSwarm(enabled bool) error342 SetAutoSwarm(enabled bool) error
336 SetJailByDefault(enabled bool) error343 SetJailByDefault(enabled bool) error
344+ SetOpenRouterServerTools(enabled bool) error
337 SetRecursiveFileSuggest(enabled bool) error345 SetRecursiveFileSuggest(enabled bool) error
338 SetRespectGitignore(enabled bool) error346 SetRespectGitignore(enabled bool) error
339 SetCompactMode(enabled bool) error347 SetCompactMode(enabled bool) error
@@ -678,6 +686,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
678 if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {686 if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {
679 i.applyAutoSwarmTool(true)687 i.applyAutoSwarmTool(true)
680 }688 }
689+ i.applyOpenRouterServerTools(i.openRouterServerToolsEnabled())
681 return i690 return i
682 }691 }
683 692
@@ -3383,6 +3392,19 @@ func (i *Interactive) openSettingsDialog() {
3383 }3392 }
3384 3393
3385 jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault3394 jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault
3395+ openRouterServerTools := i.openRouterServerToolsEnabled()
3396+ openRouterServerToolsDisabled := true
3397+ openRouterServerToolsHint := ""
3398+ switch {
3399+ case i.cfg.Provider != "openrouter":
3400+ openRouterServerToolsHint = "only available when the current provider is OpenRouter"
3401+ case i.cfg.NoTools:
3402+ openRouterServerToolsHint = "disabled for this run by --no-tools"
3403+ case strings.Contains(strings.ToLower(i.cfg.Model), "@preset/"):
3404+ openRouterServerToolsHint = "this preset owns its server-tool configuration"
3405+ default:
3406+ openRouterServerToolsDisabled = false
3407+ }
3386 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest3408 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
3387 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore3409 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
3388 compactMode := i.compactModeEnabled()3410 compactMode := i.compactModeEnabled()
@@ -3501,6 +3523,14 @@ func (i *Interactive) openSettingsDialog() {
3501 desc: "confine tools to the session working directory unless /unjail is used",3523 desc: "confine tools to the session working directory unless /unjail is used",
3502 value: jailByDefault,3524 value: jailByDefault,
3503 },3525 },
3526+ {
3527+ key: "openrouter_server_tools_enabled",
3528+ label: "OpenRouter Server Tools",
3529+ desc: "advertise beta remote tools such as web search, datetime, and advisor; additional charges may apply",
3530+ value: openRouterServerTools && !openRouterServerToolsDisabled,
3531+ disabled: openRouterServerToolsDisabled,
3532+ hint: openRouterServerToolsHint,
3533+ },
3504 {3534 {
3505 key: "recursive_file_suggest",3535 key: "recursive_file_suggest",
3506 label: "recursive @-file search",3536 label: "recursive @-file search",
@@ -3861,6 +3891,22 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
3861 i.statusOK = "jail by default " + onOff(value)3891 i.statusOK = "jail by default " + onOff(value)
3862 i.statusErr = ""3892 i.statusErr = ""
3863 i.mu.Unlock()3893 i.mu.Unlock()
3894+ case "openrouter_server_tools_enabled":
3895+ val := value
3896+ i.cfg.OpenRouterServerToolsEnabled = &val
3897+ if i.cfg.SettingsStore != nil {
3898+ if err := i.cfg.SettingsStore.SetOpenRouterServerTools(value); err != nil {
3899+ i.mu.Lock()
3900+ i.statusErr = "settings: " + err.Error()
3901+ i.mu.Unlock()
3902+ return
3903+ }
3904+ }
3905+ i.applyOpenRouterServerTools(value)
3906+ i.mu.Lock()
3907+ i.statusOK = "OpenRouter server tools " + onOff(value)
3908+ i.statusErr = ""
3909+ i.mu.Unlock()
3864 case "recursive_file_suggest":3910 case "recursive_file_suggest":
3865 val := value3911 val := value
3866 i.cfg.RecursiveFileSuggest = &val3912 i.cfg.RecursiveFileSuggest = &val
@@ -5365,6 +5411,7 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string)
5365 // helpers are no-ops when their feature is inactive, so the5411 // helpers are no-ops when their feature is inactive, so the
5366 // cross-provider path still works on a vanilla setup.5412 // cross-provider path still works on a vanilla setup.
5367 i.applyAutoSwarmTool(i.autoSwarmEnabled())5413 i.applyAutoSwarmTool(i.autoSwarmEnabled())
5414+ i.applyOpenRouterServerTools(i.openRouterServerToolsEnabled())
5368 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())5415 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
5369 if i.cfg.PersistModel != nil {5416 if i.cfg.PersistModel != nil {
5370 i.cfg.PersistModel(p, md)5417 i.cfg.PersistModel(p, md)
@@ -5394,6 +5441,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
5394 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method5441 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
5395 i.mu.Unlock()5442 i.mu.Unlock()
5396 i.applyAutoSwarmTool(i.autoSwarmEnabled())5443 i.applyAutoSwarmTool(i.autoSwarmEnabled())
5444+ i.applyOpenRouterServerTools(i.openRouterServerToolsEnabled())
5397 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())5445 i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
5398 i.dialog.ShowResult(true, "")5446 i.dialog.ShowResult(true, "")
5399 }5447 }
@@ -6575,6 +6623,32 @@ func (i *Interactive) autoSwarmEnabled() bool {
6575 return i.cfg.AutoSwarmEnabled != nil && *i.cfg.AutoSwarmEnabled6623 return i.cfg.AutoSwarmEnabled != nil && *i.cfg.AutoSwarmEnabled
6576 }6624 }
6577 6625
6626+func (i *Interactive) openRouterServerToolsEnabled() bool {
6627+ return i.cfg.OpenRouterServerToolsEnabled != nil && *i.cfg.OpenRouterServerToolsEnabled
6628+}
6629+
6630+func (i *Interactive) applyOpenRouterServerTools(active bool) {
6631+ if i.agent == nil {
6632+ return
6633+ }
6634+ current := i.agent.Tools
6635+ next := core.Registry{}
6636+ for name, t := range current {
6637+ if tools.IsOpenRouterServerTool(name) {
6638+ continue
6639+ }
6640+ next[name] = t
6641+ }
6642+ i.agent.MaxToolCalls = 0
6643+ if active && !i.cfg.NoTools && i.cfg.Provider == "openrouter" && !strings.Contains(strings.ToLower(i.cfg.Model), "@preset/") {
6644+ for _, t := range tools.OpenRouterServerTools() {
6645+ next[t.Name()] = t
6646+ }
6647+ i.agent.MaxToolCalls = tools.OpenRouterServerToolCallLimit
6648+ }
6649+ i.agent.SetTools(next)
6650+}
6651+
6578 func (i *Interactive) applyAutoSwarmTool(active bool) {6652 func (i *Interactive) applyAutoSwarmTool(active bool) {
6579 if i.agent == nil {6653 if i.agent == nil {
6580 return6654 return
M packages/agent/modes/interactive_model_test.go +13 -0
@@ -81,6 +81,19 @@ func TestReasoningCommandOpensDirectSelector(t *testing.T) {
8181 }
8282 }
8383
84+func TestNormalizeModelQueryKeepsPresetAtSign(t *testing.T) {
85+ if got := normalizeModelQuery("@preset/flash"); got != "@presetflash" {
86+ t.Fatalf("id = %q; want @presetflash", got)
87+ }
88+ if got := normalizeModelQuery("preset"); got != "preset" {
89+ t.Fatalf("query = %q; want preset", got)
90+ }
91+ haystack := normalizeModelQuery("openrouter @preset/flash Flash (preset)")
92+ if !strings.Contains(haystack, normalizeModelQuery("preset")) {
93+ t.Fatalf("%q does not contain preset", haystack)
94+ }
95+}
96+
8497 func TestModelDialogAdvertisesReasoningSelector(t *testing.T) {
8598 d := newModelDialog()
8699 d.Open("", nil, "high")
@@ -81,6 +81,19 @@ func TestReasoningCommandOpensDirectSelector(t *testing.T) {
81 }81 }
82 }82 }
83 83
84+func TestNormalizeModelQueryKeepsPresetAtSign(t *testing.T) {
85+ if got := normalizeModelQuery("@preset/flash"); got != "@presetflash" {
86+ t.Fatalf("id = %q; want @presetflash", got)
87+ }
88+ if got := normalizeModelQuery("preset"); got != "preset" {
89+ t.Fatalf("query = %q; want preset", got)
90+ }
91+ haystack := normalizeModelQuery("openrouter @preset/flash Flash (preset)")
92+ if !strings.Contains(haystack, normalizeModelQuery("preset")) {
93+ t.Fatalf("%q does not contain preset", haystack)
94+ }
95+}
96+
84 func TestModelDialogAdvertisesReasoningSelector(t *testing.T) {97 func TestModelDialogAdvertisesReasoningSelector(t *testing.T) {
85 d := newModelDialog()98 d := newModelDialog()
86 d.Open("", nil, "high")99 d.Open("", nil, "high")
M packages/agent/modes/model_dialog.go +3 -2
@@ -121,13 +121,14 @@ func sortedModels(in []provider.Model) []provider.Model {
121121
122122 // normalizeModelQuery lowercases and strips punctuation so fuzzy
123123 // substring matching works on both the query and haystacks. "opus46"
124-// and "opus-4-6" both become "opus46".
124+// and "opus-4-6" both become "opus46". "@" is kept so typing "preset"
125+// or "@preset" matches OpenRouter ids like "@preset/flash".
125126 func normalizeModelQuery(s string) string {
126127 var sb strings.Builder
127128 sb.Grow(len(s))
128129 for _, r := range s {
129130 switch {
130- case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
131+ case r == '@', r >= 'a' && r <= 'z', r >= '0' && r <= '9':
131132 sb.WriteRune(r)
132133 case r >= 'A' && r <= 'Z':
133134 sb.WriteRune(r + ('a' - 'A'))
@@ -121,13 +121,14 @@ func sortedModels(in []provider.Model) []provider.Model {
121 121
122 // normalizeModelQuery lowercases and strips punctuation so fuzzy122 // normalizeModelQuery lowercases and strips punctuation so fuzzy
123 // substring matching works on both the query and haystacks. "opus46"123 // substring matching works on both the query and haystacks. "opus46"
124-// and "opus-4-6" both become "opus46".124+// and "opus-4-6" both become "opus46". "@" is kept so typing "preset"
125+// or "@preset" matches OpenRouter ids like "@preset/flash".
125 func normalizeModelQuery(s string) string {126 func normalizeModelQuery(s string) string {
126 var sb strings.Builder127 var sb strings.Builder
127 sb.Grow(len(s))128 sb.Grow(len(s))
128 for _, r := range s {129 for _, r := range s {
129 switch {130 switch {
130- case r >= 'a' && r <= 'z', r >= '0' && r <= '9':131+ case r == '@', r >= 'a' && r <= 'z', r >= '0' && r <= '9':
131 sb.WriteRune(r)132 sb.WriteRune(r)
132 case r >= 'A' && r <= 'Z':133 case r >= 'A' && r <= 'Z':
133 sb.WriteRune(r + ('a' - 'A'))134 sb.WriteRune(r + ('a' - 'A'))
A packages/agent/modes/openrouter_server_tools_test.go +75 -0
@@ -0,0 +1,75 @@
1+package modes
2+
3+import (
4+ "testing"
5+
6+ agenttools "github.com/patriceckhart/zot/packages/agent/tools"
7+ "github.com/patriceckhart/zot/packages/core"
8+)
9+
10+func TestOpenRouterServerToolsDefaultOff(t *testing.T) {
11+ agent := core.NewAgent(nil, "openai/gpt-4.1", "", core.Registry{})
12+ NewInteractive(InteractiveConfig{
13+ Agent: agent,
14+ Provider: "openrouter",
15+ Model: "openai/gpt-4.1",
16+ })
17+ if _, err := agent.Tools.Get("openrouter:web_search"); err == nil {
18+ t.Fatal("server tools enabled without explicit opt-in")
19+ }
20+ if agent.MaxToolCalls != 0 {
21+ t.Fatalf("MaxToolCalls = %d; want 0", agent.MaxToolCalls)
22+ }
23+}
24+
25+func TestOpenRouterServerToolsOptInSetsSafeLimit(t *testing.T) {
26+ enabled := true
27+ agent := core.NewAgent(nil, "openai/gpt-4.1", "", core.Registry{})
28+ NewInteractive(InteractiveConfig{
29+ Agent: agent,
30+ Provider: "openrouter",
31+ Model: "openai/gpt-4.1",
32+ OpenRouterServerToolsEnabled: &enabled,
33+ })
34+ if _, err := agent.Tools.Get("openrouter:web_search"); err != nil {
35+ t.Fatalf("enabled server tool missing: %v", err)
36+ }
37+ if agent.MaxToolCalls != agenttools.OpenRouterServerToolCallLimit {
38+ t.Fatalf("MaxToolCalls = %d; want %d", agent.MaxToolCalls, agenttools.OpenRouterServerToolCallLimit)
39+ }
40+}
41+
42+func TestOpenRouterServerToolsRespectNoTools(t *testing.T) {
43+ enabled := true
44+ agent := core.NewAgent(nil, "openai/gpt-4.1", "", core.Registry{})
45+ NewInteractive(InteractiveConfig{
46+ Agent: agent,
47+ Provider: "openrouter",
48+ Model: "openai/gpt-4.1",
49+ OpenRouterServerToolsEnabled: &enabled,
50+ NoTools: true,
51+ })
52+ if len(agent.Tools) != 0 {
53+ t.Fatalf("--no-tools registry has %d tools; want none", len(agent.Tools))
54+ }
55+ if agent.MaxToolCalls != 0 {
56+ t.Fatalf("MaxToolCalls = %d; want 0", agent.MaxToolCalls)
57+ }
58+}
59+
60+func TestOpenRouterPresetKeepsPresetToolConfiguration(t *testing.T) {
61+ enabled := true
62+ agent := core.NewAgent(nil, "@preset/flash", "", core.Registry{})
63+ NewInteractive(InteractiveConfig{
64+ Agent: agent,
65+ Provider: "openrouter",
66+ Model: "@preset/flash",
67+ OpenRouterServerToolsEnabled: &enabled,
68+ })
69+ if _, err := agent.Tools.Get("openrouter:web_search"); err == nil {
70+ t.Fatal("request tools would override tools configured by the preset")
71+ }
72+ if agent.MaxToolCalls != 0 {
73+ t.Fatalf("MaxToolCalls = %d; want preset-owned default", agent.MaxToolCalls)
74+ }
75+}
@@ -0,0 +1,75 @@
1+package modes
2+
3+import (
4+ "testing"
5+
6+ agenttools "github.com/patriceckhart/zot/packages/agent/tools"
7+ "github.com/patriceckhart/zot/packages/core"
8+)
9+
10+func TestOpenRouterServerToolsDefaultOff(t *testing.T) {
11+ agent := core.NewAgent(nil, "openai/gpt-4.1", "", core.Registry{})
12+ NewInteractive(InteractiveConfig{
13+ Agent: agent,
14+ Provider: "openrouter",
15+ Model: "openai/gpt-4.1",
16+ })
17+ if _, err := agent.Tools.Get("openrouter:web_search"); err == nil {
18+ t.Fatal("server tools enabled without explicit opt-in")
19+ }
20+ if agent.MaxToolCalls != 0 {
21+ t.Fatalf("MaxToolCalls = %d; want 0", agent.MaxToolCalls)
22+ }
23+}
24+
25+func TestOpenRouterServerToolsOptInSetsSafeLimit(t *testing.T) {
26+ enabled := true
27+ agent := core.NewAgent(nil, "openai/gpt-4.1", "", core.Registry{})
28+ NewInteractive(InteractiveConfig{
29+ Agent: agent,
30+ Provider: "openrouter",
31+ Model: "openai/gpt-4.1",
32+ OpenRouterServerToolsEnabled: &enabled,
33+ })
34+ if _, err := agent.Tools.Get("openrouter:web_search"); err != nil {
35+ t.Fatalf("enabled server tool missing: %v", err)
36+ }
37+ if agent.MaxToolCalls != agenttools.OpenRouterServerToolCallLimit {
38+ t.Fatalf("MaxToolCalls = %d; want %d", agent.MaxToolCalls, agenttools.OpenRouterServerToolCallLimit)
39+ }
40+}
41+
42+func TestOpenRouterServerToolsRespectNoTools(t *testing.T) {
43+ enabled := true
44+ agent := core.NewAgent(nil, "openai/gpt-4.1", "", core.Registry{})
45+ NewInteractive(InteractiveConfig{
46+ Agent: agent,
47+ Provider: "openrouter",
48+ Model: "openai/gpt-4.1",
49+ OpenRouterServerToolsEnabled: &enabled,
50+ NoTools: true,
51+ })
52+ if len(agent.Tools) != 0 {
53+ t.Fatalf("--no-tools registry has %d tools; want none", len(agent.Tools))
54+ }
55+ if agent.MaxToolCalls != 0 {
56+ t.Fatalf("MaxToolCalls = %d; want 0", agent.MaxToolCalls)
57+ }
58+}
59+
60+func TestOpenRouterPresetKeepsPresetToolConfiguration(t *testing.T) {
61+ enabled := true
62+ agent := core.NewAgent(nil, "@preset/flash", "", core.Registry{})
63+ NewInteractive(InteractiveConfig{
64+ Agent: agent,
65+ Provider: "openrouter",
66+ Model: "@preset/flash",
67+ OpenRouterServerToolsEnabled: &enabled,
68+ })
69+ if _, err := agent.Tools.Get("openrouter:web_search"); err == nil {
70+ t.Fatal("request tools would override tools configured by the preset")
71+ }
72+ if agent.MaxToolCalls != 0 {
73+ t.Fatalf("MaxToolCalls = %d; want preset-owned default", agent.MaxToolCalls)
74+ }
75+}
M packages/agent/settings_store.go +9 -0
@@ -68,6 +68,15 @@ func (configSettingsStore) SetJailByDefault(enabled bool) error {
6868 return SaveConfig(cfg)
6969 }
7070
71+func (configSettingsStore) SetOpenRouterServerTools(enabled bool) error {
72+ cfg, err := LoadConfig()
73+ if err != nil {
74+ return err
75+ }
76+ cfg.OpenRouterServerToolsEnabled = &enabled
77+ return SaveConfig(cfg)
78+}
79+
7180 func (configSettingsStore) SetRecursiveFileSuggest(enabled bool) error {
7281 cfg, err := LoadConfig()
7382 if err != nil {
@@ -68,6 +68,15 @@ func (configSettingsStore) SetJailByDefault(enabled bool) error {
68 return SaveConfig(cfg)68 return SaveConfig(cfg)
69 }69 }
70 70
71+func (configSettingsStore) SetOpenRouterServerTools(enabled bool) error {
72+ cfg, err := LoadConfig()
73+ if err != nil {
74+ return err
75+ }
76+ cfg.OpenRouterServerToolsEnabled = &enabled
77+ return SaveConfig(cfg)
78+}
79+
71 func (configSettingsStore) SetRecursiveFileSuggest(enabled bool) error {80 func (configSettingsStore) SetRecursiveFileSuggest(enabled bool) error {
72 cfg, err := LoadConfig()81 cfg, err := LoadConfig()
73 if err != nil {82 if err != nil {
M packages/agent/settings_store_test.go +31 -0
@@ -23,6 +23,37 @@ func TestConfigSettingsStorePersistsShowInstructionsAtStartup(t *testing.T) {
2323 }
2424 }
2525
26+func TestConfigSettingsStorePersistsOpenRouterServerTools(t *testing.T) {
27+ t.Setenv("ZOT_HOME", t.TempDir())
28+ if err := SaveConfig(Config{Theme: "dark"}); err != nil {
29+ t.Fatal(err)
30+ }
31+
32+ if err := (configSettingsStore{}).SetOpenRouterServerTools(false); err != nil {
33+ t.Fatal(err)
34+ }
35+ cfg, err := LoadConfig()
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if cfg.OpenRouterServerToolsEnabled == nil || *cfg.OpenRouterServerToolsEnabled {
40+ t.Fatal("openrouter_server_tools_enabled was not persisted as disabled")
41+ }
42+ if cfg.Theme != "dark" {
43+ t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme)
44+ }
45+}
46+
47+func TestOpenRouterServerToolsEnabledDefaultsOff(t *testing.T) {
48+ t.Setenv("ZOT_HOME", t.TempDir())
49+ if err := SaveConfig(Config{}); err != nil {
50+ t.Fatal(err)
51+ }
52+ if OpenRouterServerToolsEnabled() {
53+ t.Fatal("nil preference should default to disabled")
54+ }
55+}
56+
2657 func TestConfigSettingsStorePersistsJailByDefault(t *testing.T) {
2758 t.Setenv("ZOT_HOME", t.TempDir())
2859 if err := SaveConfig(Config{Theme: "dark"}); err != nil {
@@ -23,6 +23,37 @@ func TestConfigSettingsStorePersistsShowInstructionsAtStartup(t *testing.T) {
23 }23 }
24 }24 }
25 25
26+func TestConfigSettingsStorePersistsOpenRouterServerTools(t *testing.T) {
27+ t.Setenv("ZOT_HOME", t.TempDir())
28+ if err := SaveConfig(Config{Theme: "dark"}); err != nil {
29+ t.Fatal(err)
30+ }
31+
32+ if err := (configSettingsStore{}).SetOpenRouterServerTools(false); err != nil {
33+ t.Fatal(err)
34+ }
35+ cfg, err := LoadConfig()
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if cfg.OpenRouterServerToolsEnabled == nil || *cfg.OpenRouterServerToolsEnabled {
40+ t.Fatal("openrouter_server_tools_enabled was not persisted as disabled")
41+ }
42+ if cfg.Theme != "dark" {
43+ t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme)
44+ }
45+}
46+
47+func TestOpenRouterServerToolsEnabledDefaultsOff(t *testing.T) {
48+ t.Setenv("ZOT_HOME", t.TempDir())
49+ if err := SaveConfig(Config{}); err != nil {
50+ t.Fatal(err)
51+ }
52+ if OpenRouterServerToolsEnabled() {
53+ t.Fatal("nil preference should default to disabled")
54+ }
55+}
56+
26 func TestConfigSettingsStorePersistsJailByDefault(t *testing.T) {57 func TestConfigSettingsStorePersistsJailByDefault(t *testing.T) {
27 t.Setenv("ZOT_HOME", t.TempDir())58 t.Setenv("ZOT_HOME", t.TempDir())
28 if err := SaveConfig(Config{Theme: "dark"}); err != nil {59 if err := SaveConfig(Config{Theme: "dark"}); err != nil {
A packages/agent/tools/openrouter_server.go +102 -0
@@ -0,0 +1,102 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+ "github.com/patriceckhart/zot/packages/provider"
10+)
11+
12+// OpenRouterServerToolCallLimit bounds the remote agent loop when zot
13+// advertises OpenRouter server tools.
14+const OpenRouterServerToolCallLimit = 4
15+
16+// OpenRouterServerToolNames lists every OpenRouter server tool that zot
17+// can advertise on Chat Completions. Shell, bash, apply_patch, and
18+// tool_search require Responses/Messages and are omitted here.
19+func OpenRouterServerToolNames() []string {
20+ return []string{
21+ "openrouter:web_search",
22+ "openrouter:web_fetch",
23+ "openrouter:advisor",
24+ "openrouter:subagent",
25+ "openrouter:fusion",
26+ "openrouter:image_generation",
27+ "openrouter:datetime",
28+ "openrouter:experimental__search_models",
29+ }
30+}
31+
32+// IsOpenRouterServerTool reports whether name is an OpenRouter server tool
33+// that zot may inject into the live registry.
34+func IsOpenRouterServerTool(name string) bool {
35+ for _, n := range OpenRouterServerToolNames() {
36+ if n == name {
37+ return true
38+ }
39+ }
40+ return false
41+}
42+
43+// OpenRouterServerTool is a stub advertised to the model so OpenRouter
44+// can execute the matching server tool. The agent never runs it locally.
45+type OpenRouterServerTool struct {
46+ name string
47+ description string
48+ schema json.RawMessage
49+}
50+
51+func (t *OpenRouterServerTool) Name() string { return t.name }
52+func (t *OpenRouterServerTool) Description() string { return t.description }
53+func (t *OpenRouterServerTool) Schema() json.RawMessage { return t.schema }
54+
55+func (t *OpenRouterServerTool) Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error) {
56+ return core.ToolResult{
57+ Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("%s is executed by OpenRouter, not locally", t.name)}},
58+ IsError: true,
59+ }, nil
60+}
61+
62+// OpenRouterServerTools returns stubs for every Chat Completions-capable
63+// OpenRouter server tool.
64+func OpenRouterServerTools() []core.Tool {
65+ return []core.Tool{
66+ &OpenRouterServerTool{
67+ name: "openrouter:web_search",
68+ description: "Search the web for current information. Executed by OpenRouter.",
69+ },
70+ &OpenRouterServerTool{
71+ name: "openrouter:web_fetch",
72+ description: "Fetch and extract content from a URL. Executed by OpenRouter.",
73+ },
74+ &OpenRouterServerTool{
75+ name: "openrouter:advisor",
76+ description: "Consult a stronger model for guidance mid-generation. Executed by OpenRouter.",
77+ schema: json.RawMessage(`{"model":"openai/gpt-4.1-mini"}`),
78+ },
79+ &OpenRouterServerTool{
80+ name: "openrouter:subagent",
81+ description: "Delegate a self-contained task to a smaller worker model. Executed by OpenRouter.",
82+ schema: json.RawMessage(`{"model":"openai/gpt-4.1-mini"}`),
83+ },
84+ &OpenRouterServerTool{
85+ name: "openrouter:fusion",
86+ description: "Run a panel of models and an analyst for multi-model analysis. Executed by OpenRouter.",
87+ },
88+ &OpenRouterServerTool{
89+ name: "openrouter:image_generation",
90+ description: "Generate an image from a text prompt. Executed by OpenRouter.",
91+ },
92+ &OpenRouterServerTool{
93+ name: "openrouter:datetime",
94+ description: "Get the current date and time. Executed by OpenRouter.",
95+ },
96+ &OpenRouterServerTool{
97+ name: "openrouter:experimental__search_models",
98+ description: "Search and filter the OpenRouter model catalog. Executed by OpenRouter.",
99+ schema: json.RawMessage(`{"max_results":5}`),
100+ },
101+ }
102+}
@@ -0,0 +1,102 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+ "github.com/patriceckhart/zot/packages/provider"
10+)
11+
12+// OpenRouterServerToolCallLimit bounds the remote agent loop when zot
13+// advertises OpenRouter server tools.
14+const OpenRouterServerToolCallLimit = 4
15+
16+// OpenRouterServerToolNames lists every OpenRouter server tool that zot
17+// can advertise on Chat Completions. Shell, bash, apply_patch, and
18+// tool_search require Responses/Messages and are omitted here.
19+func OpenRouterServerToolNames() []string {
20+ return []string{
21+ "openrouter:web_search",
22+ "openrouter:web_fetch",
23+ "openrouter:advisor",
24+ "openrouter:subagent",
25+ "openrouter:fusion",
26+ "openrouter:image_generation",
27+ "openrouter:datetime",
28+ "openrouter:experimental__search_models",
29+ }
30+}
31+
32+// IsOpenRouterServerTool reports whether name is an OpenRouter server tool
33+// that zot may inject into the live registry.
34+func IsOpenRouterServerTool(name string) bool {
35+ for _, n := range OpenRouterServerToolNames() {
36+ if n == name {
37+ return true
38+ }
39+ }
40+ return false
41+}
42+
43+// OpenRouterServerTool is a stub advertised to the model so OpenRouter
44+// can execute the matching server tool. The agent never runs it locally.
45+type OpenRouterServerTool struct {
46+ name string
47+ description string
48+ schema json.RawMessage
49+}
50+
51+func (t *OpenRouterServerTool) Name() string { return t.name }
52+func (t *OpenRouterServerTool) Description() string { return t.description }
53+func (t *OpenRouterServerTool) Schema() json.RawMessage { return t.schema }
54+
55+func (t *OpenRouterServerTool) Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error) {
56+ return core.ToolResult{
57+ Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("%s is executed by OpenRouter, not locally", t.name)}},
58+ IsError: true,
59+ }, nil
60+}
61+
62+// OpenRouterServerTools returns stubs for every Chat Completions-capable
63+// OpenRouter server tool.
64+func OpenRouterServerTools() []core.Tool {
65+ return []core.Tool{
66+ &OpenRouterServerTool{
67+ name: "openrouter:web_search",
68+ description: "Search the web for current information. Executed by OpenRouter.",
69+ },
70+ &OpenRouterServerTool{
71+ name: "openrouter:web_fetch",
72+ description: "Fetch and extract content from a URL. Executed by OpenRouter.",
73+ },
74+ &OpenRouterServerTool{
75+ name: "openrouter:advisor",
76+ description: "Consult a stronger model for guidance mid-generation. Executed by OpenRouter.",
77+ schema: json.RawMessage(`{"model":"openai/gpt-4.1-mini"}`),
78+ },
79+ &OpenRouterServerTool{
80+ name: "openrouter:subagent",
81+ description: "Delegate a self-contained task to a smaller worker model. Executed by OpenRouter.",
82+ schema: json.RawMessage(`{"model":"openai/gpt-4.1-mini"}`),
83+ },
84+ &OpenRouterServerTool{
85+ name: "openrouter:fusion",
86+ description: "Run a panel of models and an analyst for multi-model analysis. Executed by OpenRouter.",
87+ },
88+ &OpenRouterServerTool{
89+ name: "openrouter:image_generation",
90+ description: "Generate an image from a text prompt. Executed by OpenRouter.",
91+ },
92+ &OpenRouterServerTool{
93+ name: "openrouter:datetime",
94+ description: "Get the current date and time. Executed by OpenRouter.",
95+ },
96+ &OpenRouterServerTool{
97+ name: "openrouter:experimental__search_models",
98+ description: "Search and filter the OpenRouter model catalog. Executed by OpenRouter.",
99+ schema: json.RawMessage(`{"max_results":5}`),
100+ },
101+ }
102+}
A packages/agent/tools/openrouter_server_test.go +23 -0
@@ -0,0 +1,23 @@
1+package tools
2+
3+import "testing"
4+
5+func TestOpenRouterServerToolsHaveStableNames(t *testing.T) {
6+ got := map[string]bool{}
7+ for _, tool := range OpenRouterServerTools() {
8+ name := tool.Name()
9+ if !IsOpenRouterServerTool(name) {
10+ t.Errorf("%q is not listed in OpenRouterServerToolNames", name)
11+ }
12+ if got[name] {
13+ t.Errorf("duplicate server tool %q", name)
14+ }
15+ got[name] = true
16+ if tool.Description() == "" {
17+ t.Errorf("%q has empty description", name)
18+ }
19+ }
20+ if len(got) != len(OpenRouterServerToolNames()) {
21+ t.Fatalf("got %d tools; want %d", len(got), len(OpenRouterServerToolNames()))
22+ }
23+}
@@ -0,0 +1,23 @@
1+package tools
2+
3+import "testing"
4+
5+func TestOpenRouterServerToolsHaveStableNames(t *testing.T) {
6+ got := map[string]bool{}
7+ for _, tool := range OpenRouterServerTools() {
8+ name := tool.Name()
9+ if !IsOpenRouterServerTool(name) {
10+ t.Errorf("%q is not listed in OpenRouterServerToolNames", name)
11+ }
12+ if got[name] {
13+ t.Errorf("duplicate server tool %q", name)
14+ }
15+ got[name] = true
16+ if tool.Description() == "" {
17+ t.Errorf("%q has empty description", name)
18+ }
19+ }
20+ if len(got) != len(OpenRouterServerToolNames()) {
21+ t.Fatalf("got %d tools; want %d", len(got), len(OpenRouterServerToolNames()))
22+ }
23+}
M packages/core/agent.go +21 -7
@@ -31,6 +31,14 @@ type Agent struct {
3131 // responses aren't silently cut off with stopReason=length.
3232 MaxTokens int
3333
34+ // SessionID is the zot conversation id, forwarded to providers that
35+ // support sticky routing. Empty means omitted.
36+ SessionID string
37+
38+ // MaxToolCalls caps provider-executed server-tool calls per turn.
39+ // Zero leaves the field unset.
40+ MaxToolCalls int
41+
3442 // BeforeToolExecute, if set, is called immediately before each
3543 // tool runs. Returning (allowed=false, reason) short-circuits
3644 // the call with an error result containing reason. Optionally,
@@ -405,8 +413,12 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
405413 }
406414
407415 if stop == provider.StopToolUse {
408- // Execute each tool call, append a single tool-results message, continue.
416+ // Execute each client tool call, append a single tool-results message, continue.
409417 toolMsg, hadError := a.executeTools(ctx, assistantMsg, sink)
418+ if len(toolMsg.Content) == 0 {
419+ // Provider-executed (server) tools need no client results.
420+ continue
421+ }
410422 a.mu.Lock()
411423 a.messages = append(a.messages, toolMsg)
412424 a.rev++
@@ -562,11 +574,13 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.St
562574 // next in-process request is rejected by providers like Anthropic
563575 // with "tool_use ids were found without tool_result blocks". The
564576 // repair is pure and a no-op on already-valid transcripts.
565- Messages: repairToolUseResultPairs(a.Messages()),
566- Tools: a.Tools.Specs(),
567- Reasoning: a.Reasoning,
568- MaxTokens: a.MaxTokens,
569- Temperature: a.Temperature,
577+ Messages: repairToolUseResultPairs(a.Messages()),
578+ Tools: a.Tools.Specs(),
579+ Reasoning: a.Reasoning,
580+ MaxTokens: a.MaxTokens,
581+ Temperature: a.Temperature,
582+ SessionID: a.SessionID,
583+ MaxToolCalls: a.MaxToolCalls,
570584 }
571585 stream, err := a.Client.Stream(ctx, req)
572586 if err != nil {
@@ -675,7 +689,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
675689
676690 for _, c := range msg.Content {
677691 tc, ok := c.(provider.ToolCallBlock)
678- if !ok {
692+ if !ok || tc.Server {
679693 continue
680694 }
681695 res := a.runOneTool(ctx, tc, sink)
@@ -31,6 +31,14 @@ type Agent struct {
31 // responses aren't silently cut off with stopReason=length.31 // responses aren't silently cut off with stopReason=length.
32 MaxTokens int32 MaxTokens int
33 33
34+ // SessionID is the zot conversation id, forwarded to providers that
35+ // support sticky routing. Empty means omitted.
36+ SessionID string
37+
38+ // MaxToolCalls caps provider-executed server-tool calls per turn.
39+ // Zero leaves the field unset.
40+ MaxToolCalls int
41+
34 // BeforeToolExecute, if set, is called immediately before each42 // BeforeToolExecute, if set, is called immediately before each
35 // tool runs. Returning (allowed=false, reason) short-circuits43 // tool runs. Returning (allowed=false, reason) short-circuits
36 // the call with an error result containing reason. Optionally,44 // the call with an error result containing reason. Optionally,
@@ -405,8 +413,12 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
405 }413 }
406 414
407 if stop == provider.StopToolUse {415 if stop == provider.StopToolUse {
408- // Execute each tool call, append a single tool-results message, continue.416+ // Execute each client tool call, append a single tool-results message, continue.
409 toolMsg, hadError := a.executeTools(ctx, assistantMsg, sink)417 toolMsg, hadError := a.executeTools(ctx, assistantMsg, sink)
418+ if len(toolMsg.Content) == 0 {
419+ // Provider-executed (server) tools need no client results.
420+ continue
421+ }
410 a.mu.Lock()422 a.mu.Lock()
411 a.messages = append(a.messages, toolMsg)423 a.messages = append(a.messages, toolMsg)
412 a.rev++424 a.rev++
@@ -562,11 +574,13 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.St
562 // next in-process request is rejected by providers like Anthropic574 // next in-process request is rejected by providers like Anthropic
563 // with "tool_use ids were found without tool_result blocks". The575 // with "tool_use ids were found without tool_result blocks". The
564 // repair is pure and a no-op on already-valid transcripts.576 // repair is pure and a no-op on already-valid transcripts.
565- Messages: repairToolUseResultPairs(a.Messages()),577+ Messages: repairToolUseResultPairs(a.Messages()),
566- Tools: a.Tools.Specs(),578+ Tools: a.Tools.Specs(),
567- Reasoning: a.Reasoning,579+ Reasoning: a.Reasoning,
568- MaxTokens: a.MaxTokens,580+ MaxTokens: a.MaxTokens,
569- Temperature: a.Temperature,581+ Temperature: a.Temperature,
582+ SessionID: a.SessionID,
583+ MaxToolCalls: a.MaxToolCalls,
570 }584 }
571 stream, err := a.Client.Stream(ctx, req)585 stream, err := a.Client.Stream(ctx, req)
572 if err != nil {586 if err != nil {
@@ -675,7 +689,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
675 689
676 for _, c := range msg.Content {690 for _, c := range msg.Content {
677 tc, ok := c.(provider.ToolCallBlock)691 tc, ok := c.(provider.ToolCallBlock)
678- if !ok {692+ if !ok || tc.Server {
679 continue693 continue
680 }694 }
681 res := a.runOneTool(ctx, tc, sink)695 res := a.runOneTool(ctx, tc, sink)
M packages/core/agent_retry_test.go +13 -0
@@ -234,6 +234,19 @@ func TestAgentPropagatesMaxTokens(t *testing.T) {
234234 }
235235 }
236236
237+func TestAgentPropagatesMaxToolCalls(t *testing.T) {
238+ client := &captureClient{}
239+ a := NewAgent(client, "fake-model", "system", Registry{})
240+ a.MaxToolCalls = 4
241+
242+ if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
243+ t.Fatalf("Prompt returned %v", err)
244+ }
245+ if client.lastReq.MaxToolCalls != 4 {
246+ t.Fatalf("request MaxToolCalls = %d; want 4", client.lastReq.MaxToolCalls)
247+ }
248+}
249+
237250 func TestAgentPropagatesTemperature(t *testing.T) {
238251 client := &captureClient{}
239252 a := NewAgent(client, "fake-model", "system", Registry{})
@@ -234,6 +234,19 @@ func TestAgentPropagatesMaxTokens(t *testing.T) {
234 }234 }
235 }235 }
236 236
237+func TestAgentPropagatesMaxToolCalls(t *testing.T) {
238+ client := &captureClient{}
239+ a := NewAgent(client, "fake-model", "system", Registry{})
240+ a.MaxToolCalls = 4
241+
242+ if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
243+ t.Fatalf("Prompt returned %v", err)
244+ }
245+ if client.lastReq.MaxToolCalls != 4 {
246+ t.Fatalf("request MaxToolCalls = %d; want 4", client.lastReq.MaxToolCalls)
247+ }
248+}
249+
237 func TestAgentPropagatesTemperature(t *testing.T) {250 func TestAgentPropagatesTemperature(t *testing.T) {
238 client := &captureClient{}251 client := &captureClient{}
239 a := NewAgent(client, "fake-model", "system", Registry{})252 a := NewAgent(client, "fake-model", "system", Registry{})
M packages/core/compact.go +1 -0
@@ -51,6 +51,7 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin
5151 System: summarizationSystem,
5252 MaxTokens: 4096,
5353 Temperature: a.Temperature,
54+ SessionID: a.SessionID,
5455 Messages: []provider.Message{
5556 {
5657 Role: provider.RoleUser,
@@ -51,6 +51,7 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin
51 System: summarizationSystem,51 System: summarizationSystem,
52 MaxTokens: 4096,52 MaxTokens: 4096,
53 Temperature: a.Temperature,53 Temperature: a.Temperature,
54+ SessionID: a.SessionID,
54 Messages: []provider.Message{55 Messages: []provider.Message{
55 {56 {
56 Role: provider.RoleUser,57 Role: provider.RoleUser,
M packages/core/intercept_test.go +20 -0
@@ -76,6 +76,26 @@ func TestBeforeToolExecuteInvalidJSONIgnored(t *testing.T) {
7676 }
7777 }
7878
79+func TestExecuteToolsSkipsServerToolCalls(t *testing.T) {
80+ rec := &recordingTool{}
81+ a := NewAgent(nil, "test", "", Registry{"echo": rec})
82+ msg, hadError := a.executeTools(context.Background(), provider.Message{
83+ Role: provider.RoleAssistant,
84+ Content: []provider.Content{
85+ provider.ToolCallBlock{ID: "srv", Name: "openrouter:datetime", Server: true},
86+ },
87+ }, func(AgentEvent) {})
88+ if hadError {
89+ t.Fatal("server tool produced error")
90+ }
91+ if len(msg.Content) != 0 {
92+ t.Fatalf("server tools must not produce client results, got %+v", msg.Content)
93+ }
94+ if rec.lastArgs != nil {
95+ t.Fatal("client tool executed for a server tool call")
96+ }
97+}
98+
7999 // TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from
80100 // the interceptor returns an error ToolResult with the reason text.
81101 func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
@@ -76,6 +76,26 @@ func TestBeforeToolExecuteInvalidJSONIgnored(t *testing.T) {
76 }76 }
77 }77 }
78 78
79+func TestExecuteToolsSkipsServerToolCalls(t *testing.T) {
80+ rec := &recordingTool{}
81+ a := NewAgent(nil, "test", "", Registry{"echo": rec})
82+ msg, hadError := a.executeTools(context.Background(), provider.Message{
83+ Role: provider.RoleAssistant,
84+ Content: []provider.Content{
85+ provider.ToolCallBlock{ID: "srv", Name: "openrouter:datetime", Server: true},
86+ },
87+ }, func(AgentEvent) {})
88+ if hadError {
89+ t.Fatal("server tool produced error")
90+ }
91+ if len(msg.Content) != 0 {
92+ t.Fatalf("server tools must not produce client results, got %+v", msg.Content)
93+ }
94+ if rec.lastArgs != nil {
95+ t.Fatal("client tool executed for a server tool call")
96+ }
97+}
98+
79 // TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from99 // TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from
80 // the interceptor returns an error ToolResult with the reason text.100 // the interceptor returns an error ToolResult with the reason text.
81 func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {101 func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
M packages/core/session.go +3 -1
@@ -323,7 +323,7 @@ func repairToolUseResultPairs(msgs []provider.Message) []provider.Message {
323323 // Collect tool_use ids in this assistant message.
324324 var ids []string
325325 for _, c := range m.Content {
326- if tc, ok := c.(provider.ToolCallBlock); ok {
326+ if tc, ok := c.(provider.ToolCallBlock); ok && !tc.Server {
327327 ids = append(ids, tc.ID)
328328 }
329329 }
@@ -852,6 +852,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
852852 Name string `json:"name"`
853853 Arguments json.RawMessage `json:"arguments"`
854854 ThoughtSignature string `json:"thought_signature"`
855+ Server bool `json:"server"`
855856 }
856857 _ = json.Unmarshal(raw, &tc)
857858 msg.Content = append(msg.Content, provider.ToolCallBlock{
@@ -859,6 +860,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
859860 Name: tc.Name,
860861 Arguments: tc.Arguments,
861862 ThoughtSignature: tc.ThoughtSignature,
863+ Server: tc.Server,
862864 })
863865 case head.CallID != "":
864866 var tr struct {
@@ -323,7 +323,7 @@ func repairToolUseResultPairs(msgs []provider.Message) []provider.Message {
323 // Collect tool_use ids in this assistant message.323 // Collect tool_use ids in this assistant message.
324 var ids []string324 var ids []string
325 for _, c := range m.Content {325 for _, c := range m.Content {
326- if tc, ok := c.(provider.ToolCallBlock); ok {326+ if tc, ok := c.(provider.ToolCallBlock); ok && !tc.Server {
327 ids = append(ids, tc.ID)327 ids = append(ids, tc.ID)
328 }328 }
329 }329 }
@@ -852,6 +852,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
852 Name string `json:"name"`852 Name string `json:"name"`
853 Arguments json.RawMessage `json:"arguments"`853 Arguments json.RawMessage `json:"arguments"`
854 ThoughtSignature string `json:"thought_signature"`854 ThoughtSignature string `json:"thought_signature"`
855+ Server bool `json:"server"`
855 }856 }
856 _ = json.Unmarshal(raw, &tc)857 _ = json.Unmarshal(raw, &tc)
857 msg.Content = append(msg.Content, provider.ToolCallBlock{858 msg.Content = append(msg.Content, provider.ToolCallBlock{
@@ -859,6 +860,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
859 Name: tc.Name,860 Name: tc.Name,
860 Arguments: tc.Arguments,861 Arguments: tc.Arguments,
861 ThoughtSignature: tc.ThoughtSignature,862 ThoughtSignature: tc.ThoughtSignature,
863+ Server: tc.Server,
862 })864 })
863 case head.CallID != "":865 case head.CallID != "":
864 var tr struct {866 var tr struct {
M packages/core/session_repair_test.go +14 -0
@@ -102,3 +102,17 @@ func TestRepairToolUseResultPairsEmpty(t *testing.T) {
102102 t.Fatalf("expected empty, got %d", len(out))
103103 }
104104 }
105+
106+func TestRepairToolUseResultPairsSkipsServerTools(t *testing.T) {
107+ msgs := []provider.Message{
108+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hi"}}},
109+ {Role: provider.RoleAssistant, Content: []provider.Content{
110+ provider.ToolCallBlock{ID: "srv", Name: "openrouter:datetime", Server: true},
111+ provider.TextBlock{Text: "noon"},
112+ }},
113+ }
114+ out := repairToolUseResultPairs(msgs)
115+ if len(out) != len(msgs) {
116+ t.Fatalf("server tools should not get stub results, len %d -> %d", len(msgs), len(out))
117+ }
118+}
@@ -102,3 +102,17 @@ func TestRepairToolUseResultPairsEmpty(t *testing.T) {
102 t.Fatalf("expected empty, got %d", len(out))102 t.Fatalf("expected empty, got %d", len(out))
103 }103 }
104 }104 }
105+
106+func TestRepairToolUseResultPairsSkipsServerTools(t *testing.T) {
107+ msgs := []provider.Message{
108+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hi"}}},
109+ {Role: provider.RoleAssistant, Content: []provider.Content{
110+ provider.ToolCallBlock{ID: "srv", Name: "openrouter:datetime", Server: true},
111+ provider.TextBlock{Text: "noon"},
112+ }},
113+ }
114+ out := repairToolUseResultPairs(msgs)
115+ if len(out) != len(msgs) {
116+ t.Fatalf("server tools should not get stub results, len %d -> %d", len(msgs), len(out))
117+ }
118+}
M packages/provider/deferred_tools_test.go +8 -4
@@ -23,7 +23,7 @@ func TestKimiK3DeferredToolsLoadAtToolResult(t *testing.T) {
2323 if err != nil {
2424 t.Fatal(err)
2525 }
26- if len(wire.Tools) != 1 || wire.Tools[0].Function.Name != "search_tools" {
26+ if len(wire.Tools) != 1 || wire.Tools[0].Function == nil || wire.Tools[0].Function.Name != "search_tools" {
2727 t.Fatalf("top-level tools = %+v, want only search_tools", wire.Tools)
2828 }
2929 var loaded []oaiTool
@@ -32,7 +32,7 @@ func TestKimiK3DeferredToolsLoadAtToolResult(t *testing.T) {
3232 loaded = message.Tools
3333 }
3434 }
35- if len(loaded) != 1 || loaded[0].Function.Name != "lookup_weather" {
35+ if len(loaded) != 1 || loaded[0].Function == nil || loaded[0].Function.Name != "lookup_weather" {
3636 t.Fatalf("loaded tools = %+v, want lookup_weather", loaded)
3737 }
3838 }
@@ -69,7 +69,9 @@ func TestKimiK3DeferredToolsSkippedForNonMoonshotProvider(t *testing.T) {
6969 }
7070 var names []string
7171 for _, tool := range wire.Tools {
72- names = append(names, tool.Function.Name)
72+ if tool.Function != nil {
73+ names = append(names, tool.Function.Name)
74+ }
7375 }
7476 if len(names) != 2 || names[0] != "search_tools" || names[1] != "lookup_weather" {
7577 t.Fatalf("top-level tools = %v, want [search_tools lookup_weather]", names)
@@ -89,7 +91,9 @@ func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {
8991 }
9092 names := make([]string, 0, len(wire.Tools))
9193 for _, tool := range wire.Tools {
92- names = append(names, tool.Function.Name)
94+ if tool.Function != nil {
95+ names = append(names, tool.Function.Name)
96+ }
9397 }
9498 return names, nil
9599 },
@@ -23,7 +23,7 @@ func TestKimiK3DeferredToolsLoadAtToolResult(t *testing.T) {
23 if err != nil {23 if err != nil {
24 t.Fatal(err)24 t.Fatal(err)
25 }25 }
26- if len(wire.Tools) != 1 || wire.Tools[0].Function.Name != "search_tools" {26+ if len(wire.Tools) != 1 || wire.Tools[0].Function == nil || wire.Tools[0].Function.Name != "search_tools" {
27 t.Fatalf("top-level tools = %+v, want only search_tools", wire.Tools)27 t.Fatalf("top-level tools = %+v, want only search_tools", wire.Tools)
28 }28 }
29 var loaded []oaiTool29 var loaded []oaiTool
@@ -32,7 +32,7 @@ func TestKimiK3DeferredToolsLoadAtToolResult(t *testing.T) {
32 loaded = message.Tools32 loaded = message.Tools
33 }33 }
34 }34 }
35- if len(loaded) != 1 || loaded[0].Function.Name != "lookup_weather" {35+ if len(loaded) != 1 || loaded[0].Function == nil || loaded[0].Function.Name != "lookup_weather" {
36 t.Fatalf("loaded tools = %+v, want lookup_weather", loaded)36 t.Fatalf("loaded tools = %+v, want lookup_weather", loaded)
37 }37 }
38 }38 }
@@ -69,7 +69,9 @@ func TestKimiK3DeferredToolsSkippedForNonMoonshotProvider(t *testing.T) {
69 }69 }
70 var names []string70 var names []string
71 for _, tool := range wire.Tools {71 for _, tool := range wire.Tools {
72- names = append(names, tool.Function.Name)72+ if tool.Function != nil {
73+ names = append(names, tool.Function.Name)
74+ }
73 }75 }
74 if len(names) != 2 || names[0] != "search_tools" || names[1] != "lookup_weather" {76 if len(names) != 2 || names[0] != "search_tools" || names[1] != "lookup_weather" {
75 t.Fatalf("top-level tools = %v, want [search_tools lookup_weather]", names)77 t.Fatalf("top-level tools = %v, want [search_tools lookup_weather]", names)
@@ -89,7 +91,9 @@ func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {
89 }91 }
90 names := make([]string, 0, len(wire.Tools))92 names := make([]string, 0, len(wire.Tools))
91 for _, tool := range wire.Tools {93 for _, tool := range wire.Tools {
92- names = append(names, tool.Function.Name)94+ if tool.Function != nil {
95+ names = append(names, tool.Function.Name)
96+ }
93 }97 }
94 return names, nil98 return names, nil
95 },99 },
M packages/provider/discover.go +65 -0
@@ -312,6 +312,71 @@ func DiscoverOpenRouter(ctx context.Context, baseURL string) ([]Model, error) {
312312 return out, nil
313313 }
314314
315+// DiscoverOpenRouterPresets lists the authenticated user's OpenRouter
316+// presets and returns them as selectable models with ids of the form
317+// "@preset/{slug}". Requires an API key; /presets is not public.
318+func DiscoverOpenRouterPresets(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
319+ if baseURL == "" {
320+ baseURL = openrouterDefaultBaseURL
321+ }
322+ baseURL = strings.TrimRight(baseURL, "/")
323+ client := &http.Client{Timeout: 15 * time.Second}
324+ var out []Model
325+ offset := 0
326+ for {
327+ url := fmt.Sprintf("%s/presets?limit=100&offset=%d", baseURL, offset)
328+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
329+ if err != nil {
330+ return nil, err
331+ }
332+ if apiKey != "" {
333+ req.Header.Set("authorization", "Bearer "+apiKey)
334+ }
335+ resp, err := client.Do(req)
336+ if err != nil {
337+ return nil, err
338+ }
339+ body, _ := io.ReadAll(resp.Body)
340+ resp.Body.Close()
341+ if resp.StatusCode != 200 {
342+ return nil, fmt.Errorf("openrouter presets http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
343+ }
344+ var page struct {
345+ Data []struct {
346+ Name string `json:"name"`
347+ Slug string `json:"slug"`
348+ Description string `json:"description"`
349+ Status string `json:"status"`
350+ } `json:"data"`
351+ TotalCount int `json:"total_count"`
352+ }
353+ if err := json.Unmarshal(body, &page); err != nil {
354+ return nil, fmt.Errorf("openrouter presets parse: %w", err)
355+ }
356+ for _, d := range page.Data {
357+ if d.Slug == "" || d.Status == "archived" || d.Status == "disabled" {
358+ continue
359+ }
360+ display := d.Name
361+ if display == "" {
362+ display = d.Slug
363+ }
364+ out = append(out, Model{
365+ Provider: "openrouter",
366+ ID: "@preset/" + d.Slug,
367+ DisplayName: display + " (preset)",
368+ BaseURL: baseURL,
369+ Source: "live",
370+ })
371+ }
372+ if len(page.Data) == 0 || offset+len(page.Data) >= page.TotalCount {
373+ break
374+ }
375+ offset += len(page.Data)
376+ }
377+ return out, nil
378+}
379+
315380 // openrouterSupportsReasoning reports whether OpenRouter's
316381 // supported_parameters list marks the model as reasoning-capable.
317382 func openrouterSupportsReasoning(params []string) bool {
@@ -312,6 +312,71 @@ func DiscoverOpenRouter(ctx context.Context, baseURL string) ([]Model, error) {
312 return out, nil312 return out, nil
313 }313 }
314 314
315+// DiscoverOpenRouterPresets lists the authenticated user's OpenRouter
316+// presets and returns them as selectable models with ids of the form
317+// "@preset/{slug}". Requires an API key; /presets is not public.
318+func DiscoverOpenRouterPresets(ctx context.Context, apiKey, baseURL string) ([]Model, error) {
319+ if baseURL == "" {
320+ baseURL = openrouterDefaultBaseURL
321+ }
322+ baseURL = strings.TrimRight(baseURL, "/")
323+ client := &http.Client{Timeout: 15 * time.Second}
324+ var out []Model
325+ offset := 0
326+ for {
327+ url := fmt.Sprintf("%s/presets?limit=100&offset=%d", baseURL, offset)
328+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
329+ if err != nil {
330+ return nil, err
331+ }
332+ if apiKey != "" {
333+ req.Header.Set("authorization", "Bearer "+apiKey)
334+ }
335+ resp, err := client.Do(req)
336+ if err != nil {
337+ return nil, err
338+ }
339+ body, _ := io.ReadAll(resp.Body)
340+ resp.Body.Close()
341+ if resp.StatusCode != 200 {
342+ return nil, fmt.Errorf("openrouter presets http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
343+ }
344+ var page struct {
345+ Data []struct {
346+ Name string `json:"name"`
347+ Slug string `json:"slug"`
348+ Description string `json:"description"`
349+ Status string `json:"status"`
350+ } `json:"data"`
351+ TotalCount int `json:"total_count"`
352+ }
353+ if err := json.Unmarshal(body, &page); err != nil {
354+ return nil, fmt.Errorf("openrouter presets parse: %w", err)
355+ }
356+ for _, d := range page.Data {
357+ if d.Slug == "" || d.Status == "archived" || d.Status == "disabled" {
358+ continue
359+ }
360+ display := d.Name
361+ if display == "" {
362+ display = d.Slug
363+ }
364+ out = append(out, Model{
365+ Provider: "openrouter",
366+ ID: "@preset/" + d.Slug,
367+ DisplayName: display + " (preset)",
368+ BaseURL: baseURL,
369+ Source: "live",
370+ })
371+ }
372+ if len(page.Data) == 0 || offset+len(page.Data) >= page.TotalCount {
373+ break
374+ }
375+ offset += len(page.Data)
376+ }
377+ return out, nil
378+}
379+
315 // openrouterSupportsReasoning reports whether OpenRouter's380 // openrouterSupportsReasoning reports whether OpenRouter's
316 // supported_parameters list marks the model as reasoning-capable.381 // supported_parameters list marks the model as reasoning-capable.
317 func openrouterSupportsReasoning(params []string) bool {382 func openrouterSupportsReasoning(params []string) bool {
M packages/provider/discover_openrouter_test.go +38 -0
@@ -51,3 +51,41 @@ func TestDiscoverOpenRouterPrefersServedContextLength(t *testing.T) {
5151 t.Errorf("no-top ContextWindow = %d; want 128000 (model value, no top_provider)", got)
5252 }
5353 }
54+
55+func TestDiscoverOpenRouterPresets(t *testing.T) {
56+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
57+ if r.URL.Path != "/presets" {
58+ http.NotFound(w, r)
59+ return
60+ }
61+ if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
62+ t.Errorf("Authorization = %q; want Bearer test-key", got)
63+ }
64+ w.Header().Set("Content-Type", "application/json")
65+ _, _ = w.Write([]byte(`{"data":[
66+ {"name":"Flash","slug":"flash","status":"active"},
67+ {"name":"Old","slug":"old","status":"archived"},
68+ {"name":"Off","slug":"off","status":"disabled"},
69+ {"name":"NoSlug","slug":"","status":"active"}
70+ ],"total_count":4}`))
71+ }))
72+ defer srv.Close()
73+
74+ models, err := DiscoverOpenRouterPresets(context.Background(), "test-key", srv.URL)
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ if len(models) != 1 {
79+ t.Fatalf("got %d presets; want 1 active", len(models))
80+ }
81+ m := models[0]
82+ if m.Provider != "openrouter" || m.ID != "@preset/flash" {
83+ t.Errorf("preset = %+v; want openrouter @preset/flash", m)
84+ }
85+ if m.DisplayName != "Flash (preset)" {
86+ t.Errorf("DisplayName = %q", m.DisplayName)
87+ }
88+ if m.ContextWindow != 0 || m.MaxOutput != 0 || m.Reasoning {
89+ t.Errorf("preset has invented capabilities: %+v", m)
90+ }
91+}
@@ -51,3 +51,41 @@ func TestDiscoverOpenRouterPrefersServedContextLength(t *testing.T) {
51 t.Errorf("no-top ContextWindow = %d; want 128000 (model value, no top_provider)", got)51 t.Errorf("no-top ContextWindow = %d; want 128000 (model value, no top_provider)", got)
52 }52 }
53 }53 }
54+
55+func TestDiscoverOpenRouterPresets(t *testing.T) {
56+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
57+ if r.URL.Path != "/presets" {
58+ http.NotFound(w, r)
59+ return
60+ }
61+ if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
62+ t.Errorf("Authorization = %q; want Bearer test-key", got)
63+ }
64+ w.Header().Set("Content-Type", "application/json")
65+ _, _ = w.Write([]byte(`{"data":[
66+ {"name":"Flash","slug":"flash","status":"active"},
67+ {"name":"Old","slug":"old","status":"archived"},
68+ {"name":"Off","slug":"off","status":"disabled"},
69+ {"name":"NoSlug","slug":"","status":"active"}
70+ ],"total_count":4}`))
71+ }))
72+ defer srv.Close()
73+
74+ models, err := DiscoverOpenRouterPresets(context.Background(), "test-key", srv.URL)
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ if len(models) != 1 {
79+ t.Fatalf("got %d presets; want 1 active", len(models))
80+ }
81+ m := models[0]
82+ if m.Provider != "openrouter" || m.ID != "@preset/flash" {
83+ t.Errorf("preset = %+v; want openrouter @preset/flash", m)
84+ }
85+ if m.DisplayName != "Flash (preset)" {
86+ t.Errorf("DisplayName = %q", m.DisplayName)
87+ }
88+ if m.ContextWindow != 0 || m.MaxOutput != 0 || m.Reasoning {
89+ t.Errorf("preset has invented capabilities: %+v", m)
90+ }
91+}
M packages/provider/openai.go +69 -14
@@ -133,9 +133,11 @@ type oaiToolCallFn struct {
133133 }
134134
135135 type oaiToolCall struct {
136- ID string `json:"id"`
137- Type string `json:"type"` // "function"
138- Function oaiToolCallFn `json:"function"`
136+ ID string `json:"id"`
137+ Type string `json:"type"` // "function" or "openrouter:..."
138+ Function oaiToolCallFn `json:"function,omitempty"`
139+ Name string `json:"name,omitempty"`
140+ Arguments json.RawMessage `json:"arguments,omitempty"`
139141 }
140142
141143 type oaiMessage struct {
@@ -153,12 +155,13 @@ type oaiMessage struct {
153155 }
154156
155157 type oaiTool struct {
156- Type string `json:"type"` // "function"
157- Function struct {
158+ Type string `json:"type"` // "function" or "openrouter:..."
159+ Parameters json.RawMessage `json:"parameters,omitempty"`
160+ Function *struct {
158161 Name string `json:"name"`
159162 Description string `json:"description,omitempty"`
160163 Parameters json.RawMessage `json:"parameters"`
161- } `json:"function"`
164+ } `json:"function,omitempty"`
162165 }
163166
164167 type oaiStreamOptions struct {
@@ -176,6 +179,8 @@ type oaiRequest struct {
176179 MaxTokens *int `json:"max_tokens,omitempty"`
177180 MaxCompletionTok *int `json:"max_completion_tokens,omitempty"`
178181 ReasoningEffort string `json:"reasoning_effort,omitempty"`
182+ SessionID string `json:"session_id,omitempty"`
183+ MaxToolCalls int `json:"max_tool_calls,omitempty"`
179184 }
180185
181186 // ---- request building ----
@@ -203,6 +208,14 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
203208 StreamOptions: &oaiStreamOptions{IncludeUsage: true},
204209 Temperature: req.Temperature,
205210 }
211+ if c.Name() == "openrouter" {
212+ if req.SessionID != "" {
213+ out.SessionID = req.SessionID
214+ }
215+ if req.MaxToolCalls > 0 {
216+ out.MaxToolCalls = req.MaxToolCalls
217+ }
218+ }
206219
207220 maxTok := req.MaxTokens
208221 if maxTok <= 0 {
@@ -310,6 +323,9 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
310323 }
311324 text.WriteString(v.Text)
312325 case ToolCallBlock:
326+ if v.Server {
327+ continue
328+ }
313329 args := v.Arguments
314330 if len(args) == 0 || !json.Valid(args) {
315331 args = json.RawMessage("{}")
@@ -407,12 +423,25 @@ func supportsDeferredTools(provider string) bool {
407423 return provider == "moonshotai" || provider == "moonshotai-cn"
408424 }
409425
426+func isOpenRouterServerToolType(typ string) bool {
427+ return strings.HasPrefix(typ, "openrouter:")
428+}
429+
410430 func makeOAITool(t Tool) oaiTool {
431+ if isOpenRouterServerToolType(t.Name) {
432+ return oaiTool{Type: t.Name, Parameters: t.Schema}
433+ }
411434 var tool oaiTool
412435 tool.Type = "function"
413- tool.Function.Name = t.Name
414- tool.Function.Description = t.Description
415- tool.Function.Parameters = t.Schema
436+ tool.Function = &struct {
437+ Name string `json:"name"`
438+ Description string `json:"description,omitempty"`
439+ Parameters json.RawMessage `json:"parameters"`
440+ }{
441+ Name: t.Name,
442+ Description: t.Description,
443+ Parameters: t.Schema,
444+ }
416445 return tool
417446 }
418447
@@ -554,6 +583,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
554583 toolName string
555584 toolArgs strings.Builder
556585 announced bool
586+ server bool
557587 }
558588 var (
559589 blocks []*blockEntry
@@ -600,7 +630,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
600630 args = "{}"
601631 }
602632 content = append(content, ToolCallBlock{
603- ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args),
633+ ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args), Server: b.server,
604634 })
605635 }
606636 }
@@ -639,10 +669,12 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
639669 Content string `json:"content"`
640670 ReasoningContent string `json:"reasoning_content"`
641671 ToolCalls []struct {
642- Index int `json:"index"`
643- ID string `json:"id"`
644- Type string `json:"type"`
645- Function struct {
672+ Index int `json:"index"`
673+ ID string `json:"id"`
674+ Type string `json:"type"`
675+ Name string `json:"name"`
676+ Arguments json.RawMessage `json:"arguments"`
677+ Function struct {
646678 Name string `json:"name"`
647679 Arguments string `json:"arguments"`
648680 } `json:"function"`
@@ -699,8 +731,22 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
699731 if tc.ID != "" {
700732 t.toolID = tc.ID
701733 }
734+ if isOpenRouterServerToolType(tc.Type) {
735+ t.server = true
736+ if tc.Name != "" {
737+ t.toolName = tc.Name
738+ } else if t.toolName == "" {
739+ t.toolName = tc.Type
740+ }
741+ if len(tc.Arguments) > 0 {
742+ t.toolArgs.Write(tc.Arguments)
743+ }
744+ }
702745 if tc.Function.Name != "" {
703746 t.toolName = tc.Function.Name
747+ if isOpenRouterServerToolType(tc.Function.Name) {
748+ t.server = true
749+ }
704750 }
705751 if !t.announced && t.toolID != "" && t.toolName != "" {
706752 t.announced = true
@@ -720,11 +766,20 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
720766 stop = StopLength
721767 case "tool_calls", "function_call":
722768 stop = StopToolUse
769+ hasClientTool := false
723770 for _, b := range blocks {
724771 if b.kind == "tool_use" && b.announced {
725772 out <- EventToolEnd{ID: b.toolID}
773+ if !b.server {
774+ hasClientTool = true
775+ }
726776 }
727777 }
778+ if !hasClientTool {
779+ // OpenRouter executed every tool server-side; the
780+ // client must not send tool_result messages.
781+ stop = StopEnd
782+ }
728783 }
729784 }
730785 }
@@ -133,9 +133,11 @@ type oaiToolCallFn struct {
133 }133 }
134 134
135 type oaiToolCall struct {135 type oaiToolCall struct {
136- ID string `json:"id"`136+ ID string `json:"id"`
137- Type string `json:"type"` // "function"137+ Type string `json:"type"` // "function" or "openrouter:..."
138- Function oaiToolCallFn `json:"function"`138+ Function oaiToolCallFn `json:"function,omitempty"`
139+ Name string `json:"name,omitempty"`
140+ Arguments json.RawMessage `json:"arguments,omitempty"`
139 }141 }
140 142
141 type oaiMessage struct {143 type oaiMessage struct {
@@ -153,12 +155,13 @@ type oaiMessage struct {
153 }155 }
154 156
155 type oaiTool struct {157 type oaiTool struct {
156- Type string `json:"type"` // "function"158+ Type string `json:"type"` // "function" or "openrouter:..."
157- Function struct {159+ Parameters json.RawMessage `json:"parameters,omitempty"`
160+ Function *struct {
158 Name string `json:"name"`161 Name string `json:"name"`
159 Description string `json:"description,omitempty"`162 Description string `json:"description,omitempty"`
160 Parameters json.RawMessage `json:"parameters"`163 Parameters json.RawMessage `json:"parameters"`
161- } `json:"function"`164+ } `json:"function,omitempty"`
162 }165 }
163 166
164 type oaiStreamOptions struct {167 type oaiStreamOptions struct {
@@ -176,6 +179,8 @@ type oaiRequest struct {
176 MaxTokens *int `json:"max_tokens,omitempty"`179 MaxTokens *int `json:"max_tokens,omitempty"`
177 MaxCompletionTok *int `json:"max_completion_tokens,omitempty"`180 MaxCompletionTok *int `json:"max_completion_tokens,omitempty"`
178 ReasoningEffort string `json:"reasoning_effort,omitempty"`181 ReasoningEffort string `json:"reasoning_effort,omitempty"`
182+ SessionID string `json:"session_id,omitempty"`
183+ MaxToolCalls int `json:"max_tool_calls,omitempty"`
179 }184 }
180 185
181 // ---- request building ----186 // ---- request building ----
@@ -203,6 +208,14 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
203 StreamOptions: &oaiStreamOptions{IncludeUsage: true},208 StreamOptions: &oaiStreamOptions{IncludeUsage: true},
204 Temperature: req.Temperature,209 Temperature: req.Temperature,
205 }210 }
211+ if c.Name() == "openrouter" {
212+ if req.SessionID != "" {
213+ out.SessionID = req.SessionID
214+ }
215+ if req.MaxToolCalls > 0 {
216+ out.MaxToolCalls = req.MaxToolCalls
217+ }
218+ }
206 219
207 maxTok := req.MaxTokens220 maxTok := req.MaxTokens
208 if maxTok <= 0 {221 if maxTok <= 0 {
@@ -310,6 +323,9 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
310 }323 }
311 text.WriteString(v.Text)324 text.WriteString(v.Text)
312 case ToolCallBlock:325 case ToolCallBlock:
326+ if v.Server {
327+ continue
328+ }
313 args := v.Arguments329 args := v.Arguments
314 if len(args) == 0 || !json.Valid(args) {330 if len(args) == 0 || !json.Valid(args) {
315 args = json.RawMessage("{}")331 args = json.RawMessage("{}")
@@ -407,12 +423,25 @@ func supportsDeferredTools(provider string) bool {
407 return provider == "moonshotai" || provider == "moonshotai-cn"423 return provider == "moonshotai" || provider == "moonshotai-cn"
408 }424 }
409 425
426+func isOpenRouterServerToolType(typ string) bool {
427+ return strings.HasPrefix(typ, "openrouter:")
428+}
429+
410 func makeOAITool(t Tool) oaiTool {430 func makeOAITool(t Tool) oaiTool {
431+ if isOpenRouterServerToolType(t.Name) {
432+ return oaiTool{Type: t.Name, Parameters: t.Schema}
433+ }
411 var tool oaiTool434 var tool oaiTool
412 tool.Type = "function"435 tool.Type = "function"
413- tool.Function.Name = t.Name436+ tool.Function = &struct {
414- tool.Function.Description = t.Description437+ Name string `json:"name"`
415- tool.Function.Parameters = t.Schema438+ Description string `json:"description,omitempty"`
439+ Parameters json.RawMessage `json:"parameters"`
440+ }{
441+ Name: t.Name,
442+ Description: t.Description,
443+ Parameters: t.Schema,
444+ }
416 return tool445 return tool
417 }446 }
418 447
@@ -554,6 +583,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
554 toolName string583 toolName string
555 toolArgs strings.Builder584 toolArgs strings.Builder
556 announced bool585 announced bool
586+ server bool
557 }587 }
558 var (588 var (
559 blocks []*blockEntry589 blocks []*blockEntry
@@ -600,7 +630,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
600 args = "{}"630 args = "{}"
601 }631 }
602 content = append(content, ToolCallBlock{632 content = append(content, ToolCallBlock{
603- ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args),633+ ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args), Server: b.server,
604 })634 })
605 }635 }
606 }636 }
@@ -639,10 +669,12 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
639 Content string `json:"content"`669 Content string `json:"content"`
640 ReasoningContent string `json:"reasoning_content"`670 ReasoningContent string `json:"reasoning_content"`
641 ToolCalls []struct {671 ToolCalls []struct {
642- Index int `json:"index"`672+ Index int `json:"index"`
643- ID string `json:"id"`673+ ID string `json:"id"`
644- Type string `json:"type"`674+ Type string `json:"type"`
645- Function struct {675+ Name string `json:"name"`
676+ Arguments json.RawMessage `json:"arguments"`
677+ Function struct {
646 Name string `json:"name"`678 Name string `json:"name"`
647 Arguments string `json:"arguments"`679 Arguments string `json:"arguments"`
648 } `json:"function"`680 } `json:"function"`
@@ -699,8 +731,22 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
699 if tc.ID != "" {731 if tc.ID != "" {
700 t.toolID = tc.ID732 t.toolID = tc.ID
701 }733 }
734+ if isOpenRouterServerToolType(tc.Type) {
735+ t.server = true
736+ if tc.Name != "" {
737+ t.toolName = tc.Name
738+ } else if t.toolName == "" {
739+ t.toolName = tc.Type
740+ }
741+ if len(tc.Arguments) > 0 {
742+ t.toolArgs.Write(tc.Arguments)
743+ }
744+ }
702 if tc.Function.Name != "" {745 if tc.Function.Name != "" {
703 t.toolName = tc.Function.Name746 t.toolName = tc.Function.Name
747+ if isOpenRouterServerToolType(tc.Function.Name) {
748+ t.server = true
749+ }
704 }750 }
705 if !t.announced && t.toolID != "" && t.toolName != "" {751 if !t.announced && t.toolID != "" && t.toolName != "" {
706 t.announced = true752 t.announced = true
@@ -720,11 +766,20 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
720 stop = StopLength766 stop = StopLength
721 case "tool_calls", "function_call":767 case "tool_calls", "function_call":
722 stop = StopToolUse768 stop = StopToolUse
769+ hasClientTool := false
723 for _, b := range blocks {770 for _, b := range blocks {
724 if b.kind == "tool_use" && b.announced {771 if b.kind == "tool_use" && b.announced {
725 out <- EventToolEnd{ID: b.toolID}772 out <- EventToolEnd{ID: b.toolID}
773+ if !b.server {
774+ hasClientTool = true
775+ }
726 }776 }
727 }777 }
778+ if !hasClientTool {
779+ // OpenRouter executed every tool server-side; the
780+ // client must not send tool_result messages.
781+ stop = StopEnd
782+ }
728 }783 }
729 }784 }
730 }785 }
A packages/provider/openai_openrouter_test.go +154 -0
@@ -0,0 +1,154 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
10+ "testing"
11+)
12+
13+func TestOpenRouterBuildRequestIncludesSessionID(t *testing.T) {
14+ c := NewOpenRouter("token", "").(*openaiClient)
15+ wire, err := c.buildRequest(Request{
16+ Model: "@preset/flash",
17+ SessionID: "sess-abc",
18+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
19+ })
20+ if err != nil {
21+ t.Fatal(err)
22+ }
23+ if wire.SessionID != "sess-abc" {
24+ t.Fatalf("SessionID = %q; want sess-abc", wire.SessionID)
25+ }
26+}
27+
28+func TestOpenAIBuildRequestOmitsSessionID(t *testing.T) {
29+ c := NewOpenAI("token", "").(*openaiClient)
30+ wire, err := c.buildRequest(Request{
31+ Model: "gpt-5",
32+ SessionID: "sess-abc",
33+ MaxToolCalls: 4,
34+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
35+ })
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if wire.SessionID != "" {
40+ t.Fatalf("SessionID = %q; want empty on non-openrouter clients", wire.SessionID)
41+ }
42+ if wire.MaxToolCalls != 0 {
43+ t.Fatalf("MaxToolCalls = %d; want empty on non-openrouter clients", wire.MaxToolCalls)
44+ }
45+}
46+
47+func TestOpenRouterOmitsServerToolCallsFromHistory(t *testing.T) {
48+ c := NewOpenRouter("token", "").(*openaiClient)
49+ wire, err := c.buildRequest(Request{
50+ Model: "@preset/flash",
51+ Messages: []Message{
52+ {Role: RoleUser, Content: []Content{TextBlock{Text: "time?"}}},
53+ {Role: RoleAssistant, Content: []Content{
54+ ToolCallBlock{ID: "srv-1", Name: "openrouter:datetime", Arguments: json.RawMessage(`{}`), Server: true},
55+ TextBlock{Text: "it is noon"},
56+ }},
57+ },
58+ })
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ if len(wire.Messages) != 2 {
63+ t.Fatalf("messages = %d; want 2", len(wire.Messages))
64+ }
65+ am := wire.Messages[1]
66+ if len(am.ToolCalls) != 0 {
67+ t.Fatalf("replayed server tool_calls = %+v; want none", am.ToolCalls)
68+ }
69+ if am.Content != "it is noon" {
70+ t.Fatalf("content = %#v", am.Content)
71+ }
72+}
73+
74+func TestOpenRouterServerToolStreamDoesNotStopForClientExecution(t *testing.T) {
75+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
76+ body, _ := io.ReadAll(r.Body)
77+ if !strings.Contains(string(body), `"session_id":"sess-1"`) {
78+ t.Errorf("request body missing session_id: %s", body)
79+ }
80+ w.Header().Set("content-type", "text/event-stream")
81+ w.WriteHeader(http.StatusOK)
82+ fl, _ := w.(http.Flusher)
83+ write := func(s string) {
84+ _, _ = w.Write([]byte(s))
85+ if fl != nil {
86+ fl.Flush()
87+ }
88+ }
89+ write("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_dt\",\"type\":\"openrouter:datetime\",\"name\":\"openrouter:datetime\",\"arguments\":\"{}\"}]},\"finish_reason\":null}]}\n\n")
90+ write("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"2026-08-30\"},\"finish_reason\":null}]}\n\n")
91+ write("data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n")
92+ write("data: [DONE]\n\n")
93+ }))
94+ defer srv.Close()
95+
96+ c := NewOpenRouter("token", srv.URL)
97+ evs, err := c.Stream(context.Background(), Request{
98+ Model: "@preset/flash",
99+ SessionID: "sess-1",
100+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "time?"}}}},
101+ })
102+ if err != nil {
103+ t.Fatal(err)
104+ }
105+ var done EventDone
106+ for ev := range evs {
107+ if e, ok := ev.(EventDone); ok {
108+ done = e
109+ }
110+ }
111+ if done.Stop != StopEnd {
112+ t.Fatalf("stop = %v; want end (server tools must not trigger client execution)", done.Stop)
113+ }
114+ var sawServer bool
115+ for _, c := range done.Message.Content {
116+ if tc, ok := c.(ToolCallBlock); ok {
117+ if !tc.Server || tc.Name != "openrouter:datetime" {
118+ t.Fatalf("tool call = %+v; want server openrouter:datetime", tc)
119+ }
120+ sawServer = true
121+ }
122+ }
123+ if !sawServer {
124+ t.Fatalf("assembled message missing server tool call: %+v", done.Message.Content)
125+ }
126+}
127+
128+func TestOpenRouterAdvertisesServerTools(t *testing.T) {
129+ c := NewOpenRouter("token", "").(*openaiClient)
130+ wire, err := c.buildRequest(Request{
131+ Model: "@preset/flash",
132+ Tools: []Tool{
133+ {Name: "openrouter:datetime"},
134+ {Name: "openrouter:web_search", Schema: json.RawMessage(`{"max_results":1}`)},
135+ {Name: "read", Description: "read a file", Schema: json.RawMessage(`{"type":"object"}`)},
136+ },
137+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
138+ })
139+ if err != nil {
140+ t.Fatal(err)
141+ }
142+ if len(wire.Tools) != 3 {
143+ t.Fatalf("tools = %d; want 3", len(wire.Tools))
144+ }
145+ if wire.Tools[0].Type != "openrouter:datetime" || wire.Tools[0].Function != nil {
146+ t.Fatalf("datetime tool = %+v", wire.Tools[0])
147+ }
148+ if wire.Tools[1].Type != "openrouter:web_search" || string(wire.Tools[1].Parameters) != `{"max_results":1}` {
149+ t.Fatalf("web_search tool = %+v", wire.Tools[1])
150+ }
151+ if wire.Tools[2].Type != "function" || wire.Tools[2].Function == nil || wire.Tools[2].Function.Name != "read" {
152+ t.Fatalf("function tool = %+v", wire.Tools[2])
153+ }
154+}
@@ -0,0 +1,154 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "net/http"
8+ "net/http/httptest"
9+ "strings"
10+ "testing"
11+)
12+
13+func TestOpenRouterBuildRequestIncludesSessionID(t *testing.T) {
14+ c := NewOpenRouter("token", "").(*openaiClient)
15+ wire, err := c.buildRequest(Request{
16+ Model: "@preset/flash",
17+ SessionID: "sess-abc",
18+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
19+ })
20+ if err != nil {
21+ t.Fatal(err)
22+ }
23+ if wire.SessionID != "sess-abc" {
24+ t.Fatalf("SessionID = %q; want sess-abc", wire.SessionID)
25+ }
26+}
27+
28+func TestOpenAIBuildRequestOmitsSessionID(t *testing.T) {
29+ c := NewOpenAI("token", "").(*openaiClient)
30+ wire, err := c.buildRequest(Request{
31+ Model: "gpt-5",
32+ SessionID: "sess-abc",
33+ MaxToolCalls: 4,
34+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
35+ })
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if wire.SessionID != "" {
40+ t.Fatalf("SessionID = %q; want empty on non-openrouter clients", wire.SessionID)
41+ }
42+ if wire.MaxToolCalls != 0 {
43+ t.Fatalf("MaxToolCalls = %d; want empty on non-openrouter clients", wire.MaxToolCalls)
44+ }
45+}
46+
47+func TestOpenRouterOmitsServerToolCallsFromHistory(t *testing.T) {
48+ c := NewOpenRouter("token", "").(*openaiClient)
49+ wire, err := c.buildRequest(Request{
50+ Model: "@preset/flash",
51+ Messages: []Message{
52+ {Role: RoleUser, Content: []Content{TextBlock{Text: "time?"}}},
53+ {Role: RoleAssistant, Content: []Content{
54+ ToolCallBlock{ID: "srv-1", Name: "openrouter:datetime", Arguments: json.RawMessage(`{}`), Server: true},
55+ TextBlock{Text: "it is noon"},
56+ }},
57+ },
58+ })
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ if len(wire.Messages) != 2 {
63+ t.Fatalf("messages = %d; want 2", len(wire.Messages))
64+ }
65+ am := wire.Messages[1]
66+ if len(am.ToolCalls) != 0 {
67+ t.Fatalf("replayed server tool_calls = %+v; want none", am.ToolCalls)
68+ }
69+ if am.Content != "it is noon" {
70+ t.Fatalf("content = %#v", am.Content)
71+ }
72+}
73+
74+func TestOpenRouterServerToolStreamDoesNotStopForClientExecution(t *testing.T) {
75+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
76+ body, _ := io.ReadAll(r.Body)
77+ if !strings.Contains(string(body), `"session_id":"sess-1"`) {
78+ t.Errorf("request body missing session_id: %s", body)
79+ }
80+ w.Header().Set("content-type", "text/event-stream")
81+ w.WriteHeader(http.StatusOK)
82+ fl, _ := w.(http.Flusher)
83+ write := func(s string) {
84+ _, _ = w.Write([]byte(s))
85+ if fl != nil {
86+ fl.Flush()
87+ }
88+ }
89+ write("data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_dt\",\"type\":\"openrouter:datetime\",\"name\":\"openrouter:datetime\",\"arguments\":\"{}\"}]},\"finish_reason\":null}]}\n\n")
90+ write("data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"2026-08-30\"},\"finish_reason\":null}]}\n\n")
91+ write("data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n")
92+ write("data: [DONE]\n\n")
93+ }))
94+ defer srv.Close()
95+
96+ c := NewOpenRouter("token", srv.URL)
97+ evs, err := c.Stream(context.Background(), Request{
98+ Model: "@preset/flash",
99+ SessionID: "sess-1",
100+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "time?"}}}},
101+ })
102+ if err != nil {
103+ t.Fatal(err)
104+ }
105+ var done EventDone
106+ for ev := range evs {
107+ if e, ok := ev.(EventDone); ok {
108+ done = e
109+ }
110+ }
111+ if done.Stop != StopEnd {
112+ t.Fatalf("stop = %v; want end (server tools must not trigger client execution)", done.Stop)
113+ }
114+ var sawServer bool
115+ for _, c := range done.Message.Content {
116+ if tc, ok := c.(ToolCallBlock); ok {
117+ if !tc.Server || tc.Name != "openrouter:datetime" {
118+ t.Fatalf("tool call = %+v; want server openrouter:datetime", tc)
119+ }
120+ sawServer = true
121+ }
122+ }
123+ if !sawServer {
124+ t.Fatalf("assembled message missing server tool call: %+v", done.Message.Content)
125+ }
126+}
127+
128+func TestOpenRouterAdvertisesServerTools(t *testing.T) {
129+ c := NewOpenRouter("token", "").(*openaiClient)
130+ wire, err := c.buildRequest(Request{
131+ Model: "@preset/flash",
132+ Tools: []Tool{
133+ {Name: "openrouter:datetime"},
134+ {Name: "openrouter:web_search", Schema: json.RawMessage(`{"max_results":1}`)},
135+ {Name: "read", Description: "read a file", Schema: json.RawMessage(`{"type":"object"}`)},
136+ },
137+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
138+ })
139+ if err != nil {
140+ t.Fatal(err)
141+ }
142+ if len(wire.Tools) != 3 {
143+ t.Fatalf("tools = %d; want 3", len(wire.Tools))
144+ }
145+ if wire.Tools[0].Type != "openrouter:datetime" || wire.Tools[0].Function != nil {
146+ t.Fatalf("datetime tool = %+v", wire.Tools[0])
147+ }
148+ if wire.Tools[1].Type != "openrouter:web_search" || string(wire.Tools[1].Parameters) != `{"max_results":1}` {
149+ t.Fatalf("web_search tool = %+v", wire.Tools[1])
150+ }
151+ if wire.Tools[2].Type != "function" || wire.Tools[2].Function == nil || wire.Tools[2].Function.Name != "read" {
152+ t.Fatalf("function tool = %+v", wire.Tools[2])
153+ }
154+}
A packages/provider/openrouter_server_tools_live_test.go +380 -0
@@ -0,0 +1,380 @@
1+package provider
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "os"
11+ "strings"
12+ "testing"
13+ "time"
14+)
15+
16+const openRouterLiveGeneralModel = "openai/gpt-4.1"
17+
18+// Live OpenRouter server-tool batteries. Skipped unless OPENROUTER_API_KEY
19+// is set so CI and unpaid checkouts never hit the network.
20+func TestOpenRouterServerToolsLiveDeepSeekFlash(t *testing.T) {
21+ runOpenRouterServerToolBattery(t, "deepseek/deepseek-v4-flash")
22+}
23+
24+func TestOpenRouterServerToolsLivePresetFlash(t *testing.T) {
25+ runOpenRouterServerToolBattery(t, "@preset/flash")
26+}
27+
28+func TestOpenRouterServerToolsLiveGeneral(t *testing.T) {
29+ runOpenRouterServerToolBatteryOnNativeAPIs(t, openRouterLiveGeneralModel)
30+}
31+
32+func TestOpenRouterServerToolsLiveGeneralWithSettingsEnabled(t *testing.T) {
33+ // Same multidisciplinary model as the settings-on agent path. Chat
34+ // Completions-capable tools are advertised together (the setting's
35+ // default-on registry); shell/bash/apply_patch/tool_search still use
36+ // their native APIs.
37+ if testing.Short() {
38+ t.Skip("skip live OpenRouter battery in -short mode")
39+ }
40+ key := strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY"))
41+ if key == "" {
42+ t.Skip("OPENROUTER_API_KEY not set")
43+ }
44+
45+ client := NewOpenRouter(key, "")
46+ settingsTools := make([]Tool, 0, len(openRouterServerToolCases))
47+ for _, tc := range openRouterServerToolCases {
48+ if !tc.chatCompletionsUnsupported {
49+ settingsTools = append(settingsTools, tc.tool)
50+ }
51+ }
52+
53+ for _, tc := range openRouterServerToolCases {
54+ tc := tc
55+ t.Run(tc.name, func(t *testing.T) {
56+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
57+ defer cancel()
58+ switch tc.name {
59+ case "openrouter:shell", "openrouter:apply_patch", "openrouter:tool_search":
60+ postOpenRouterJSON(t, ctx, key, openrouterDefaultBaseURL+"/responses", nativeResponsesBody(openRouterLiveGeneralModel, tc))
61+ case "openrouter:bash":
62+ postOpenRouterJSON(t, ctx, key, openrouterDefaultBaseURL+"/messages", nativeMessagesBody(openRouterLiveGeneralModel, tc))
63+ default:
64+ stream, err := client.Stream(ctx, Request{
65+ Model: openRouterLiveGeneralModel,
66+ Tools: settingsTools,
67+ MaxTokens: 256,
68+ MaxToolCalls: 4,
69+ SessionID: "zot-live-settings-" + strings.TrimPrefix(tc.name, "openrouter:"),
70+ Messages: []Message{{
71+ Role: RoleUser,
72+ Content: []Content{TextBlock{Text: tc.prompt}},
73+ }},
74+ })
75+ if err != nil {
76+ t.Fatalf("Stream(%s): %v", tc.name, err)
77+ }
78+ var done EventDone
79+ for ev := range stream {
80+ if e, ok := ev.(EventDone); ok {
81+ done = e
82+ }
83+ }
84+ if done.Err != nil {
85+ t.Fatalf("EventDone.Err for %s: %v", tc.name, done.Err)
86+ }
87+ if done.Stop == StopError {
88+ t.Fatalf("stop=error for %s", tc.name)
89+ }
90+ }
91+ })
92+ }
93+}
94+
95+type openRouterServerToolCase struct {
96+ name string
97+ tool Tool
98+ prompt string
99+ // chatCompletionsUnsupported is true when OpenRouter documents the
100+ // tool as Chat Completions-incompatible (400).
101+ chatCompletionsUnsupported bool
102+}
103+
104+var openRouterServerToolCases = []openRouterServerToolCase{
105+ {
106+ name: "openrouter:web_search",
107+ tool: Tool{Name: "openrouter:web_search", Schema: json.RawMessage(`{"max_results":1}`)},
108+ prompt: "Search the web for 'OpenRouter AI'. You MUST call the web search tool, then reply with one sentence.",
109+ },
110+ {
111+ name: "openrouter:web_fetch",
112+ tool: Tool{Name: "openrouter:web_fetch"},
113+ prompt: "Fetch https://example.com and quote the h1. You MUST call the web fetch tool.",
114+ },
115+ {
116+ name: "openrouter:advisor",
117+ tool: Tool{Name: "openrouter:advisor", Schema: json.RawMessage(`{"model":"openai/gpt-4.1-mini"}`)},
118+ prompt: "Ask the advisor whether 17 is prime. You MUST call the advisor tool, then answer in one word.",
119+ },
120+ {
121+ name: "openrouter:subagent",
122+ tool: Tool{Name: "openrouter:subagent", Schema: json.RawMessage(`{"model":"openai/gpt-4.1-mini"}`)},
123+ prompt: "Delegate to a subagent: return the sum of 21 and 21. You MUST call the subagent tool.",
124+ },
125+ {
126+ name: "openrouter:fusion",
127+ tool: Tool{Name: "openrouter:fusion", Schema: json.RawMessage(`{"analysis_models":["openai/gpt-4.1-mini"],"model":"openai/gpt-4.1-mini"}`)},
128+ prompt: "Use fusion to answer in one sentence: what is 1+1? You MUST call the fusion tool.",
129+ },
130+ {
131+ name: "openrouter:shell",
132+ tool: Tool{Name: "openrouter:shell"},
133+ prompt: "Run `echo hi` in the hosted shell. You MUST call the shell tool.",
134+ chatCompletionsUnsupported: true,
135+ },
136+ {
137+ name: "openrouter:bash",
138+ tool: Tool{Name: "openrouter:bash"},
139+ prompt: "Run `echo hi` with bash. You MUST call the bash tool.",
140+ chatCompletionsUnsupported: true,
141+ },
142+ {
143+ name: "openrouter:apply_patch",
144+ tool: Tool{Name: "openrouter:apply_patch"},
145+ prompt: "Propose a patch that creates hello.txt with hello. You MUST call apply_patch.",
146+ chatCompletionsUnsupported: true,
147+ },
148+ {
149+ name: "openrouter:image_generation",
150+ tool: Tool{Name: "openrouter:image_generation"},
151+ prompt: "Generate a tiny image of a red square. You MUST call image generation, then describe the result in one sentence.",
152+ },
153+ {
154+ name: "openrouter:datetime",
155+ tool: Tool{Name: "openrouter:datetime"},
156+ prompt: "What is the current UTC datetime? You MUST call the datetime tool and then print only the datetime.",
157+ },
158+ {
159+ name: "openrouter:tool_search",
160+ tool: Tool{Name: "openrouter:tool_search"},
161+ prompt: "Find a tool that can add numbers. You MUST call tool_search first.",
162+ chatCompletionsUnsupported: true,
163+ },
164+ {
165+ name: "openrouter:experimental__search_models",
166+ tool: Tool{Name: "openrouter:experimental__search_models", Schema: json.RawMessage(`{"max_results":3}`)},
167+ prompt: "Search the OpenRouter catalog for cheap flash models. You MUST call the search models tool and list up to 3 ids.",
168+ },
169+}
170+
171+func runOpenRouterServerToolBattery(t *testing.T, model string) {
172+ t.Helper()
173+ if testing.Short() {
174+ t.Skip("skip live OpenRouter battery in -short mode")
175+ }
176+ key := strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY"))
177+ if key == "" {
178+ t.Skip("OPENROUTER_API_KEY not set")
179+ }
180+
181+ client := NewOpenRouter(key, "")
182+ for _, tc := range openRouterServerToolCases {
183+ tc := tc
184+ t.Run(tc.name, func(t *testing.T) {
185+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
186+ defer cancel()
187+
188+ tools := []Tool{tc.tool}
189+ if tc.name == "openrouter:tool_search" {
190+ tools = append(tools, Tool{
191+ Name: "add_numbers",
192+ Description: "Add two integers.",
193+ Schema: json.RawMessage(`{"type":"object","properties":{"a":{"type":"integer"},"b":{"type":"integer"}},"required":["a","b"]}`),
194+ Deferred: true,
195+ })
196+ }
197+
198+ stream, err := client.Stream(ctx, Request{
199+ Model: model,
200+ Tools: tools,
201+ MaxTokens: 256,
202+ MaxToolCalls: 4,
203+ SessionID: "zot-live-" + strings.ReplaceAll(model, "/", "-") + "-" + strings.TrimPrefix(tc.name, "openrouter:"),
204+ Messages: []Message{{
205+ Role: RoleUser,
206+ Content: []Content{TextBlock{Text: tc.prompt}},
207+ }},
208+ })
209+ if tc.chatCompletionsUnsupported {
210+ if err == nil {
211+ // Drain so the HTTP body is not leaked if the
212+ // server accepted the request anyway.
213+ for range stream {
214+ }
215+ t.Fatalf("expected chat-completions rejection for %s, got stream", tc.name)
216+ }
217+ if !strings.Contains(strings.ToLower(err.Error()), "chat-completions") &&
218+ !strings.Contains(strings.ToLower(err.Error()), "not available") {
219+ t.Fatalf("unexpected error for unsupported tool %s: %v", tc.name, err)
220+ }
221+ return
222+ }
223+ if err != nil {
224+ t.Fatalf("Stream(%s, %s): %v", model, tc.name, err)
225+ }
226+ var done EventDone
227+ for ev := range stream {
228+ if e, ok := ev.(EventDone); ok {
229+ done = e
230+ }
231+ }
232+ if done.Err != nil {
233+ t.Fatalf("EventDone.Err for %s/%s: %v", model, tc.name, done.Err)
234+ }
235+ if done.Stop == StopError {
236+ t.Fatalf("stop=error for %s/%s", model, tc.name)
237+ }
238+ })
239+ }
240+}
241+
242+func runOpenRouterServerToolBatteryOnNativeAPIs(t *testing.T, model string) {
243+ t.Helper()
244+ if testing.Short() {
245+ t.Skip("skip live OpenRouter battery in -short mode")
246+ }
247+ key := strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY"))
248+ if key == "" {
249+ t.Skip("OPENROUTER_API_KEY not set")
250+ }
251+
252+ client := NewOpenRouter(key, "")
253+ for _, tc := range openRouterServerToolCases {
254+ tc := tc
255+ t.Run(tc.name, func(t *testing.T) {
256+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
257+ defer cancel()
258+ switch tc.name {
259+ case "openrouter:shell", "openrouter:apply_patch", "openrouter:tool_search":
260+ postOpenRouterJSON(t, ctx, key, openrouterDefaultBaseURL+"/responses", nativeResponsesBody(model, tc))
261+ case "openrouter:bash":
262+ postOpenRouterJSON(t, ctx, key, openrouterDefaultBaseURL+"/messages", nativeMessagesBody(model, tc))
263+ default:
264+ runOpenRouterChatTool(t, ctx, client, model, tc)
265+ }
266+ })
267+ }
268+}
269+
270+func runOpenRouterChatTool(t *testing.T, ctx context.Context, client Client, model string, tc openRouterServerToolCase) {
271+ t.Helper()
272+ stream, err := client.Stream(ctx, Request{
273+ Model: model,
274+ Tools: []Tool{tc.tool},
275+ MaxTokens: 256,
276+ MaxToolCalls: 4,
277+ SessionID: "zot-live-general-" + strings.TrimPrefix(tc.name, "openrouter:"),
278+ Messages: []Message{{
279+ Role: RoleUser,
280+ Content: []Content{TextBlock{Text: tc.prompt}},
281+ }},
282+ })
283+ if err != nil {
284+ t.Fatalf("Stream(%s, %s): %v", model, tc.name, err)
285+ }
286+ var done EventDone
287+ for ev := range stream {
288+ if e, ok := ev.(EventDone); ok {
289+ done = e
290+ }
291+ }
292+ if done.Err != nil {
293+ t.Fatalf("EventDone.Err for %s/%s: %v", model, tc.name, done.Err)
294+ }
295+ if done.Stop == StopError {
296+ t.Fatalf("stop=error for %s/%s", model, tc.name)
297+ }
298+}
299+
300+func nativeResponsesBody(model string, tc openRouterServerToolCase) map[string]any {
301+ tool := map[string]any{"type": tc.name}
302+ switch tc.name {
303+ case "openrouter:shell":
304+ tool["parameters"] = map[string]any{"engine": "openrouter"}
305+ case "openrouter:tool_search":
306+ return map[string]any{
307+ "model": model,
308+ "input": tc.prompt,
309+ "tools": []any{
310+ map[string]any{"type": "openrouter:tool_search"},
311+ map[string]any{
312+ "type": "function",
313+ "name": "add_numbers",
314+ "description": "Add two integers.",
315+ "parameters": map[string]any{
316+ "type": "object",
317+ "properties": map[string]any{"a": map[string]any{"type": "integer"}, "b": map[string]any{"type": "integer"}},
318+ "required": []string{"a", "b"},
319+ },
320+ "defer_loading": true,
321+ },
322+ },
323+ }
324+ }
325+ return map[string]any{
326+ "model": model,
327+ "input": tc.prompt,
328+ "tools": []any{tool},
329+ }
330+}
331+
332+func nativeMessagesBody(model string, tc openRouterServerToolCase) map[string]any {
333+ return map[string]any{
334+ "model": model,
335+ "max_tokens": 256,
336+ "messages": []any{map[string]any{"role": "user", "content": tc.prompt}},
337+ "tools": []any{map[string]any{
338+ "type": tc.name,
339+ "parameters": map[string]any{"engine": "openrouter"},
340+ }},
341+ }
342+}
343+
344+func postOpenRouterJSON(t *testing.T, ctx context.Context, key, url string, body map[string]any) {
345+ t.Helper()
346+ payload, err := json.Marshal(body)
347+ if err != nil {
348+ t.Fatal(err)
349+ }
350+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
351+ if err != nil {
352+ t.Fatal(err)
353+ }
354+ req.Header.Set("Authorization", "Bearer "+key)
355+ req.Header.Set("Content-Type", "application/json")
356+ resp, err := http.DefaultClient.Do(req)
357+ if err != nil {
358+ t.Fatalf("%s: %v", url, err)
359+ }
360+ defer resp.Body.Close()
361+ raw, _ := io.ReadAll(resp.Body)
362+ if resp.StatusCode != http.StatusOK {
363+ t.Fatalf("%s http %d: %s", url, resp.StatusCode, truncateLiveBody(raw))
364+ }
365+ var decoded map[string]any
366+ if err := json.Unmarshal(raw, &decoded); err != nil {
367+ t.Fatalf("decode %s: %v\n%s", url, err, truncateLiveBody(raw))
368+ }
369+ if errObj, ok := decoded["error"]; ok && errObj != nil {
370+ t.Fatalf("%s error: %v", url, errObj)
371+ }
372+}
373+
374+func truncateLiveBody(raw []byte) string {
375+ s := strings.TrimSpace(string(raw))
376+ if len(s) > 400 {
377+ return s[:400] + fmt.Sprintf("... (%d bytes)", len(s))
378+ }
379+ return s
380+}
@@ -0,0 +1,380 @@
1+package provider
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "net/http"
10+ "os"
11+ "strings"
12+ "testing"
13+ "time"
14+)
15+
16+const openRouterLiveGeneralModel = "openai/gpt-4.1"
17+
18+// Live OpenRouter server-tool batteries. Skipped unless OPENROUTER_API_KEY
19+// is set so CI and unpaid checkouts never hit the network.
20+func TestOpenRouterServerToolsLiveDeepSeekFlash(t *testing.T) {
21+ runOpenRouterServerToolBattery(t, "deepseek/deepseek-v4-flash")
22+}
23+
24+func TestOpenRouterServerToolsLivePresetFlash(t *testing.T) {
25+ runOpenRouterServerToolBattery(t, "@preset/flash")
26+}
27+
28+func TestOpenRouterServerToolsLiveGeneral(t *testing.T) {
29+ runOpenRouterServerToolBatteryOnNativeAPIs(t, openRouterLiveGeneralModel)
30+}
31+
32+func TestOpenRouterServerToolsLiveGeneralWithSettingsEnabled(t *testing.T) {
33+ // Same multidisciplinary model as the settings-on agent path. Chat
34+ // Completions-capable tools are advertised together (the setting's
35+ // default-on registry); shell/bash/apply_patch/tool_search still use
36+ // their native APIs.
37+ if testing.Short() {
38+ t.Skip("skip live OpenRouter battery in -short mode")
39+ }
40+ key := strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY"))
41+ if key == "" {
42+ t.Skip("OPENROUTER_API_KEY not set")
43+ }
44+
45+ client := NewOpenRouter(key, "")
46+ settingsTools := make([]Tool, 0, len(openRouterServerToolCases))
47+ for _, tc := range openRouterServerToolCases {
48+ if !tc.chatCompletionsUnsupported {
49+ settingsTools = append(settingsTools, tc.tool)
50+ }
51+ }
52+
53+ for _, tc := range openRouterServerToolCases {
54+ tc := tc
55+ t.Run(tc.name, func(t *testing.T) {
56+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
57+ defer cancel()
58+ switch tc.name {
59+ case "openrouter:shell", "openrouter:apply_patch", "openrouter:tool_search":
60+ postOpenRouterJSON(t, ctx, key, openrouterDefaultBaseURL+"/responses", nativeResponsesBody(openRouterLiveGeneralModel, tc))
61+ case "openrouter:bash":
62+ postOpenRouterJSON(t, ctx, key, openrouterDefaultBaseURL+"/messages", nativeMessagesBody(openRouterLiveGeneralModel, tc))
63+ default:
64+ stream, err := client.Stream(ctx, Request{
65+ Model: openRouterLiveGeneralModel,
66+ Tools: settingsTools,
67+ MaxTokens: 256,
68+ MaxToolCalls: 4,
69+ SessionID: "zot-live-settings-" + strings.TrimPrefix(tc.name, "openrouter:"),
70+ Messages: []Message{{
71+ Role: RoleUser,
72+ Content: []Content{TextBlock{Text: tc.prompt}},
73+ }},
74+ })
75+ if err != nil {
76+ t.Fatalf("Stream(%s): %v", tc.name, err)
77+ }
78+ var done EventDone
79+ for ev := range stream {
80+ if e, ok := ev.(EventDone); ok {
81+ done = e
82+ }
83+ }
84+ if done.Err != nil {
85+ t.Fatalf("EventDone.Err for %s: %v", tc.name, done.Err)
86+ }
87+ if done.Stop == StopError {
88+ t.Fatalf("stop=error for %s", tc.name)
89+ }
90+ }
91+ })
92+ }
93+}
94+
95+type openRouterServerToolCase struct {
96+ name string
97+ tool Tool
98+ prompt string
99+ // chatCompletionsUnsupported is true when OpenRouter documents the
100+ // tool as Chat Completions-incompatible (400).
101+ chatCompletionsUnsupported bool
102+}
103+
104+var openRouterServerToolCases = []openRouterServerToolCase{
105+ {
106+ name: "openrouter:web_search",
107+ tool: Tool{Name: "openrouter:web_search", Schema: json.RawMessage(`{"max_results":1}`)},
108+ prompt: "Search the web for 'OpenRouter AI'. You MUST call the web search tool, then reply with one sentence.",
109+ },
110+ {
111+ name: "openrouter:web_fetch",
112+ tool: Tool{Name: "openrouter:web_fetch"},
113+ prompt: "Fetch https://example.com and quote the h1. You MUST call the web fetch tool.",
114+ },
115+ {
116+ name: "openrouter:advisor",
117+ tool: Tool{Name: "openrouter:advisor", Schema: json.RawMessage(`{"model":"openai/gpt-4.1-mini"}`)},
118+ prompt: "Ask the advisor whether 17 is prime. You MUST call the advisor tool, then answer in one word.",
119+ },
120+ {
121+ name: "openrouter:subagent",
122+ tool: Tool{Name: "openrouter:subagent", Schema: json.RawMessage(`{"model":"openai/gpt-4.1-mini"}`)},
123+ prompt: "Delegate to a subagent: return the sum of 21 and 21. You MUST call the subagent tool.",
124+ },
125+ {
126+ name: "openrouter:fusion",
127+ tool: Tool{Name: "openrouter:fusion", Schema: json.RawMessage(`{"analysis_models":["openai/gpt-4.1-mini"],"model":"openai/gpt-4.1-mini"}`)},
128+ prompt: "Use fusion to answer in one sentence: what is 1+1? You MUST call the fusion tool.",
129+ },
130+ {
131+ name: "openrouter:shell",
132+ tool: Tool{Name: "openrouter:shell"},
133+ prompt: "Run `echo hi` in the hosted shell. You MUST call the shell tool.",
134+ chatCompletionsUnsupported: true,
135+ },
136+ {
137+ name: "openrouter:bash",
138+ tool: Tool{Name: "openrouter:bash"},
139+ prompt: "Run `echo hi` with bash. You MUST call the bash tool.",
140+ chatCompletionsUnsupported: true,
141+ },
142+ {
143+ name: "openrouter:apply_patch",
144+ tool: Tool{Name: "openrouter:apply_patch"},
145+ prompt: "Propose a patch that creates hello.txt with hello. You MUST call apply_patch.",
146+ chatCompletionsUnsupported: true,
147+ },
148+ {
149+ name: "openrouter:image_generation",
150+ tool: Tool{Name: "openrouter:image_generation"},
151+ prompt: "Generate a tiny image of a red square. You MUST call image generation, then describe the result in one sentence.",
152+ },
153+ {
154+ name: "openrouter:datetime",
155+ tool: Tool{Name: "openrouter:datetime"},
156+ prompt: "What is the current UTC datetime? You MUST call the datetime tool and then print only the datetime.",
157+ },
158+ {
159+ name: "openrouter:tool_search",
160+ tool: Tool{Name: "openrouter:tool_search"},
161+ prompt: "Find a tool that can add numbers. You MUST call tool_search first.",
162+ chatCompletionsUnsupported: true,
163+ },
164+ {
165+ name: "openrouter:experimental__search_models",
166+ tool: Tool{Name: "openrouter:experimental__search_models", Schema: json.RawMessage(`{"max_results":3}`)},
167+ prompt: "Search the OpenRouter catalog for cheap flash models. You MUST call the search models tool and list up to 3 ids.",
168+ },
169+}
170+
171+func runOpenRouterServerToolBattery(t *testing.T, model string) {
172+ t.Helper()
173+ if testing.Short() {
174+ t.Skip("skip live OpenRouter battery in -short mode")
175+ }
176+ key := strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY"))
177+ if key == "" {
178+ t.Skip("OPENROUTER_API_KEY not set")
179+ }
180+
181+ client := NewOpenRouter(key, "")
182+ for _, tc := range openRouterServerToolCases {
183+ tc := tc
184+ t.Run(tc.name, func(t *testing.T) {
185+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
186+ defer cancel()
187+
188+ tools := []Tool{tc.tool}
189+ if tc.name == "openrouter:tool_search" {
190+ tools = append(tools, Tool{
191+ Name: "add_numbers",
192+ Description: "Add two integers.",
193+ Schema: json.RawMessage(`{"type":"object","properties":{"a":{"type":"integer"},"b":{"type":"integer"}},"required":["a","b"]}`),
194+ Deferred: true,
195+ })
196+ }
197+
198+ stream, err := client.Stream(ctx, Request{
199+ Model: model,
200+ Tools: tools,
201+ MaxTokens: 256,
202+ MaxToolCalls: 4,
203+ SessionID: "zot-live-" + strings.ReplaceAll(model, "/", "-") + "-" + strings.TrimPrefix(tc.name, "openrouter:"),
204+ Messages: []Message{{
205+ Role: RoleUser,
206+ Content: []Content{TextBlock{Text: tc.prompt}},
207+ }},
208+ })
209+ if tc.chatCompletionsUnsupported {
210+ if err == nil {
211+ // Drain so the HTTP body is not leaked if the
212+ // server accepted the request anyway.
213+ for range stream {
214+ }
215+ t.Fatalf("expected chat-completions rejection for %s, got stream", tc.name)
216+ }
217+ if !strings.Contains(strings.ToLower(err.Error()), "chat-completions") &&
218+ !strings.Contains(strings.ToLower(err.Error()), "not available") {
219+ t.Fatalf("unexpected error for unsupported tool %s: %v", tc.name, err)
220+ }
221+ return
222+ }
223+ if err != nil {
224+ t.Fatalf("Stream(%s, %s): %v", model, tc.name, err)
225+ }
226+ var done EventDone
227+ for ev := range stream {
228+ if e, ok := ev.(EventDone); ok {
229+ done = e
230+ }
231+ }
232+ if done.Err != nil {
233+ t.Fatalf("EventDone.Err for %s/%s: %v", model, tc.name, done.Err)
234+ }
235+ if done.Stop == StopError {
236+ t.Fatalf("stop=error for %s/%s", model, tc.name)
237+ }
238+ })
239+ }
240+}
241+
242+func runOpenRouterServerToolBatteryOnNativeAPIs(t *testing.T, model string) {
243+ t.Helper()
244+ if testing.Short() {
245+ t.Skip("skip live OpenRouter battery in -short mode")
246+ }
247+ key := strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY"))
248+ if key == "" {
249+ t.Skip("OPENROUTER_API_KEY not set")
250+ }
251+
252+ client := NewOpenRouter(key, "")
253+ for _, tc := range openRouterServerToolCases {
254+ tc := tc
255+ t.Run(tc.name, func(t *testing.T) {
256+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
257+ defer cancel()
258+ switch tc.name {
259+ case "openrouter:shell", "openrouter:apply_patch", "openrouter:tool_search":
260+ postOpenRouterJSON(t, ctx, key, openrouterDefaultBaseURL+"/responses", nativeResponsesBody(model, tc))
261+ case "openrouter:bash":
262+ postOpenRouterJSON(t, ctx, key, openrouterDefaultBaseURL+"/messages", nativeMessagesBody(model, tc))
263+ default:
264+ runOpenRouterChatTool(t, ctx, client, model, tc)
265+ }
266+ })
267+ }
268+}
269+
270+func runOpenRouterChatTool(t *testing.T, ctx context.Context, client Client, model string, tc openRouterServerToolCase) {
271+ t.Helper()
272+ stream, err := client.Stream(ctx, Request{
273+ Model: model,
274+ Tools: []Tool{tc.tool},
275+ MaxTokens: 256,
276+ MaxToolCalls: 4,
277+ SessionID: "zot-live-general-" + strings.TrimPrefix(tc.name, "openrouter:"),
278+ Messages: []Message{{
279+ Role: RoleUser,
280+ Content: []Content{TextBlock{Text: tc.prompt}},
281+ }},
282+ })
283+ if err != nil {
284+ t.Fatalf("Stream(%s, %s): %v", model, tc.name, err)
285+ }
286+ var done EventDone
287+ for ev := range stream {
288+ if e, ok := ev.(EventDone); ok {
289+ done = e
290+ }
291+ }
292+ if done.Err != nil {
293+ t.Fatalf("EventDone.Err for %s/%s: %v", model, tc.name, done.Err)
294+ }
295+ if done.Stop == StopError {
296+ t.Fatalf("stop=error for %s/%s", model, tc.name)
297+ }
298+}
299+
300+func nativeResponsesBody(model string, tc openRouterServerToolCase) map[string]any {
301+ tool := map[string]any{"type": tc.name}
302+ switch tc.name {
303+ case "openrouter:shell":
304+ tool["parameters"] = map[string]any{"engine": "openrouter"}
305+ case "openrouter:tool_search":
306+ return map[string]any{
307+ "model": model,
308+ "input": tc.prompt,
309+ "tools": []any{
310+ map[string]any{"type": "openrouter:tool_search"},
311+ map[string]any{
312+ "type": "function",
313+ "name": "add_numbers",
314+ "description": "Add two integers.",
315+ "parameters": map[string]any{
316+ "type": "object",
317+ "properties": map[string]any{"a": map[string]any{"type": "integer"}, "b": map[string]any{"type": "integer"}},
318+ "required": []string{"a", "b"},
319+ },
320+ "defer_loading": true,
321+ },
322+ },
323+ }
324+ }
325+ return map[string]any{
326+ "model": model,
327+ "input": tc.prompt,
328+ "tools": []any{tool},
329+ }
330+}
331+
332+func nativeMessagesBody(model string, tc openRouterServerToolCase) map[string]any {
333+ return map[string]any{
334+ "model": model,
335+ "max_tokens": 256,
336+ "messages": []any{map[string]any{"role": "user", "content": tc.prompt}},
337+ "tools": []any{map[string]any{
338+ "type": tc.name,
339+ "parameters": map[string]any{"engine": "openrouter"},
340+ }},
341+ }
342+}
343+
344+func postOpenRouterJSON(t *testing.T, ctx context.Context, key, url string, body map[string]any) {
345+ t.Helper()
346+ payload, err := json.Marshal(body)
347+ if err != nil {
348+ t.Fatal(err)
349+ }
350+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
351+ if err != nil {
352+ t.Fatal(err)
353+ }
354+ req.Header.Set("Authorization", "Bearer "+key)
355+ req.Header.Set("Content-Type", "application/json")
356+ resp, err := http.DefaultClient.Do(req)
357+ if err != nil {
358+ t.Fatalf("%s: %v", url, err)
359+ }
360+ defer resp.Body.Close()
361+ raw, _ := io.ReadAll(resp.Body)
362+ if resp.StatusCode != http.StatusOK {
363+ t.Fatalf("%s http %d: %s", url, resp.StatusCode, truncateLiveBody(raw))
364+ }
365+ var decoded map[string]any
366+ if err := json.Unmarshal(raw, &decoded); err != nil {
367+ t.Fatalf("decode %s: %v\n%s", url, err, truncateLiveBody(raw))
368+ }
369+ if errObj, ok := decoded["error"]; ok && errObj != nil {
370+ t.Fatalf("%s error: %v", url, errObj)
371+ }
372+}
373+
374+func truncateLiveBody(raw []byte) string {
375+ s := strings.TrimSpace(string(raw))
376+ if len(s) > 400 {
377+ return s[:400] + fmt.Sprintf("... (%d bytes)", len(s))
378+ }
379+ return s
380+}
M packages/provider/provider.go +10 -0
@@ -49,6 +49,10 @@ type ToolCallBlock struct {
4949 Name string `json:"name"`
5050 Arguments json.RawMessage `json:"arguments"`
5151 ThoughtSignature string `json:"thought_signature,omitempty"`
52+ // Server is true when the provider executes this tool itself
53+ // (OpenRouter server tools). The agent must not run these locally
54+ // or send a client tool_result for them.
55+ Server bool `json:"server,omitempty"`
5256 }
5357
5458 func (ToolCallBlock) isContent() {}
@@ -257,6 +261,12 @@ type Request struct {
257261 // "max". Empty disables reasoning. The max tier is sent natively only to
258262 // models that support it and clamped elsewhere.
259263 Reasoning string
264+ // SessionID, when set, is the conversation id. Providers that support
265+ // sticky routing (OpenRouter session_id) forward it; others ignore it.
266+ SessionID string
267+ // MaxToolCalls, when set, caps OpenRouter's server-tool agent loop.
268+ // Zero omits the field.
269+ MaxToolCalls int
260270 }
261271
262272 // Client is an LLM streaming client.
@@ -49,6 +49,10 @@ type ToolCallBlock struct {
49 Name string `json:"name"`49 Name string `json:"name"`
50 Arguments json.RawMessage `json:"arguments"`50 Arguments json.RawMessage `json:"arguments"`
51 ThoughtSignature string `json:"thought_signature,omitempty"`51 ThoughtSignature string `json:"thought_signature,omitempty"`
52+ // Server is true when the provider executes this tool itself
53+ // (OpenRouter server tools). The agent must not run these locally
54+ // or send a client tool_result for them.
55+ Server bool `json:"server,omitempty"`
52 }56 }
53 57
54 func (ToolCallBlock) isContent() {}58 func (ToolCallBlock) isContent() {}
@@ -257,6 +261,12 @@ type Request struct {
257 // "max". Empty disables reasoning. The max tier is sent natively only to261 // "max". Empty disables reasoning. The max tier is sent natively only to
258 // models that support it and clamped elsewhere.262 // models that support it and clamped elsewhere.
259 Reasoning string263 Reasoning string
264+ // SessionID, when set, is the conversation id. Providers that support
265+ // sticky routing (OpenRouter session_id) forward it; others ignore it.
266+ SessionID string
267+ // MaxToolCalls, when set, caps OpenRouter's server-tool agent loop.
268+ // Zero omits the field.
269+ MaxToolCalls int
260 }270 }
261 271
262 // Client is an LLM streaming client.272 // Client is an LLM streaming client.

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