diff --git a/CHANGELOG.md b/CHANGELOG.md index b04a38c36da0..7b07e4b9dd17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/commands/models.set.e2e.test.ts b/src/commands/models.set.e2e.test.ts index ab36d9f05e0f..5a929d46967d 100644 --- a/src/commands/models.set.e2e.test.ts +++ b/src/commands/models.set.e2e.test.ts @@ -9,8 +9,17 @@ vi.mock("./models/shared.js", async () => { const actual = await vi.importActual("./models/shared.js"); return { ...actual, - updateConfig: async (mutator: (cfg: Record) => Record) => { - const next = mutator(structuredClone(mocks.currentConfig)); + updateConfig: async ( + mutator: ( + cfg: Record, + context: { + runtimeConfig: Record; + }, + ) => Record, + ) => { + const sourceConfig = structuredClone(mocks.currentConfig); + const runtimeConfig = structuredClone(mocks.currentConfig); + const next = mutator(sourceConfig, { runtimeConfig }); mocks.writtenConfig = next; return next; }, diff --git a/src/commands/models/set.test.ts b/src/commands/models/set.test.ts new file mode 100644 index 000000000000..21d67cd58513 --- /dev/null +++ b/src/commands/models/set.test.ts @@ -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"); + }); +}); diff --git a/src/commands/models/set.ts b/src/commands/models/set.ts index e14dff3b5963..777e1fab161d 100644 --- a/src/commands/models/set.ts +++ b/src/commands/models/set.ts @@ -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, diff --git a/src/commands/models/shared.test.ts b/src/commands/models/shared.test.ts index d32906b5d386..528ae8b802f3 100644 --- a/src/commands/models/shared.test.ts +++ b/src/commands/models/shared.test.ts @@ -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"); + }); }); diff --git a/src/commands/models/shared.ts b/src/commands/models/shared.ts index 37a7709abc32..ed1fc4f5c8f7 100644 --- a/src/commands/models/shared.ts +++ b/src/commands/models/shared.ts @@ -59,15 +59,21 @@ export async function loadValidConfigOrThrow(): Promise { 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 { 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;