mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 23:36:50 +00:00
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 head29138ac5d0. - Required merge gates passed before the squash merge. Prepared head SHA:29138ac5d0Review: 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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
134
src/commands/models/set.test.ts
Normal file
134
src/commands/models/set.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user