Skip to content

Commit 170f72d

Browse files
IWhatsskillclawsweeper[bot]Takhoffman
authored
fix(models): resolve set aliases from runtime config [AI-assisted] (#83262)
Summary: - The branch passes runtime config into the model config write helper, updates `openclaw models set` to resolve aliases source-first then runtime-fallback, and adds regression tests plus a changelog entry. - Reproducibility: yes. I did not execute the CLI in this read-only review, but the current-main source path a ... ing against source config while runtime defaults can be the only place the displayed `sonnet` alias exists. Automerge notes: - PR branch already contained follow-up commit before automerge: fix(models): preserve authored aliases for set - PR branch already contained follow-up commit before automerge: fix(models): resolve set aliases from runtime config [AI-assisted] Validation: - ClawSweeper review passed for head 29138ac. - Required merge gates passed before the squash merge. Prepared head SHA: 29138ac Review: #83262 (comment) Co-authored-by: JARVIS-Glasses <284122573+JARVIS-Glasses@users.noreply.github.com> Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
1 parent 17e2ccf commit 170f72d

6 files changed

Lines changed: 224 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai
3838
- Channels/message tool: resolve configured external channel plugins during in-agent channel selection, so `openclaw agent --local` message-tool sends no longer report an available channel as unavailable. (#85022) Thanks @Kaspre.
3939
- Agents/subagents: surface blocked child-run completions as errors instead of successful subagent finishes. (#80886) Thanks @TurboTheTurtle.
4040
- Agents/Pi: treat accepted embedded `sessions_spawn` child-session handoffs as terminal progress so parent turns no longer report false non-deliverable failures. (#85054) Thanks @samzong.
41+
- CLI/models: resolve `openclaw models set` aliases from the runtime config while keeping authored aliases ahead of runtime-only defaults. (#83262) Thanks @IWhatsskill.
4142
- WhatsApp: update Baileys to `7.0.0-rc13` and drop the obsolete logger type patch.
4243
- Install/update: reject OpenClaw GitHub source package targets early and point moving-main users at the dev/git install path instead of the broken npm source-install flow.
4344
- Gateway: mirror successful same-source message-tool sends into session transcripts so delivered replies stay in later history/context. (#84837) Thanks @iFiras-Max1.

src/commands/models.set.e2e.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,17 @@ vi.mock("./models/shared.js", async () => {
99
const actual = await vi.importActual<typeof import("./models/shared.js")>("./models/shared.js");
1010
return {
1111
...actual,
12-
updateConfig: async (mutator: (cfg: Record<string, unknown>) => Record<string, unknown>) => {
13-
const next = mutator(structuredClone(mocks.currentConfig));
12+
updateConfig: async (
13+
mutator: (
14+
cfg: Record<string, unknown>,
15+
context: {
16+
runtimeConfig: Record<string, unknown>;
17+
},
18+
) => Record<string, unknown>,
19+
) => {
20+
const sourceConfig = structuredClone(mocks.currentConfig);
21+
const runtimeConfig = structuredClone(mocks.currentConfig);
22+
const next = mutator(sourceConfig, { runtimeConfig });
1423
mocks.writtenConfig = next;
1524
return next;
1625
},

src/commands/models/set.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { OpenClawConfig } from "../../config/config.js";
3+
import type { RuntimeEnv } from "../../runtime.js";
4+
5+
const mocks = vi.hoisted(() => ({
6+
logConfigUpdated: vi.fn(),
7+
readConfigFileSnapshot: vi.fn(),
8+
repairCodexRuntimePluginInstallForModelSelection: vi.fn(),
9+
replaceConfigFile: vi.fn(),
10+
}));
11+
12+
vi.mock("../../config/config.js", () => ({
13+
readConfigFileSnapshot: (...args: unknown[]) => mocks.readConfigFileSnapshot(...args),
14+
replaceConfigFile: (...args: unknown[]) => mocks.replaceConfigFile(...args),
15+
}));
16+
17+
vi.mock("../../config/logging.js", () => ({
18+
logConfigUpdated: (...args: unknown[]) => mocks.logConfigUpdated(...args),
19+
}));
20+
21+
vi.mock("../codex-runtime-plugin-install.js", () => ({
22+
repairCodexRuntimePluginInstallForModelSelection: (...args: unknown[]) =>
23+
mocks.repairCodexRuntimePluginInstallForModelSelection(...args),
24+
}));
25+
26+
import { modelsSetCommand } from "./set.js";
27+
28+
function makeRuntime(): RuntimeEnv {
29+
return {
30+
log: vi.fn(),
31+
error: vi.fn(),
32+
exit: vi.fn(),
33+
} as unknown as RuntimeEnv;
34+
}
35+
36+
describe("modelsSetCommand", () => {
37+
beforeEach(() => {
38+
vi.clearAllMocks();
39+
mocks.replaceConfigFile.mockResolvedValue(undefined);
40+
mocks.repairCodexRuntimePluginInstallForModelSelection.mockResolvedValue({ warnings: [] });
41+
});
42+
43+
it("resolves aliases from runtime config while writing only source config", async () => {
44+
const sourceConfig = {
45+
agents: {
46+
defaults: {
47+
models: {
48+
"anthropic/claude-sonnet-4-6": {},
49+
},
50+
},
51+
},
52+
} as unknown as OpenClawConfig;
53+
const runtimeConfig = {
54+
agents: {
55+
defaults: {
56+
models: {
57+
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
58+
},
59+
},
60+
},
61+
} as unknown as OpenClawConfig;
62+
mocks.readConfigFileSnapshot.mockResolvedValue({
63+
valid: true,
64+
hash: "config-hash",
65+
sourceConfig,
66+
runtimeConfig,
67+
config: runtimeConfig,
68+
});
69+
const runtime = makeRuntime();
70+
71+
await modelsSetCommand("sonnet", runtime);
72+
73+
expect(mocks.replaceConfigFile).toHaveBeenCalledOnce();
74+
const [replaceParams] = mocks.replaceConfigFile.mock.calls[0] ?? [];
75+
expect(replaceParams?.nextConfig.agents?.defaults?.model).toEqual({
76+
primary: "anthropic/claude-sonnet-4-6",
77+
});
78+
expect(replaceParams?.nextConfig.agents?.defaults?.models).toEqual({
79+
"anthropic/claude-sonnet-4-6": {},
80+
});
81+
expect(replaceParams?.nextConfig.agents?.defaults?.models).not.toHaveProperty("openai/sonnet");
82+
expect(mocks.repairCodexRuntimePluginInstallForModelSelection).toHaveBeenCalledWith({
83+
cfg: replaceParams?.nextConfig,
84+
model: "anthropic/claude-sonnet-4-6",
85+
});
86+
expect(runtime.log).toHaveBeenCalledWith("Default model: anthropic/claude-sonnet-4-6");
87+
});
88+
89+
it("keeps authored aliases ahead of runtime-only aliases", async () => {
90+
const sourceConfig = {
91+
agents: {
92+
defaults: {
93+
models: {
94+
"openai/gpt-5.5": { alias: "sonnet" },
95+
},
96+
},
97+
},
98+
} as unknown as OpenClawConfig;
99+
const runtimeConfig = {
100+
agents: {
101+
defaults: {
102+
models: {
103+
"openai/gpt-5.5": { alias: "sonnet" },
104+
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
105+
},
106+
},
107+
},
108+
} as unknown as OpenClawConfig;
109+
mocks.readConfigFileSnapshot.mockResolvedValue({
110+
valid: true,
111+
hash: "config-hash",
112+
sourceConfig,
113+
runtimeConfig,
114+
config: runtimeConfig,
115+
});
116+
const runtime = makeRuntime();
117+
118+
await modelsSetCommand("sonnet", runtime);
119+
120+
expect(mocks.replaceConfigFile).toHaveBeenCalledOnce();
121+
const [replaceParams] = mocks.replaceConfigFile.mock.calls[0] ?? [];
122+
expect(replaceParams?.nextConfig.agents?.defaults?.model).toEqual({
123+
primary: "openai/gpt-5.5",
124+
});
125+
expect(replaceParams?.nextConfig.agents?.defaults?.models).toEqual({
126+
"openai/gpt-5.5": { alias: "sonnet" },
127+
});
128+
expect(mocks.repairCodexRuntimePluginInstallForModelSelection).toHaveBeenCalledWith({
129+
cfg: replaceParams?.nextConfig,
130+
model: "openai/gpt-5.5",
131+
});
132+
expect(runtime.log).toHaveBeenCalledWith("Default model: openai/gpt-5.5");
133+
});
134+
});

src/commands/models/set.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,13 @@ import { repairCodexRuntimePluginInstallForModelSelection } from "../codex-runti
55
import { applyDefaultModelPrimaryUpdate, updateConfig } from "./shared.js";
66

77
export async function modelsSetCommand(modelRaw: string, runtime: RuntimeEnv) {
8-
const updated = await updateConfig((cfg) => {
9-
return applyDefaultModelPrimaryUpdate({ cfg, modelRaw, field: "model" });
8+
const updated = await updateConfig((cfg, context) => {
9+
return applyDefaultModelPrimaryUpdate({
10+
cfg,
11+
resolveCfg: context.runtimeConfig,
12+
modelRaw,
13+
field: "model",
14+
});
1015
});
1116
const repaired = await repairCodexRuntimePluginInstallForModelSelection({
1217
cfg: updated,

src/commands/models/shared.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,36 @@ describe("models/shared", () => {
6161
expect(replaceParams?.nextConfig.update).toEqual({ channel: "beta" });
6262
expect(replaceParams?.baseHash).toBe("config-1");
6363
});
64+
65+
it("updateConfig exposes runtime config without writing runtime defaults", async () => {
66+
const sourceConfig = {
67+
agents: { defaults: { models: { "anthropic/claude-sonnet-4-6": {} } } },
68+
} as unknown as OpenClawConfig;
69+
const runtimeConfig = {
70+
agents: {
71+
defaults: {
72+
models: { "anthropic/claude-sonnet-4-6": { alias: "sonnet" } },
73+
},
74+
},
75+
} as unknown as OpenClawConfig;
76+
mocks.readConfigFileSnapshot.mockResolvedValue({
77+
valid: true,
78+
hash: "config-2",
79+
sourceConfig,
80+
runtimeConfig,
81+
config: runtimeConfig,
82+
});
83+
mocks.replaceConfigFile.mockResolvedValue(undefined);
84+
85+
await updateConfig((current, context) => {
86+
expect(current).toEqual(sourceConfig);
87+
expect(context.runtimeConfig).toEqual(runtimeConfig);
88+
return current;
89+
});
90+
91+
expect(mocks.replaceConfigFile).toHaveBeenCalledOnce();
92+
const [replaceParams] = mocks.replaceConfigFile.mock.calls[0] ?? [];
93+
expect(replaceParams?.nextConfig).toEqual(sourceConfig);
94+
expect(replaceParams?.baseHash).toBe("config-2");
95+
});
6496
});

src/commands/models/shared.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,21 @@ export async function loadValidConfigOrThrow(): Promise<OpenClawConfig> {
5959
return snapshot.runtimeConfig ?? snapshot.config;
6060
}
6161

62+
export type UpdateConfigContext = {
63+
runtimeConfig: OpenClawConfig;
64+
};
65+
6266
export async function updateConfig(
63-
mutator: (cfg: OpenClawConfig) => OpenClawConfig,
67+
mutator: (cfg: OpenClawConfig, context: UpdateConfigContext) => OpenClawConfig,
6468
): Promise<OpenClawConfig> {
6569
const snapshot = await readConfigFileSnapshot();
6670
if (!snapshot.valid) {
6771
const issues = formatConfigIssueLines(snapshot.issues, "-").join("\n");
6872
throw new Error(`Invalid config at ${snapshot.path}\n${issues}`);
6973
}
70-
const next = mutator(structuredClone(snapshot.sourceConfig ?? snapshot.config));
74+
const sourceConfig = structuredClone(snapshot.sourceConfig ?? snapshot.config);
75+
const runtimeConfig = structuredClone(snapshot.runtimeConfig ?? snapshot.config);
76+
const next = mutator(sourceConfig, { runtimeConfig });
7177
await replaceConfigFile({
7278
nextConfig: next,
7379
baseHash: snapshot.hash,
@@ -94,6 +100,22 @@ export function resolveModelTarget(params: { raw: string; cfg: OpenClawConfig })
94100
return resolved.ref;
95101
}
96102

103+
function resolveAuthoredModelAliasTarget(params: {
104+
raw: string;
105+
cfg: OpenClawConfig;
106+
}): { provider: string; model: string } | undefined {
107+
const aliasIndex = buildModelAliasIndex({
108+
cfg: params.cfg,
109+
defaultProvider: DEFAULT_PROVIDER,
110+
});
111+
const resolved = resolveModelRefFromString({
112+
raw: params.raw,
113+
defaultProvider: DEFAULT_PROVIDER,
114+
aliasIndex,
115+
});
116+
return resolved?.alias ? resolved.ref : undefined;
117+
}
118+
97119
export function resolveModelKeysFromEntries(params: {
98120
cfg: OpenClawConfig;
99121
entries: readonly string[];
@@ -209,10 +231,24 @@ export function mergePrimaryFallbackConfig(
209231

210232
export function applyDefaultModelPrimaryUpdate(params: {
211233
cfg: OpenClawConfig;
234+
resolveCfg?: OpenClawConfig;
212235
modelRaw: string;
213236
field: "model" | "imageModel";
214237
}): OpenClawConfig {
215-
const resolved = resolveModelTarget({ raw: params.modelRaw, cfg: params.cfg });
238+
const resolved =
239+
params.resolveCfg && params.resolveCfg !== params.cfg
240+
? (resolveAuthoredModelAliasTarget({
241+
raw: params.modelRaw,
242+
cfg: params.cfg,
243+
}) ??
244+
resolveModelTarget({
245+
raw: params.modelRaw,
246+
cfg: params.resolveCfg,
247+
}))
248+
: resolveModelTarget({
249+
raw: params.modelRaw,
250+
cfg: params.cfg,
251+
});
216252
const nextModels = {
217253
...params.cfg.agents?.defaults?.models,
218254
} as Record<string, AgentModelEntryConfig>;

0 commit comments

Comments
 (0)