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 29138ac5d0.
- Required merge gates passed before the squash merge.

Prepared head SHA: 29138ac5d0
Review: https://github.com/openclaw/openclaw/pull/83262#issuecomment-4472495568

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>
This commit is contained in:
WhatsSkiLL
2026-05-22 08:31:44 +02:00
committed by GitHub
parent 17e2ccf179
commit 170f72d5a1
6 changed files with 224 additions and 7 deletions

View File

@@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai
- 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.
- Agents/subagents: surface blocked child-run completions as errors instead of successful subagent finishes. (#80886) Thanks @TurboTheTurtle.
- 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.
- CLI/models: resolve `openclaw models set` aliases from the runtime config while keeping authored aliases ahead of runtime-only defaults. (#83262) Thanks @IWhatsskill.
- WhatsApp: update Baileys to `7.0.0-rc13` and drop the obsolete logger type patch.
- 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.
- Gateway: mirror successful same-source message-tool sends into session transcripts so delivered replies stay in later history/context. (#84837) Thanks @iFiras-Max1.

View File

@@ -9,8 +9,17 @@ vi.mock("./models/shared.js", async () => {
const actual = await vi.importActual<typeof import("./models/shared.js")>("./models/shared.js");
return {
...actual,
updateConfig: async (mutator: (cfg: Record<string, unknown>) => Record<string, unknown>) => {
const next = mutator(structuredClone(mocks.currentConfig));
updateConfig: async (
mutator: (
cfg: Record<string, unknown>,
context: {
runtimeConfig: Record<string, unknown>;
},
) => Record<string, unknown>,
) => {
const sourceConfig = structuredClone(mocks.currentConfig);
const runtimeConfig = structuredClone(mocks.currentConfig);
const next = mutator(sourceConfig, { runtimeConfig });
mocks.writtenConfig = next;
return next;
},

View File

@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
const mocks = vi.hoisted(() => ({
logConfigUpdated: vi.fn(),
readConfigFileSnapshot: vi.fn(),
repairCodexRuntimePluginInstallForModelSelection: vi.fn(),
replaceConfigFile: vi.fn(),
}));
vi.mock("../../config/config.js", () => ({
readConfigFileSnapshot: (...args: unknown[]) => mocks.readConfigFileSnapshot(...args),
replaceConfigFile: (...args: unknown[]) => mocks.replaceConfigFile(...args),
}));
vi.mock("../../config/logging.js", () => ({
logConfigUpdated: (...args: unknown[]) => mocks.logConfigUpdated(...args),
}));
vi.mock("../codex-runtime-plugin-install.js", () => ({
repairCodexRuntimePluginInstallForModelSelection: (...args: unknown[]) =>
mocks.repairCodexRuntimePluginInstallForModelSelection(...args),
}));
import { modelsSetCommand } from "./set.js";
function makeRuntime(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as unknown as RuntimeEnv;
}
describe("modelsSetCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.replaceConfigFile.mockResolvedValue(undefined);
mocks.repairCodexRuntimePluginInstallForModelSelection.mockResolvedValue({ warnings: [] });
});
it("resolves aliases from runtime config while writing only source config", async () => {
const sourceConfig = {
agents: {
defaults: {
models: {
"anthropic/claude-sonnet-4-6": {},
},
},
},
} as unknown as OpenClawConfig;
const runtimeConfig = {
agents: {
defaults: {
models: {
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
},
},
},
} as unknown as OpenClawConfig;
mocks.readConfigFileSnapshot.mockResolvedValue({
valid: true,
hash: "config-hash",
sourceConfig,
runtimeConfig,
config: runtimeConfig,
});
const runtime = makeRuntime();
await modelsSetCommand("sonnet", runtime);
expect(mocks.replaceConfigFile).toHaveBeenCalledOnce();
const [replaceParams] = mocks.replaceConfigFile.mock.calls[0] ?? [];
expect(replaceParams?.nextConfig.agents?.defaults?.model).toEqual({
primary: "anthropic/claude-sonnet-4-6",
});
expect(replaceParams?.nextConfig.agents?.defaults?.models).toEqual({
"anthropic/claude-sonnet-4-6": {},
});
expect(replaceParams?.nextConfig.agents?.defaults?.models).not.toHaveProperty("openai/sonnet");
expect(mocks.repairCodexRuntimePluginInstallForModelSelection).toHaveBeenCalledWith({
cfg: replaceParams?.nextConfig,
model: "anthropic/claude-sonnet-4-6",
});
expect(runtime.log).toHaveBeenCalledWith("Default model: anthropic/claude-sonnet-4-6");
});
it("keeps authored aliases ahead of runtime-only aliases", async () => {
const sourceConfig = {
agents: {
defaults: {
models: {
"openai/gpt-5.5": { alias: "sonnet" },
},
},
},
} as unknown as OpenClawConfig;
const runtimeConfig = {
agents: {
defaults: {
models: {
"openai/gpt-5.5": { alias: "sonnet" },
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
},
},
},
} as unknown as OpenClawConfig;
mocks.readConfigFileSnapshot.mockResolvedValue({
valid: true,
hash: "config-hash",
sourceConfig,
runtimeConfig,
config: runtimeConfig,
});
const runtime = makeRuntime();
await modelsSetCommand("sonnet", runtime);
expect(mocks.replaceConfigFile).toHaveBeenCalledOnce();
const [replaceParams] = mocks.replaceConfigFile.mock.calls[0] ?? [];
expect(replaceParams?.nextConfig.agents?.defaults?.model).toEqual({
primary: "openai/gpt-5.5",
});
expect(replaceParams?.nextConfig.agents?.defaults?.models).toEqual({
"openai/gpt-5.5": { alias: "sonnet" },
});
expect(mocks.repairCodexRuntimePluginInstallForModelSelection).toHaveBeenCalledWith({
cfg: replaceParams?.nextConfig,
model: "openai/gpt-5.5",
});
expect(runtime.log).toHaveBeenCalledWith("Default model: openai/gpt-5.5");
});
});

View File

@@ -5,8 +5,13 @@ import { repairCodexRuntimePluginInstallForModelSelection } from "../codex-runti
import { applyDefaultModelPrimaryUpdate, updateConfig } from "./shared.js";
export async function modelsSetCommand(modelRaw: string, runtime: RuntimeEnv) {
const updated = await updateConfig((cfg) => {
return applyDefaultModelPrimaryUpdate({ cfg, modelRaw, field: "model" });
const updated = await updateConfig((cfg, context) => {
return applyDefaultModelPrimaryUpdate({
cfg,
resolveCfg: context.runtimeConfig,
modelRaw,
field: "model",
});
});
const repaired = await repairCodexRuntimePluginInstallForModelSelection({
cfg: updated,

View File

@@ -61,4 +61,36 @@ describe("models/shared", () => {
expect(replaceParams?.nextConfig.update).toEqual({ channel: "beta" });
expect(replaceParams?.baseHash).toBe("config-1");
});
it("updateConfig exposes runtime config without writing runtime defaults", async () => {
const sourceConfig = {
agents: { defaults: { models: { "anthropic/claude-sonnet-4-6": {} } } },
} as unknown as OpenClawConfig;
const runtimeConfig = {
agents: {
defaults: {
models: { "anthropic/claude-sonnet-4-6": { alias: "sonnet" } },
},
},
} as unknown as OpenClawConfig;
mocks.readConfigFileSnapshot.mockResolvedValue({
valid: true,
hash: "config-2",
sourceConfig,
runtimeConfig,
config: runtimeConfig,
});
mocks.replaceConfigFile.mockResolvedValue(undefined);
await updateConfig((current, context) => {
expect(current).toEqual(sourceConfig);
expect(context.runtimeConfig).toEqual(runtimeConfig);
return current;
});
expect(mocks.replaceConfigFile).toHaveBeenCalledOnce();
const [replaceParams] = mocks.replaceConfigFile.mock.calls[0] ?? [];
expect(replaceParams?.nextConfig).toEqual(sourceConfig);
expect(replaceParams?.baseHash).toBe("config-2");
});
});

View File

@@ -59,15 +59,21 @@ export async function loadValidConfigOrThrow(): Promise<OpenClawConfig> {
return snapshot.runtimeConfig ?? snapshot.config;
}
export type UpdateConfigContext = {
runtimeConfig: OpenClawConfig;
};
export async function updateConfig(
mutator: (cfg: OpenClawConfig) => OpenClawConfig,
mutator: (cfg: OpenClawConfig, context: UpdateConfigContext) => OpenClawConfig,
): Promise<OpenClawConfig> {
const snapshot = await readConfigFileSnapshot();
if (!snapshot.valid) {
const issues = formatConfigIssueLines(snapshot.issues, "-").join("\n");
throw new Error(`Invalid config at ${snapshot.path}\n${issues}`);
}
const next = mutator(structuredClone(snapshot.sourceConfig ?? snapshot.config));
const sourceConfig = structuredClone(snapshot.sourceConfig ?? snapshot.config);
const runtimeConfig = structuredClone(snapshot.runtimeConfig ?? snapshot.config);
const next = mutator(sourceConfig, { runtimeConfig });
await replaceConfigFile({
nextConfig: next,
baseHash: snapshot.hash,
@@ -94,6 +100,22 @@ export function resolveModelTarget(params: { raw: string; cfg: OpenClawConfig })
return resolved.ref;
}
function resolveAuthoredModelAliasTarget(params: {
raw: string;
cfg: OpenClawConfig;
}): { provider: string; model: string } | undefined {
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
defaultProvider: DEFAULT_PROVIDER,
});
const resolved = resolveModelRefFromString({
raw: params.raw,
defaultProvider: DEFAULT_PROVIDER,
aliasIndex,
});
return resolved?.alias ? resolved.ref : undefined;
}
export function resolveModelKeysFromEntries(params: {
cfg: OpenClawConfig;
entries: readonly string[];
@@ -209,10 +231,24 @@ export function mergePrimaryFallbackConfig(
export function applyDefaultModelPrimaryUpdate(params: {
cfg: OpenClawConfig;
resolveCfg?: OpenClawConfig;
modelRaw: string;
field: "model" | "imageModel";
}): OpenClawConfig {
const resolved = resolveModelTarget({ raw: params.modelRaw, cfg: params.cfg });
const resolved =
params.resolveCfg && params.resolveCfg !== params.cfg
? (resolveAuthoredModelAliasTarget({
raw: params.modelRaw,
cfg: params.cfg,
}) ??
resolveModelTarget({
raw: params.modelRaw,
cfg: params.resolveCfg,
}))
: resolveModelTarget({
raw: params.modelRaw,
cfg: params.cfg,
});
const nextModels = {
...params.cfg.agents?.defaults?.models,
} as Record<string, AgentModelEntryConfig>;