From b5477490e4f69a4a5ccf6b8f12ec674fd7d712f2 Mon Sep 17 00:00:00 2001
From: Basuotian <93654253+basuotian@users.noreply.github.com>
Date: Mon, 26 Oct 2026 13:41:04 +0800
Subject: [PATCH] MUL-7376: fix(openclaw): preserve agent identity with
persistent model overrides (#9223)
* fix(openclaw): carry per-run model override on the run input (INFRA-71)
Reimplement MUL-7376 (#8419) on this fork and address the review points.
Resolve runtime_config.model_override once, at claim time, and carry it on the run input (model_override); the daemon no longer decodes it from mutable agent config.
OpenClaw keeps agent.model as the registered agent id (--agent) and the override as the underlying model (--model); arbitrary --model from custom_args stays blocked. Usage attribution falls back runtime model -> per-run override -> unknown, never the agent id.
Round-trip model_override through the runtime-config schema, serializer, equality check and the runtime-config tab (i18n in all locales). Docs updated.
Co-authored-by: multica-agent
* docs(openclaw): clarify persistent agent model overrides
Co-authored-by: multica-agent
---------
Co-authored-by: multica-agent
Co-authored-by: Multica Eve <273080382+multica-eve@users.noreply.github.com>
---
CLI_AND_DAEMON.md | 2 +-
SELF_HOSTING_ADVANCED.md | 2 +-
.../agents/openclaw-runtime-config.test.ts | 68 +++++++++++++++++++
.../core/agents/openclaw-runtime-config.ts | 9 +++
.../components/tabs/runtime-config-tab.tsx | 28 ++++++++
packages/views/locales/en/agents.json | 3 +
packages/views/locales/fr/agents.json | 3 +
packages/views/locales/ja/agents.json | 3 +
packages/views/locales/ko/agents.json | 3 +
packages/views/locales/zh-Hans/agents.json | 3 +
server/cmd/multica/cmd_agent.go | 4 +-
server/internal/daemon/daemon.go | 5 ++
server/internal/daemon/types.go | 1 +
server/internal/handler/agent.go | 22 ++++++
server/internal/handler/daemon.go | 6 ++
.../handler/openclaw_model_override_test.go | 28 ++++++++
.../multica-platform/references/agents.md | 15 ++--
server/pkg/agent/agent.go | 6 ++
server/pkg/agent/openclaw.go | 55 +++++++++------
server/pkg/agent/openclaw_test.go | 54 ++++++++++++++-
20 files changed, 291 insertions(+), 29 deletions(-)
create mode 100644 server/internal/handler/openclaw_model_override_test.go
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({
+
+