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 Unverified
B5690EEEBB952194The 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 | |||
| 403 | 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 | 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 | 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 | 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 | 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 | 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). | |
| @@ -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 | |||
| 304 | 304 | return nil, nil, err | |
| 305 | 305 | } | |
| 306 | 306 | ag.SetMessages(msgs) | |
| 307 | + | bindAgentSession(ag, s) | |
| 307 | 308 | return s, nil, nil | |
| 308 | 309 | } | |
| 309 | 310 | } | |
| 310 | 311 | s, err := core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version) | |
| 312 | + | if err == nil { | |
| 313 | + | bindAgentSession(ag, s) | |
| 314 | + | } | |
| 311 | 315 | return s, nil, err | |
| 312 | 316 | } | |
| 313 | 317 | ||
| @@ -304,10 +304,14 @@ func openOrCreateSessionForBot(args Args, r Resolved, ag *core.Agent, version st | |||||
| 304 | return nil, nil, err | 304 | return nil, nil, err | ||
| 305 | } | 305 | } | ||
| 306 | ag.SetMessages(msgs) | 306 | ag.SetMessages(msgs) | ||
| 307 | + | bindAgentSession(ag, s) | |||
| 307 | return s, nil, nil | 308 | 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, err | 315 | 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) { | |||
| 574 | 574 | sandbox.SetPermissions(args.PermissionSet) | |
| 575 | 575 | } | |
| 576 | 576 | reg := buildToolRegistry(args, args.CWD, sandbox) | |
| 577 | + | if !args.NoTools && provName == "openrouter" && !isOpenRouterPresetModel(model) && OpenRouterServerToolsEnabled() { | |
| 578 | + | reg = withOpenRouterServerTools(reg) | |
| 579 | + | } | |
| 577 | 580 | ||
| 578 | 581 | docsDir, _ := zotdocs.EnsureInstalled(ZotHome()) | |
| 579 | 582 | ||
| @@ -984,9 +987,46 @@ func (r Resolved) NewAgent() *core.Agent { | |||
| 984 | 987 | a.MaxTokens = r.MaxOutput | |
| 985 | 988 | a.Reasoning = r.Reasoning | |
| 986 | 989 | a.Temperature = r.Temperature | |
| 990 | + | if hasOpenRouterServerTools(r.ToolRegistry) { | |
| 991 | + | a.MaxToolCalls = tools.OpenRouterServerToolCallLimit | |
| 992 | + | } | |
| 987 | 993 | 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 | 1030 | func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry { | |
| 991 | 1031 | if args.NoTools { | |
| 992 | 1032 | 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.MaxOutput | 987 | a.MaxTokens = r.MaxOutput | ||
| 985 | a.Reasoning = r.Reasoning | 988 | a.Reasoning = r.Reasoning | ||
| 986 | a.Temperature = r.Temperature | 989 | a.Temperature = r.Temperature | ||
| 990 | + | if hasOpenRouterServerTools(r.ToolRegistry) { | |||
| 991 | + | a.MaxToolCalls = tools.OpenRouterServerToolCallLimit | |||
| 992 | + | } | |||
| 987 | return a | 993 | 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 ( | |||
| 9 | 9 | "strings" | |
| 10 | 10 | "testing" | |
| 11 | 11 | ||
| 12 | + | "github.com/patriceckhart/zot/packages/agent/tools" | |
| 12 | 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 | 315 | func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) { | |
| 222 | 316 | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 223 | 317 | 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 { | |||
| 978 | 978 | // (login, /model swap to a different provider) also gets the | |
| 979 | 979 | // persistence hooks. Without this, switching provider would | |
| 980 | 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 | 987 | baseBuildAgent := buildAgent | |
| 982 | 988 | buildAgent = func() (*core.Agent, string, string, error) { | |
| 983 | 989 | 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 | |
| 985 | 993 | } | |
| 986 | 994 | baseBuildAgentFor := buildAgentFor | |
| 987 | 995 | buildAgentFor = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) { | |
| 988 | 996 | 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 | |
| 990 | 1000 | } | |
| 991 | 1001 | baseBuildAgentForRescue := buildAgentForRescue | |
| 992 | 1002 | buildAgentForRescue = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) { | |
| 993 | 1003 | 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 | |
| 995 | 1007 | } | |
| 996 | 1008 | ||
| 997 | 1009 | // 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 | 1032 | sess = newSess | |
| 1021 | 1033 | currentAg.SetMessages(msgs) | |
| 1034 | + | bindAgentSession(currentAg, sess) | |
| 1022 | 1035 | if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil { | |
| 1023 | 1036 | currentAg.SeedCost(cum) | |
| 1024 | 1037 | currentAg.SeedLastTurnUsage(last) | |
| @@ -1152,6 +1165,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1152 | 1165 | sess = newSess | |
| 1153 | 1166 | sessBaselineMsgs = 0 | |
| 1154 | 1167 | persistMu.Unlock() | |
| 1168 | + | bindAgentSession(newAg, newSess) | |
| 1155 | 1169 | } | |
| 1156 | 1170 | ||
| 1157 | 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 | 1266 | 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, | |
| 1295 | 1311 | OnStartupPreDone: func() { | |
| 1296 | 1312 | // entry.pre often installs skills/extensions; rediscover them | |
| 1297 | 1313 | // before the user starts a model turn. | |
| @@ -1475,7 +1491,8 @@ func agentSessionsRoot(root string, args Args) string { | |||
| 1475 | 1491 | } | |
| 1476 | 1492 | ||
| 1477 | 1493 | // 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 | 1496 | func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) { | |
| 1480 | 1497 | if args.NoSess { | |
| 1481 | 1498 | return nil, nil | |
| @@ -1522,15 +1539,20 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) | |||
| 1522 | 1539 | if err != nil { | |
| 1523 | 1540 | 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 | 1548 | ag.SetMessages(msgs) | |
| 1527 | 1549 | if cum, last, uerr := core.SessionUsageDetail(s.Path); uerr == nil { | |
| 1528 | 1550 | ag.SeedCost(cum) | |
| 1529 | 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 | 1558 | func pickSession(root, cwd string) (string, error) { | |
| @@ -1561,6 +1583,16 @@ func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) { | |||
| 1561 | 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 | 1596 | // writeNewTranscriptLocked is the same as WriteNewTranscript. The | |
| 1565 | 1597 | // suffix marks that interactive callers must hold persistMu when | |
| 1566 | 1598 | // 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 the | 978 | // (login, /model swap to a different provider) also gets the | ||
| 979 | // persistence hooks. Without this, switching provider would | 979 | // 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 := buildAgent | 987 | 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, err | 990 | + | a = wireAgentPersist(a) |
| 991 | + | bindLiveAgentSession(a) | |||
| 992 | + | return a, p, m, err | |||
| 985 | } | 993 | } | ||
| 986 | baseBuildAgentFor := buildAgentFor | 994 | 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, err | 997 | + | a = wireAgentPersist(a) |
| 998 | + | bindLiveAgentSession(a) | |||
| 999 | + | return a, p, m, err | |||
| 990 | } | 1000 | } | ||
| 991 | baseBuildAgentForRescue := buildAgentForRescue | 1001 | 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, err | 1004 | + | 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 and | 1009 | // 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 = newSess | 1032 | 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 = newSess | 1165 | sess = newSess | ||
| 1153 | sessBaselineMsgs = 0 | 1166 | 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 them | 1312 | // 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 nil | 1493 | // 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, nil | 1498 | 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, err | 1540 | 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. The | 1596 | // writeNewTranscriptLocked is the same as WriteNewTranscript. The | ||
| 1565 | // suffix marks that interactive callers must hold persistMu when | 1597 | // suffix marks that interactive callers must hold persistMu when | ||
| 1566 | // invoking it so concurrent appends from the agent loop don't race | 1598 | // 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) | |||
| 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 | 51 | func TestLiveInteractiveAgentFallsBackBeforeInteractiveConstruction(t *testing.T) { | |
| 36 | 52 | startup := core.NewAgent(nil, "startup-model", "", nil) | |
| 37 | 53 | 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 { | |||
| 68 | 68 | // The live session can still override this with /jail or /unjail. | |
| 69 | 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 | 76 | // RecursiveFileSuggest controls the @-mention file picker. When true | |
| 72 | 77 | // the picker fuzzy-searches the whole project tree below the working | |
| 73 | 78 | // 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 true | 76 | // RecursiveFileSuggest controls the @-mention file picker. When true | ||
| 72 | // the picker fuzzy-searches the whole project tree below the working | 77 | // 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 | |||
| 174 | 174 | ||
| 175 | 175 | func refreshModels() { | |
| 176 | 176 | cached, _ := provider.LoadCache(ModelCachePath()) | |
| 177 | - | if cached.IsFresh() { | |
| 178 | - | return | |
| 179 | - | } | |
| 177 | + | cacheFresh := cached.IsFresh() | |
| 180 | 178 | ||
| 181 | 179 | ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) | |
| 182 | 180 | defer cancel() | |
| 183 | 181 | ||
| 184 | 182 | 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" { | |
| 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 | + | } | |
| 192 | 200 | } | |
| 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 | + | } | |
| 197 | 205 | } | |
| 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...) | |
| 204 | 213 | } | |
| 205 | - | all = append(all, live...) | |
| 206 | 214 | } | |
| 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 | + | } | |
| 211 | 219 | } | |
| 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 | + | } | |
| 218 | 233 | } | |
| 219 | 234 | } | |
| 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) | |
| 225 | 242 | } | |
| 226 | 243 | } | |
| 227 | 244 | ||
| @@ -230,7 +247,27 @@ func refreshModels() { | |||
| 230 | 247 | } | |
| 231 | 248 | provider.SetLiveModels(all) | |
| 232 | 249 | _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{ | |
| 233 | - | FetchedAt: time.Now().UTC(), | |
| 250 | + | FetchedAt: modelCacheFetchedAt(cached, !cacheFresh, time.Now().UTC()), | |
| 234 | 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 | + | } | |
| @@ -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.Model | 182 | 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 can | 191 | + | all = append(all, cached.Models...) |
| 188 | - | // also list models via the bearer header, but we skip OAuth | 192 | + | } 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 only | 222 | + | // 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 so | 236 | + | // 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 ( | |||
| 9 | 9 | "os" | |
| 10 | 10 | "path/filepath" | |
| 11 | 11 | "testing" | |
| 12 | + | "time" | |
| 12 | 13 | ||
| 13 | 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 | 182 | func TestValidateAndRepairConfig_OpenRouterPreservesRoutedModelID(t *testing.T) { | |
| 144 | 183 | home := t.TempDir() | |
| 145 | 184 | 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 { | |||
| 72 | 72 | // The current sandbox may differ after a session-scoped /jail or /unjail. | |
| 73 | 73 | 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 | 82 | // RecursiveFileSuggest mirrors the persisted recursive_file_suggest | |
| 76 | 83 | // flag at startup. When true the @-mention picker fuzzy-searches the | |
| 77 | 84 | // whole project tree instead of browsing one directory at a time. | |
| @@ -334,6 +341,7 @@ type SettingsStore interface { | |||
| 334 | 341 | SetInlineImages(enabled bool) error | |
| 335 | 342 | SetAutoSwarm(enabled bool) error | |
| 336 | 343 | SetJailByDefault(enabled bool) error | |
| 344 | + | SetOpenRouterServerTools(enabled bool) error | |
| 337 | 345 | SetRecursiveFileSuggest(enabled bool) error | |
| 338 | 346 | SetRespectGitignore(enabled bool) error | |
| 339 | 347 | SetCompactMode(enabled bool) error | |
| @@ -678,6 +686,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 678 | 686 | if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled { | |
| 679 | 687 | i.applyAutoSwarmTool(true) | |
| 680 | 688 | } | |
| 689 | + | i.applyOpenRouterServerTools(i.openRouterServerToolsEnabled()) | |
| 681 | 690 | return i | |
| 682 | 691 | } | |
| 683 | 692 | ||
| @@ -3383,6 +3392,19 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3383 | 3392 | } | |
| 3384 | 3393 | ||
| 3385 | 3394 | 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 | 3408 | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | |
| 3387 | 3409 | respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore | |
| 3388 | 3410 | compactMode := i.compactModeEnabled() | |
| @@ -3501,6 +3523,14 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3501 | 3523 | desc: "confine tools to the session working directory unless /unjail is used", | |
| 3502 | 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 | 3535 | key: "recursive_file_suggest", | |
| 3506 | 3536 | label: "recursive @-file search", | |
| @@ -3861,6 +3891,22 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 3861 | 3891 | i.statusOK = "jail by default " + onOff(value) | |
| 3862 | 3892 | i.statusErr = "" | |
| 3863 | 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 | 3910 | case "recursive_file_suggest": | |
| 3865 | 3911 | val := value | |
| 3866 | 3912 | i.cfg.RecursiveFileSuggest = &val | |
| @@ -5365,6 +5411,7 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string) | |||
| 5365 | 5411 | // helpers are no-ops when their feature is inactive, so the | |
| 5366 | 5412 | // cross-provider path still works on a vanilla setup. | |
| 5367 | 5413 | i.applyAutoSwarmTool(i.autoSwarmEnabled()) | |
| 5414 | + | i.applyOpenRouterServerTools(i.openRouterServerToolsEnabled()) | |
| 5368 | 5415 | i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active()) | |
| 5369 | 5416 | if i.cfg.PersistModel != nil { | |
| 5370 | 5417 | i.cfg.PersistModel(p, md) | |
| @@ -5394,6 +5441,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) { | |||
| 5394 | 5441 | i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method | |
| 5395 | 5442 | i.mu.Unlock() | |
| 5396 | 5443 | i.applyAutoSwarmTool(i.autoSwarmEnabled()) | |
| 5444 | + | i.applyOpenRouterServerTools(i.openRouterServerToolsEnabled()) | |
| 5397 | 5445 | i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active()) | |
| 5398 | 5446 | i.dialog.ShowResult(true, "") | |
| 5399 | 5447 | } | |
| @@ -6575,6 +6623,32 @@ func (i *Interactive) autoSwarmEnabled() bool { | |||
| 6575 | 6623 | 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 | 6652 | func (i *Interactive) applyAutoSwarmTool(active bool) { | |
| 6579 | 6653 | if i.agent == nil { | |
| 6580 | 6654 | 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 *bool | 73 | 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_suggest | 82 | // RecursiveFileSuggest mirrors the persisted recursive_file_suggest | ||
| 76 | // flag at startup. When true the @-mention picker fuzzy-searches the | 83 | // 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) error | 341 | SetInlineImages(enabled bool) error | ||
| 335 | SetAutoSwarm(enabled bool) error | 342 | SetAutoSwarm(enabled bool) error | ||
| 336 | SetJailByDefault(enabled bool) error | 343 | SetJailByDefault(enabled bool) error | ||
| 344 | + | SetOpenRouterServerTools(enabled bool) error | |||
| 337 | SetRecursiveFileSuggest(enabled bool) error | 345 | SetRecursiveFileSuggest(enabled bool) error | ||
| 338 | SetRespectGitignore(enabled bool) error | 346 | SetRespectGitignore(enabled bool) error | ||
| 339 | SetCompactMode(enabled bool) error | 347 | 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 i | 690 | 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.JailByDefault | 3394 | 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.RecursiveFileSuggest | 3408 | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | ||
| 3387 | respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore | 3409 | 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 := value | 3911 | val := value | ||
| 3866 | i.cfg.RecursiveFileSuggest = &val | 3912 | 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 the | 5411 | // 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.Method | 5441 | 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.AutoSwarmEnabled | 6623 | 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 | return | 6654 | return | ||
M packages/agent/modes/interactive_model_test.go +13 -0
| @@ -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 | 97 | func TestModelDialogAdvertisesReasoningSelector(t *testing.T) { | |
| 85 | 98 | d := newModelDialog() | |
| 86 | 99 | 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 { | |||
| 121 | 121 | ||
| 122 | 122 | // normalizeModelQuery lowercases and strips punctuation so fuzzy | |
| 123 | 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 | 126 | func normalizeModelQuery(s string) string { | |
| 126 | 127 | var sb strings.Builder | |
| 127 | 128 | sb.Grow(len(s)) | |
| 128 | 129 | for _, r := range s { | |
| 129 | 130 | switch { | |
| 130 | - | case r >= 'a' && r <= 'z', r >= '0' && r <= '9': | |
| 131 | + | case r == '@', r >= 'a' && r <= 'z', r >= '0' && r <= '9': | |
| 131 | 132 | sb.WriteRune(r) | |
| 132 | 133 | case r >= 'A' && r <= 'Z': | |
| 133 | 134 | 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 fuzzy | 122 | // 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.Builder | 127 | 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 { | |||
| 68 | 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 | 80 | func (configSettingsStore) SetRecursiveFileSuggest(enabled bool) error { | |
| 72 | 81 | cfg, err := LoadConfig() | |
| 73 | 82 | 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) { | |||
| 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 | 57 | func TestConfigSettingsStorePersistsJailByDefault(t *testing.T) { | |
| 27 | 58 | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 28 | 59 | 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 { | |||
| 31 | 31 | // responses aren't silently cut off with stopReason=length. | |
| 32 | 32 | 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 | 42 | // BeforeToolExecute, if set, is called immediately before each | |
| 35 | 43 | // tool runs. Returning (allowed=false, reason) short-circuits | |
| 36 | 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 | 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 | 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 | 422 | a.mu.Lock() | |
| 411 | 423 | a.messages = append(a.messages, toolMsg) | |
| 412 | 424 | a.rev++ | |
| @@ -562,11 +574,13 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.St | |||
| 562 | 574 | // next in-process request is rejected by providers like Anthropic | |
| 563 | 575 | // with "tool_use ids were found without tool_result blocks". The | |
| 564 | 576 | // 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, | |
| 570 | 584 | } | |
| 571 | 585 | stream, err := a.Client.Stream(ctx, req) | |
| 572 | 586 | if err != nil { | |
| @@ -675,7 +689,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun | |||
| 675 | 689 | ||
| 676 | 690 | for _, c := range msg.Content { | |
| 677 | 691 | tc, ok := c.(provider.ToolCallBlock) | |
| 678 | - | if !ok { | |
| 692 | + | if !ok || tc.Server { | |
| 679 | 693 | continue | |
| 680 | 694 | } | |
| 681 | 695 | 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 int | 32 | 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 each | 42 | // BeforeToolExecute, if set, is called immediately before each | ||
| 35 | // tool runs. Returning (allowed=false, reason) short-circuits | 43 | // 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 Anthropic | 574 | // next in-process request is rejected by providers like Anthropic | ||
| 563 | // with "tool_use ids were found without tool_result blocks". The | 575 | // 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 | continue | 693 | 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) { | |||
| 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 | 250 | func TestAgentPropagatesTemperature(t *testing.T) { | |
| 238 | 251 | client := &captureClient{} | |
| 239 | 252 | 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 | |||
| 51 | 51 | System: summarizationSystem, | |
| 52 | 52 | MaxTokens: 4096, | |
| 53 | 53 | Temperature: a.Temperature, | |
| 54 | + | SessionID: a.SessionID, | |
| 54 | 55 | Messages: []provider.Message{ | |
| 55 | 56 | { | |
| 56 | 57 | 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) { | |||
| 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 | 99 | // TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from | |
| 80 | 100 | // the interceptor returns an error ToolResult with the reason text. | |
| 81 | 101 | 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 from | 99 | // 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 { | |||
| 323 | 323 | // Collect tool_use ids in this assistant message. | |
| 324 | 324 | var ids []string | |
| 325 | 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 | 327 | ids = append(ids, tc.ID) | |
| 328 | 328 | } | |
| 329 | 329 | } | |
| @@ -852,6 +852,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) { | |||
| 852 | 852 | Name string `json:"name"` | |
| 853 | 853 | Arguments json.RawMessage `json:"arguments"` | |
| 854 | 854 | ThoughtSignature string `json:"thought_signature"` | |
| 855 | + | Server bool `json:"server"` | |
| 855 | 856 | } | |
| 856 | 857 | _ = json.Unmarshal(raw, &tc) | |
| 857 | 858 | msg.Content = append(msg.Content, provider.ToolCallBlock{ | |
| @@ -859,6 +860,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) { | |||
| 859 | 860 | Name: tc.Name, | |
| 860 | 861 | Arguments: tc.Arguments, | |
| 861 | 862 | ThoughtSignature: tc.ThoughtSignature, | |
| 863 | + | Server: tc.Server, | |
| 862 | 864 | }) | |
| 863 | 865 | case head.CallID != "": | |
| 864 | 866 | 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 []string | 324 | 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) { | |||
| 102 | 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 | + | } | |
| @@ -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) { | |||
| 23 | 23 | if err != nil { | |
| 24 | 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 | 27 | t.Fatalf("top-level tools = %+v, want only search_tools", wire.Tools) | |
| 28 | 28 | } | |
| 29 | 29 | var loaded []oaiTool | |
| @@ -32,7 +32,7 @@ func TestKimiK3DeferredToolsLoadAtToolResult(t *testing.T) { | |||
| 32 | 32 | 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 | 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 | 70 | var names []string | |
| 71 | 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 | 76 | if len(names) != 2 || names[0] != "search_tools" || names[1] != "lookup_weather" { | |
| 75 | 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 | 92 | names := make([]string, 0, len(wire.Tools)) | |
| 91 | 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 | 98 | return names, nil | |
| 95 | 99 | }, | |
| @@ -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 []oaiTool | 29 | var loaded []oaiTool | ||
| @@ -32,7 +32,7 @@ func TestKimiK3DeferredToolsLoadAtToolResult(t *testing.T) { | |||||
| 32 | loaded = message.Tools | 32 | 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 []string | 70 | 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, nil | 98 | 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) { | |||
| 312 | 312 | 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 | 380 | // openrouterSupportsReasoning reports whether OpenRouter's | |
| 316 | 381 | // supported_parameters list marks the model as reasoning-capable. | |
| 317 | 382 | func openrouterSupportsReasoning(params []string) bool { | |
| @@ -312,6 +312,71 @@ func DiscoverOpenRouter(ctx context.Context, baseURL string) ([]Model, error) { | |||||
| 312 | return out, nil | 312 | 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's | 380 | // 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) { | |||
| 51 | 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 | + | } | |
| @@ -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 { | |||
| 133 | 133 | } | |
| 134 | 134 | ||
| 135 | 135 | 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"` | |
| 139 | 141 | } | |
| 140 | 142 | ||
| 141 | 143 | type oaiMessage struct { | |
| @@ -153,12 +155,13 @@ type oaiMessage struct { | |||
| 153 | 155 | } | |
| 154 | 156 | ||
| 155 | 157 | 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 { | |
| 158 | 161 | Name string `json:"name"` | |
| 159 | 162 | Description string `json:"description,omitempty"` | |
| 160 | 163 | Parameters json.RawMessage `json:"parameters"` | |
| 161 | - | } `json:"function"` | |
| 164 | + | } `json:"function,omitempty"` | |
| 162 | 165 | } | |
| 163 | 166 | ||
| 164 | 167 | type oaiStreamOptions struct { | |
| @@ -176,6 +179,8 @@ type oaiRequest struct { | |||
| 176 | 179 | MaxTokens *int `json:"max_tokens,omitempty"` | |
| 177 | 180 | MaxCompletionTok *int `json:"max_completion_tokens,omitempty"` | |
| 178 | 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 | 186 | // ---- request building ---- | |
| @@ -203,6 +208,14 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 203 | 208 | StreamOptions: &oaiStreamOptions{IncludeUsage: true}, | |
| 204 | 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 | 220 | maxTok := req.MaxTokens | |
| 208 | 221 | if maxTok <= 0 { | |
| @@ -310,6 +323,9 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 310 | 323 | } | |
| 311 | 324 | text.WriteString(v.Text) | |
| 312 | 325 | case ToolCallBlock: | |
| 326 | + | if v.Server { | |
| 327 | + | continue | |
| 328 | + | } | |
| 313 | 329 | args := v.Arguments | |
| 314 | 330 | if len(args) == 0 || !json.Valid(args) { | |
| 315 | 331 | args = json.RawMessage("{}") | |
| @@ -407,12 +423,25 @@ func supportsDeferredTools(provider string) bool { | |||
| 407 | 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 | 430 | func makeOAITool(t Tool) oaiTool { | |
| 431 | + | if isOpenRouterServerToolType(t.Name) { | |
| 432 | + | return oaiTool{Type: t.Name, Parameters: t.Schema} | |
| 433 | + | } | |
| 411 | 434 | var tool oaiTool | |
| 412 | 435 | 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 | + | } | |
| 416 | 445 | return tool | |
| 417 | 446 | } | |
| 418 | 447 | ||
| @@ -554,6 +583,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 554 | 583 | toolName string | |
| 555 | 584 | toolArgs strings.Builder | |
| 556 | 585 | announced bool | |
| 586 | + | server bool | |
| 557 | 587 | } | |
| 558 | 588 | var ( | |
| 559 | 589 | blocks []*blockEntry | |
| @@ -600,7 +630,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 600 | 630 | args = "{}" | |
| 601 | 631 | } | |
| 602 | 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 | 669 | Content string `json:"content"` | |
| 640 | 670 | ReasoningContent string `json:"reasoning_content"` | |
| 641 | 671 | 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 { | |
| 646 | 678 | Name string `json:"name"` | |
| 647 | 679 | Arguments string `json:"arguments"` | |
| 648 | 680 | } `json:"function"` | |
| @@ -699,8 +731,22 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 699 | 731 | if tc.ID != "" { | |
| 700 | 732 | 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 | 745 | if tc.Function.Name != "" { | |
| 703 | 746 | t.toolName = tc.Function.Name | |
| 747 | + | if isOpenRouterServerToolType(tc.Function.Name) { | |
| 748 | + | t.server = true | |
| 749 | + | } | |
| 704 | 750 | } | |
| 705 | 751 | if !t.announced && t.toolID != "" && t.toolName != "" { | |
| 706 | 752 | t.announced = true | |
| @@ -720,11 +766,20 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 720 | 766 | stop = StopLength | |
| 721 | 767 | case "tool_calls", "function_call": | |
| 722 | 768 | stop = StopToolUse | |
| 769 | + | hasClientTool := false | |
| 723 | 770 | for _, b := range blocks { | |
| 724 | 771 | if b.kind == "tool_use" && b.announced { | |
| 725 | 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 | } | |
| @@ -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.MaxTokens | 220 | 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.Arguments | 329 | 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 oaiTool | 434 | var tool oaiTool | ||
| 412 | tool.Type = "function" | 435 | tool.Type = "function" | ||
| 413 | - | tool.Function.Name = t.Name | 436 | + | tool.Function = &struct { |
| 414 | - | tool.Function.Description = t.Description | 437 | + | Name string `json:"name"` |
| 415 | - | tool.Function.Parameters = t.Schema | 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 | + | } | |||
| 416 | return tool | 445 | return tool | ||
| 417 | } | 446 | } | ||
| 418 | 447 | ||||
| @@ -554,6 +583,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R | |||||
| 554 | toolName string | 583 | toolName string | ||
| 555 | toolArgs strings.Builder | 584 | toolArgs strings.Builder | ||
| 556 | announced bool | 585 | announced bool | ||
| 586 | + | server bool | |||
| 557 | } | 587 | } | ||
| 558 | var ( | 588 | var ( | ||
| 559 | blocks []*blockEntry | 589 | 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.ID | 732 | 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.Name | 746 | 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 = true | 752 | t.announced = true | ||
| @@ -720,11 +766,20 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R | |||||
| 720 | stop = StopLength | 766 | stop = StopLength | ||
| 721 | case "tool_calls", "function_call": | 767 | case "tool_calls", "function_call": | ||
| 722 | stop = StopToolUse | 768 | 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 { | |||
| 49 | 49 | Name string `json:"name"` | |
| 50 | 50 | Arguments json.RawMessage `json:"arguments"` | |
| 51 | 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 | 58 | func (ToolCallBlock) isContent() {} | |
| @@ -257,6 +261,12 @@ type Request struct { | |||
| 257 | 261 | // "max". Empty disables reasoning. The max tier is sent natively only to | |
| 258 | 262 | // models that support it and clamped elsewhere. | |
| 259 | 263 | 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 | 272 | // 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 to | 261 | // "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 string | 263 | 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. | ||