diff --git a/CLI_AND_DAEMON.md b/CLI_AND_DAEMON.md index 462bac977..d1cc8c675 100644 --- a/CLI_AND_DAEMON.md +++ b/CLI_AND_DAEMON.md @@ -390,7 +390,7 @@ Agent-specific overrides: | `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary | | `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used | | `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary | -| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used | +| `MULTICA_OPENCLAW_MODEL` | Select the registered OpenClaw agent ID used by default; the agent's `runtime_config.model_override` persistently overrides its underlying model for all newly claimed runs until changed or cleared | | `MULTICA_OPENCLAW_CLI_TIMEOUT` | Deadline for each `openclaw config ...` call during task preparation (default 30s; accepts `45s` or `45`). Raise it when the local CLI is slow to start; the daemon also reads it from `backends.openclaw.cli_timeout` in the CLI config | | `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary | | `MULTICA_HERMES_MODEL` | Override the Hermes model used | diff --git a/SELF_HOSTING_ADVANCED.md b/SELF_HOSTING_ADVANCED.md index f84f01dbb..786c4e090 100644 --- a/SELF_HOSTING_ADVANCED.md +++ b/SELF_HOSTING_ADVANCED.md @@ -342,7 +342,7 @@ Agent-specific overrides: | `MULTICA_CODEARTS_PATH` | Custom path to the `codearts` launcher or binary | | `MULTICA_CODEARTS_MODEL` | Override the CodeArts model used | | `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary | -| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used | +| `MULTICA_OPENCLAW_MODEL` | Select the registered OpenClaw agent ID used by default; the agent's `runtime_config.model_override` persistently overrides its underlying model for all newly claimed runs until changed or cleared | | `MULTICA_OPENCLAW_CLI_TIMEOUT` | Deadline for each `openclaw config ...` call during task preparation (default 30s; accepts `45s` or `45`). Raise it when the local CLI is slow to start; the daemon also reads it from `backends.openclaw.cli_timeout` in the CLI config | | `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary | | `MULTICA_HERMES_MODEL` | Override the Hermes model used | diff --git a/packages/core/agents/openclaw-runtime-config.test.ts b/packages/core/agents/openclaw-runtime-config.test.ts index ffb5c2bf2..8abeb1b12 100644 --- a/packages/core/agents/openclaw-runtime-config.test.ts +++ b/packages/core/agents/openclaw-runtime-config.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { OPENCLAW_GATEWAY_TOKEN_MASK, + openclawRuntimeConfigEquals, + parseOpenclawRuntimeConfig, serializeOpenclawRuntimeConfig, } from "./openclaw-runtime-config"; @@ -60,4 +62,70 @@ describe("serializeOpenclawRuntimeConfig", () => { }, }); }); + + it("emits the agent model override alongside the routing config", () => { + expect( + serializeOpenclawRuntimeConfig({ + mode: "gateway", + gateway: { host: "gw.internal", port: 18789 }, + modelOverride: "openrouter/@preset/agent-reasoning", + }), + ).toEqual({ + mode: "gateway", + model_override: "openrouter/@preset/agent-reasoning", + gateway: { host: "gw.internal", port: 18789 }, + }); + }); + + it("omits an empty model override", () => { + expect(serializeOpenclawRuntimeConfig({ mode: "local" })).toEqual({ + mode: "local", + }); + }); +}); + +describe("parseOpenclawRuntimeConfig", () => { + it("reads and trims a persisted model override", () => { + expect( + parseOpenclawRuntimeConfig({ + mode: "local", + model_override: " openrouter/@preset/agent-reasoning ", + }), + ).toEqual({ mode: "local", modelOverride: "openrouter/@preset/agent-reasoning" }); + }); + + it("drops a non-string or blank model override", () => { + expect(parseOpenclawRuntimeConfig({ model_override: 7 })).toEqual({}); + expect(parseOpenclawRuntimeConfig({ model_override: " " })).toEqual({}); + }); + + it("keeps the model override through a gateway round-trip so saving gateway settings cannot drop it", () => { + const original = parseOpenclawRuntimeConfig({ + mode: "gateway", + gateway: { host: "gw.internal", port: 18789 }, + model_override: "openrouter/@preset/agent-reasoning", + }); + const resaved = parseOpenclawRuntimeConfig( + serializeOpenclawRuntimeConfig(original), + ); + expect(resaved.modelOverride).toBe("openrouter/@preset/agent-reasoning"); + expect(openclawRuntimeConfigEquals(original, resaved)).toBe(true); + }); +}); + +describe("openclawRuntimeConfigEquals", () => { + it("detects a changed model override", () => { + expect( + openclawRuntimeConfigEquals( + { modelOverride: "a" }, + { modelOverride: "b" }, + ), + ).toBe(false); + expect( + openclawRuntimeConfigEquals( + { modelOverride: "a" }, + { modelOverride: "a" }, + ), + ).toBe(true); + }); }); diff --git a/packages/core/agents/openclaw-runtime-config.ts b/packages/core/agents/openclaw-runtime-config.ts index c0b1190a2..e6574fdb3 100644 --- a/packages/core/agents/openclaw-runtime-config.ts +++ b/packages/core/agents/openclaw-runtime-config.ts @@ -17,6 +17,10 @@ export interface OpenclawGatewayPin { export interface OpenclawRuntimeConfig { mode?: OpenclawRoutingMode; gateway?: OpenclawGatewayPin; + // Persistent agent-level model override. `agent.model` selects the OpenClaw + // identity; this setting selects the underlying LLM for all runs claimed + // until it is changed or cleared. Each claim receives the current value. + modelOverride?: string; } // Sentinel the API substitutes for a non-empty `gateway.token` on every read. @@ -38,6 +42,9 @@ export function parseOpenclawRuntimeConfig( if (root.mode === "local" || root.mode === "gateway") { out.mode = root.mode; } + if (typeof root.model_override === "string" && root.model_override.trim() !== "") { + out.modelOverride = root.model_override.trim(); + } if (root.gateway && typeof root.gateway === "object" && !Array.isArray(root.gateway)) { const gw = root.gateway as Record; const pin: OpenclawGatewayPin = {}; @@ -59,6 +66,7 @@ export function serializeOpenclawRuntimeConfig( ): Record { const out: Record = {}; if (cfg.mode) out.mode = cfg.mode; + if (cfg.modelOverride) out.model_override = cfg.modelOverride; if (cfg.gateway) { const gw: Record = {}; if (cfg.gateway.host) gw.host = cfg.gateway.host; @@ -84,6 +92,7 @@ export function openclawRuntimeConfigEquals( b: OpenclawRuntimeConfig, ): boolean { if ((a.mode ?? "local") !== (b.mode ?? "local")) return false; + if ((a.modelOverride ?? "") !== (b.modelOverride ?? "")) return false; const aGw = a.gateway ?? {}; const bGw = b.gateway ?? {}; if ((aGw.host ?? "") !== (bGw.host ?? "")) return false; diff --git a/packages/views/agents/components/tabs/runtime-config-tab.tsx b/packages/views/agents/components/tabs/runtime-config-tab.tsx index 3889043a7..0aa3a52f3 100644 --- a/packages/views/agents/components/tabs/runtime-config-tab.tsx +++ b/packages/views/agents/components/tabs/runtime-config-tab.tsx @@ -25,6 +25,9 @@ import { useT } from "../../../i18n"; // fail-soft default — but the union accepts it as a defensive belt. interface FormState { mode: OpenclawRoutingMode; + // Persistent agent model override. Empty uses the model bound to the + // selected OpenClaw agent. A string keeps the input controlled while typing. + modelOverride: string; host: string; port: string; token: string; @@ -42,6 +45,7 @@ function configToForm(cfg: OpenclawRuntimeConfig): FormState { const masked = cfg.gateway?.token === OPENCLAW_GATEWAY_TOKEN_MASK; return { mode: cfg.mode ?? "local", + modelOverride: cfg.modelOverride ?? "", host: cfg.gateway?.host ?? "", port: cfg.gateway?.port ? String(cfg.gateway.port) : "", // Never display the mask sentinel in the input — that would let users @@ -55,6 +59,9 @@ function configToForm(cfg: OpenclawRuntimeConfig): FormState { function formToConfig(state: FormState): OpenclawRuntimeConfig { const cfg: OpenclawRuntimeConfig = { mode: state.mode }; + if (state.modelOverride.trim() !== "") { + cfg.modelOverride = state.modelOverride.trim(); + } if (state.mode === "gateway") { const gw: NonNullable = {}; if (state.host.trim() !== "") gw.host = state.host.trim(); @@ -179,6 +186,26 @@ export function RuntimeConfigTab({

+
+ + + setState((s) => ({ ...s, modelOverride: e.target.value })) + } + placeholder={t( + ($) => $.tab_body.runtime_config.model_override_placeholder, + )} + className="font-mono text-caption" + /> +

+ {t(($) => $.tab_body.runtime_config.model_override_hint)} +

+
+
` — that path admits the agent owner or a // workspace owner/admin, denies agent actors, and writes a persisted diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index e5310efb8..3ecd03f3d 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -8008,8 +8008,12 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i // task dispatch. var openclawMode string var openclawGateway execenv.OpenclawGatewayPin + var openclawModelOverride string if task.Agent != nil && provider == "openclaw" { openclawMode, openclawGateway = decodeOpenclawRuntimeConfig(task.Agent.RuntimeConfig, d.logger) + // Use the server's claim-time snapshot of the agent's persistent model + // override throughout preparation and execution. + openclawModelOverride = strings.TrimSpace(task.ModelOverride) } var agentEnvOverrides map[string]string var agentCustomArgs []string @@ -8780,6 +8784,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i ThinkingLevel: thinkingLevel, ServiceTier: serviceTier, OpenclawMode: openclawMode, + OpenclawModelOverride: openclawModelOverride, ClaudeSettingsPath: env.ClaudeSettingsPath, QwenpawWorkspace: env.QwenpawWorkspace, } diff --git a/server/internal/daemon/types.go b/server/internal/daemon/types.go index 511d72cdf..ffaa9d370 100644 --- a/server/internal/daemon/types.go +++ b/server/internal/daemon/types.go @@ -108,6 +108,7 @@ type Task struct { ProjectWorkflow *ProjectWorkflowData `json:"project_workflow,omitempty"` ThreadName string `json:"thread_name,omitempty"` // semantic title for provider-native session/thread history Agent *AgentData `json:"agent,omitempty"` + ModelOverride string `json:"model_override,omitempty"` // claim-time snapshot of the agent's persistent OpenClaw model override ConnectedApps []ConnectedAppData `json:"connected_apps,omitempty"` // per-run app capabilities mounted through runtime MCP overlays Repos []RepoData `json:"repos,omitempty"` ProjectID string `json:"project_id,omitempty"` // active project for this task, when present diff --git a/server/internal/handler/agent.go b/server/internal/handler/agent.go index c04d20f89..28d444624 100644 --- a/server/internal/handler/agent.go +++ b/server/internal/handler/agent.go @@ -300,6 +300,23 @@ func preserveMaskedGatewayToken(incoming any, persistedRuntimeConfig []byte) { gw["token"] = prev.Gateway.Token } +// openclawModelOverride reads the agent's persistent underlying-model override +// from runtime_config. Every claim snapshots the current setting until it is +// changed or cleared. Absent, malformed, or whitespace-only values return "". +func openclawModelOverride(raw []byte) string { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return "" + } + var rc struct { + ModelOverride string `json:"model_override"` + } + if err := json.Unmarshal(trimmed, &rc); err != nil { + return "" + } + return strings.TrimSpace(rc.ModelOverride) +} + // RepoData holds repository information included in claim responses so the // daemon can set up worktrees for each workspace repo. type RepoData struct { @@ -435,6 +452,11 @@ type AgentTaskResponse struct { CreatedAt string `json:"created_at"` PriorSessionID string `json:"prior_session_id,omitempty"` // session ID from a previous task on same issue PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on same issue + // ModelOverride snapshots the agent's persistent OpenClaw model override at + // claim time. Later config edits affect subsequent claims, not this payload. + // Mirrors internal/daemon/types.go with the same JSON name; empty for other + // providers. + ModelOverride string `json:"model_override,omitempty"` // PriorSessionResumeUnavailable is set when a more recent Codex session was // withheld because its rollout was missing (MUL-5305); PriorSessionID (if // any) is then an older fallback, and the daemon surfaces the continuity gap diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index 020551896..9df2815f4 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -3001,6 +3001,12 @@ func (h *Handler) buildClaimedTaskResponse(r *http.Request, task *db.AgentTaskQu RuntimeConfig: runtimeConfig, DisabledRuntimeSkills: disabledRuntimeSkillsFor(agent.DisabledRuntimeSkills, runtimeID, runtime.Provider), } + // Snapshot the agent's persistent OpenClaw model override for this claim. + // It applies to every claim until changed or cleared; later edits do not + // affect payloads already delivered to the daemon. + if runtime.Provider == "openclaw" { + resp.ModelOverride = openclawModelOverride(agent.RuntimeConfig) + } // System agents carry a product-owned instruction layer that ships with // this binary instead of being copied into their row at creation. That // is what makes it hot-updatable: editing the embedded file and diff --git a/server/internal/handler/openclaw_model_override_test.go b/server/internal/handler/openclaw_model_override_test.go new file mode 100644 index 000000000..765bc78d6 --- /dev/null +++ b/server/internal/handler/openclaw_model_override_test.go @@ -0,0 +1,28 @@ +package handler + +import "testing" + +func TestOpenclawModelOverride(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + raw string + want string + }{ + {name: "absent payload", raw: "", want: ""}, + {name: "null payload", raw: "null", want: ""}, + {name: "empty object", raw: "{}", want: ""}, + {name: "trimmed override", raw: `{"mode":"gateway","model_override":" openrouter/@preset/agent-reasoning "}`, want: "openrouter/@preset/agent-reasoning"}, + {name: "whitespace-only override", raw: `{"model_override":" "}`, want: ""}, + {name: "malformed json", raw: `{"model_override":`, want: ""}, + {name: "unrelated keys are ignored", raw: `{"model_override":"m","other":1}`, want: "m"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := openclawModelOverride([]byte(tc.raw)); got != tc.want { + t.Errorf("openclawModelOverride(%q) = %q, want %q", tc.raw, got, tc.want) + } + }) + } +} diff --git a/server/internal/service/builtin_skills/multica-platform/references/agents.md b/server/internal/service/builtin_skills/multica-platform/references/agents.md index 501a2702a..53ad2a885 100644 --- a/server/internal/service/builtin_skills/multica-platform/references/agents.md +++ b/server/internal/service/builtin_skills/multica-platform/references/agents.md @@ -209,10 +209,17 @@ to clear. `agent copy` still carries the source value and has no override flag. `model` is a first-class persisted column the daemon reads directly. `custom_args` are normally raw provider CLI args. The CLI help notes that some -providers (codex app-server, openclaw) reject `--model` inside `custom_args` — -but that is documented CLI guidance, not a server-enforced invariant; nothing -on the create path inspects `custom_args` for a model flag. Provider -backends may consume protocol selectors before launch: +providers reject `--model` inside `custom_args`; provider backends filter flags +they manage directly. For OpenClaw, `model` selects the registered OpenClaw +agent ID (for example `main`). The optional `runtime_config.model_override` is +a persistent agent-level setting: it selects the underlying model for every +run claimed by this agent until the setting is changed or cleared. It does not +create another OpenClaw agent or split its identity, workspace, sessions, or +memory. Clear the setting to use the model bound to the selected OpenClaw agent. +Each claim snapshots the current setting; changing it does not affect runs +already claimed. The daemon emits native `--agent ` and +`--model ` flags separately. +Provider backends may also consume other protocol selectors before launch: - Pi filters `--thinking` because the first-class `thinking_level` field owns that flag and must be its only source. diff --git a/server/pkg/agent/agent.go b/server/pkg/agent/agent.go index cb1fb1236..a26579a2e 100644 --- a/server/pkg/agent/agent.go +++ b/server/pkg/agent/agent.go @@ -129,6 +129,12 @@ type ExecOptions struct { // ignore this field, mirroring ThinkingLevel's renderer-side fall-through // pattern. See issue #3260. OpenclawMode string + // OpenclawModelOverride is the claim-time snapshot of the agent's persistent + // underlying-model override. The setting applies to all newly claimed runs + // until changed or cleared. OpenClaw receives it as `--model ` alongside + // `--agent `, preserving the identity, workspace, sessions, and memory + // selected by Model. Other backends ignore this field. + OpenclawModelOverride string // ClaudeSettingsPath is a daemon-owned, task-local settings file passed // through Claude Code's --settings flag. It currently carries restrictive // runtime-skill overrides only; other providers ignore it. diff --git a/server/pkg/agent/openclaw.go b/server/pkg/agent/openclaw.go index 7f6ad8d8e..00bbb082d 100644 --- a/server/pkg/agent/openclaw.go +++ b/server/pkg/agent/openclaw.go @@ -41,7 +41,7 @@ var openclawBlockedArgs = map[string]blockedArgMode{ "--json": blockedStandalone, // JSON output for daemon communication "--session-id": blockedWithValue, // managed by daemon for session resumption "--message": blockedWithValue, // prompt is set by daemon - "--model": blockedWithValue, // openclaw agent does not accept --model; model is bound at registration via `openclaw agents add/update --model` + "--model": blockedWithValue, // managed by OpenclawModelOverride; never accept an untracked duplicate from custom_args "--system-prompt": blockedWithValue, // openclaw agent does not accept --system-prompt; instructions are injected into --message } @@ -192,21 +192,16 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO b.cfg.Logger.Info("openclaw finished", "pid", cmd.Process.Pid, "status", scanResult.status, "duration", duration.Round(time.Millisecond).String()) // Build usage map. Prefer the model openclaw reported in - // `meta.agentMeta.model` (the actual LLM, e.g. `deepseek-chat`). - // Fall back to opts.Model — which for openclaw is the agent name - // passed via `--agent`, not a real model identifier — only when - // the runtime didn't surface its own model. Last resort is the - // daemon's `unknown` placeholder. + // `meta.agentMeta.model` (the actual LLM, e.g. `deepseek-chat`), then the + // agent model override snapshotted for this claim. Never fall + // back to opts.Model: for the openclaw backend that is the OpenClaw + // *agent* name passed via `--agent`, not a model identifier, and + // attributing usage to it would key the dashboard on the agent id. Last + // resort is the daemon's `unknown` placeholder. var usage map[string]TokenUsage u := scanResult.usage if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 || u.CacheWriteTokens > 0 { - model := scanResult.model - if model == "" { - model = opts.Model - } - if model == "" { - model = "unknown" - } + model := openclawUsageModel(scanResult.model, opts.OpenclawModelOverride) usage = map[string]TokenUsage{model: u} } @@ -225,12 +220,12 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO // buildOpenclawArgs assembles the argv for a one-shot `openclaw agent` invocation. // -// The CLI only accepts --local, --json, --session-id, --timeout, --message (and -// flags like --agent / --channel that users pass through CustomArgs). Notably -// it does NOT accept --model or --system-prompt — model is bound at agent -// registration time via `openclaw agents add/update --model`, and instructions -// must be injected inline into --message because openclaw loads AGENTS.md from -// its own workspace directory, not from cwd. +// The CLI accepts separate --agent and --model flags. Multica intentionally +// keeps those concerns separate: opts.Model selects the registered OpenClaw +// agent (identity / workspace / session state), while opts.OpenclawModelOverride +// carries the agent's persistent underlying-model setting as of this claim. +// Instructions must still be injected inline into --message because openclaw +// loads AGENTS.md from its own workspace directory, not from cwd. // // Routing (issue #3260): `openclaw agent` defaults to Gateway routing; --local // is the embedded-mode opt-in. The daemon historically forced --local so every @@ -261,6 +256,12 @@ func buildOpenclawArgs(prompt, sessionID string, opts ExecOptions, logger *slog. if opts.Model != "" && !customArgsContains(customArgs, "--agent") { args = append(args, "--agent", opts.Model) } + // The saved agent model override arrives in the claim payload; --model + // stays blocked in custom_args. Emit it after --agent to keep the OpenClaw + // agent identity and the underlying model independently addressable. + if opts.OpenclawModelOverride != "" { + args = append(args, "--model", opts.OpenclawModelOverride) + } args = append(args, customArgs...) if opts.SystemPrompt != "" { @@ -282,6 +283,22 @@ func customArgsContains(args []string, flag string) bool { return false } +// openclawUsageModel picks the model key for a run's usage record. It prefers +// the model openclaw reported in the result, then the agent model override +// snapshotted for this claim, and finally the daemon's `unknown` +// placeholder. It deliberately never falls back to the OpenClaw agent identity +// (ExecOptions.Model): that value is a registered OpenClaw agent id, not a +// model, so keying usage on it would misattribute tokens on the dashboard. +func openclawUsageModel(reportedModel, modelOverride string) string { + if m := strings.TrimSpace(reportedModel); m != "" { + return m + } + if m := strings.TrimSpace(modelOverride); m != "" { + return m + } + return "unknown" +} + // checkOpenclawVersion runs ` --version` and returns a // user-facing error when the installed openclaw is older than // minOpenclawVersion. The returned error becomes the task's failure diff --git a/server/pkg/agent/openclaw_test.go b/server/pkg/agent/openclaw_test.go index 3222ef6e2..79a77be77 100644 --- a/server/pkg/agent/openclaw_test.go +++ b/server/pkg/agent/openclaw_test.go @@ -964,6 +964,53 @@ func TestBuildOpenclawArgsMapsModelToAgent(t *testing.T) { } } +func TestBuildOpenclawArgsKeepsAgentAndModelOverrideSeparate(t *testing.T) { + t.Parallel() + + // The registered OpenClaw agent (--agent) keeps identity/workspace/session + // state while the agent model override (--model) selects the underlying LLM. + // Both must be emitted, in that order, and never collapsed into one flag. + args := buildOpenclawArgs("task", "ses-model-override", ExecOptions{ + Model: "main", + OpenclawModelOverride: "openrouter/@preset/agent-reasoning", + }, slog.Default()) + + agentIdx := indexOf(args, "--agent") + if agentIdx == -1 || agentIdx+1 >= len(args) || args[agentIdx+1] != "main" { + t.Fatalf("expected --agent main, got: %v", args) + } + modelIdx := indexOf(args, "--model") + if modelIdx == -1 || modelIdx+1 >= len(args) || args[modelIdx+1] != "openrouter/@preset/agent-reasoning" { + t.Fatalf("expected managed agent model override, got: %v", args) + } + if modelIdx <= agentIdx { + t.Errorf("expected the model override after agent selection, got: %v", args) + } +} + +func TestOpenclawUsageModelFallback(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + reported string + override string + want string + }{ + {name: "prefers the runtime-reported model", reported: "deepseek-chat", override: "openrouter/@preset/agent-reasoning", want: "deepseek-chat"}, + {name: "falls back to the agent model override", reported: "", override: "openrouter/@preset/agent-reasoning", want: "openrouter/@preset/agent-reasoning"}, + {name: "trims whitespace", reported: " ", override: " openrouter/@preset/agent-reasoning ", want: "openrouter/@preset/agent-reasoning"}, + {name: "falls back to unknown, never the agent id", reported: "", override: "", want: "unknown"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := openclawUsageModel(tc.reported, tc.override); got != tc.want { + t.Errorf("openclawUsageModel(%q, %q) = %q, want %q", tc.reported, tc.override, got, tc.want) + } + }) + } +} + func TestBuildOpenclawArgsCustomAgentWinsOverModel(t *testing.T) { t.Parallel() @@ -1044,6 +1091,7 @@ func TestBuildOpenclawArgsFiltersBlockedCustomArgs(t *testing.T) { // Users must not be able to re-introduce the banned flags via custom_args — // they would crash `openclaw agent` just like the direct forward did. args := buildOpenclawArgs("task", "ses-6", ExecOptions{ + OpenclawModelOverride: "openrouter/@preset/agent-long", CustomArgs: []string{ "--agent", "research-bot", "--model", "gpt-4o", @@ -1053,8 +1101,10 @@ func TestBuildOpenclawArgsFiltersBlockedCustomArgs(t *testing.T) { }, }, slog.Default()) - if idx := indexOf(args, "--model"); idx != -1 { - t.Errorf("--model should be filtered from custom_args: %v", args) + if count := countOccurrences(args, "--model"); count != 1 { + t.Errorf("expected only the managed --model override, got: %v", args) + } else if idx := indexOf(args, "--model"); idx+1 >= len(args) || args[idx+1] != "openrouter/@preset/agent-long" { + t.Errorf("custom --model should be filtered in favor of the managed override: %v", args) } if idx := indexOf(args, "--system-prompt"); idx != -1 { t.Errorf("--system-prompt should be filtered from custom_args: %v", args)