diff --git a/CHANGELOG.md b/CHANGELOG.md index 36e16ce91c7..871a2c9be58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai - Mattermost/reply media delivery: pass agent-scoped `mediaLocalRoots` through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666. - Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process `HOME`/`OPENCLAW_HOME` changes no longer reuse stale plugin state or misreport `~/...` plugins as untracked. (#44046) thanks @gumadeiras. - Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated `session.store` roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras. +- Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and `models list --plain`, and migrate legacy duplicated `openrouter/openrouter/...` config entries forward on write. ## 2026.3.11 diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 938f5558920..63aef63561c 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -73,6 +73,12 @@ describe("model-selection", () => { }); }); + describe("modelKey", () => { + it("keeps canonical OpenRouter native ids without duplicating the provider", () => { + expect(modelKey("openrouter", "openrouter/hunter-alpha")).toBe("openrouter/hunter-alpha"); + }); + }); + describe("parseModelRef", () => { it("should parse full model refs", () => { expect(parseModelRef("anthropic/claude-3-5-sonnet", "openai")).toEqual({ @@ -754,6 +760,28 @@ describe("model-selection", () => { expect(resolveAnthropicOpusThinking(cfg)).toBe("high"); }); + it("accepts legacy duplicated OpenRouter keys for per-model thinking", () => { + const cfg = { + agents: { + defaults: { + models: { + "openrouter/openrouter/hunter-alpha": { + params: { thinking: "high" }, + }, + }, + }, + }, + } as OpenClawConfig; + + expect( + resolveThinkingDefault({ + cfg, + provider: "openrouter", + model: "openrouter/hunter-alpha", + }), + ).toBe("high"); + }); + it("accepts per-model params.thinking=adaptive", () => { const cfg = { agents: { diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index d43322cbe9f..3318a115949 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -43,7 +43,28 @@ function normalizeAliasKey(value: string): string { } export function modelKey(provider: string, model: string) { - return `${provider}/${model}`; + const providerId = provider.trim(); + const modelId = model.trim(); + if (!providerId) { + return modelId; + } + if (!modelId) { + return providerId; + } + return modelId.toLowerCase().startsWith(`${providerId.toLowerCase()}/`) + ? modelId + : `${providerId}/${modelId}`; +} + +export function legacyModelKey(provider: string, model: string): string | null { + const providerId = provider.trim(); + const modelId = model.trim(); + if (!providerId || !modelId) { + return null; + } + const rawKey = `${providerId}/${modelId}`; + const canonicalKey = modelKey(providerId, modelId); + return rawKey === canonicalKey ? null : rawKey; } export function normalizeProviderId(provider: string): string { @@ -610,9 +631,12 @@ export function resolveThinkingDefault(params: { }): ThinkLevel { const normalizedProvider = normalizeProviderId(params.provider); const modelLower = params.model.toLowerCase(); + const configuredModels = params.cfg.agents?.defaults?.models; + const canonicalKey = modelKey(params.provider, params.model); + const legacyKey = legacyModelKey(params.provider, params.model); const perModelThinking = - params.cfg.agents?.defaults?.models?.[modelKey(params.provider, params.model)]?.params - ?.thinking; + configuredModels?.[canonicalKey]?.params?.thinking ?? + (legacyKey ? configuredModels?.[legacyKey]?.params?.thinking : undefined); if ( perModelThinking === "off" || perModelThinking === "minimal" || diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index fc80137b0f0..6d0564bb451 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -273,6 +273,29 @@ describe("models list/status", () => { expect(runtime.log.mock.calls[0]?.[0]).toBe("zai/glm-4.7"); }); + it("models list plain keeps canonical OpenRouter native ids", async () => { + loadConfig.mockReturnValue({ + agents: { defaults: { model: "openrouter/hunter-alpha" } }, + }); + const runtime = makeRuntime(); + + modelRegistryState.models = [ + { + provider: "openrouter", + id: "openrouter/hunter-alpha", + name: "Hunter Alpha", + input: ["text"], + baseUrl: "https://openrouter.ai/api/v1", + contextWindow: 1048576, + }, + ]; + modelRegistryState.available = modelRegistryState.models; + await modelsListCommand({ plain: true }, runtime); + + expect(runtime.log).toHaveBeenCalledTimes(1); + expect(runtime.log.mock.calls[0]?.[0]).toBe("openrouter/hunter-alpha"); + }); + it.each(["z.ai", "Z.AI", "z-ai"] as const)( "models list provider filter normalizes %s alias", async (provider) => { diff --git a/src/commands/models.set.e2e.test.ts b/src/commands/models.set.e2e.test.ts index 6671c6bb1f0..f544a1fc383 100644 --- a/src/commands/models.set.e2e.test.ts +++ b/src/commands/models.set.e2e.test.ts @@ -110,6 +110,45 @@ describe("models set + fallbacks", () => { expectWrittenPrimaryModel("zai/glm-4.7"); }); + it("keeps canonical OpenRouter native ids in models set", async () => { + mockConfigSnapshot({}); + const runtime = makeRuntime(); + + await modelsSetCommand("openrouter/hunter-alpha", runtime); + + expectWrittenPrimaryModel("openrouter/hunter-alpha"); + }); + + it("migrates legacy duplicated OpenRouter keys on write", async () => { + mockConfigSnapshot({ + agents: { + defaults: { + models: { + "openrouter/openrouter/hunter-alpha": { + params: { thinking: "high" }, + }, + }, + }, + }, + }); + const runtime = makeRuntime(); + + await modelsSetCommand("openrouter/hunter-alpha", runtime); + + expect(writeConfigFile).toHaveBeenCalledTimes(1); + const written = getWrittenConfig(); + expect(written.agents).toEqual({ + defaults: { + model: { primary: "openrouter/hunter-alpha" }, + models: { + "openrouter/hunter-alpha": { + params: { thinking: "high" }, + }, + }, + }, + }); + }); + it("rewrites string defaults.model to object form when setting primary", async () => { mockConfigSnapshot({ agents: { defaults: { model: "openai/gpt-4.1-mini" } } }); const runtime = makeRuntime(); diff --git a/src/commands/models/fallbacks-shared.ts b/src/commands/models/fallbacks-shared.ts index eb1401edd86..b7ffb79f222 100644 --- a/src/commands/models/fallbacks-shared.ts +++ b/src/commands/models/fallbacks-shared.ts @@ -2,6 +2,7 @@ import { buildModelAliasIndex, resolveModelRefFromString } from "../../agents/mo import type { OpenClawConfig } from "../../config/config.js"; import { logConfigUpdated } from "../../config/logging.js"; import { resolveAgentModelFallbackValues, toAgentModelListLike } from "../../config/model-input.js"; +import type { AgentModelEntryConfig } from "../../config/types.agent-defaults.js"; import type { RuntimeEnv } from "../../runtime.js"; import { loadModelsConfig } from "./load-config.js"; import { @@ -11,6 +12,7 @@ import { modelKey, resolveModelTarget, resolveModelKeysFromEntries, + upsertCanonicalModelConfigEntry, updateConfig, } from "./shared.js"; @@ -79,11 +81,10 @@ export async function addFallbackCommand( ) { const updated = await updateConfig((cfg) => { const resolved = resolveModelTarget({ raw: modelRaw, cfg }); - const targetKey = modelKey(resolved.provider, resolved.model); - const nextModels = { ...cfg.agents?.defaults?.models } as Record; - if (!nextModels[targetKey]) { - nextModels[targetKey] = {}; - } + const nextModels = { + ...cfg.agents?.defaults?.models, + } as Record; + const targetKey = upsertCanonicalModelConfigEntry(nextModels, resolved); const existing = getFallbacks(cfg, params.key); const existingKeys = resolveModelKeysFromEntries({ cfg, entries: existing }); if (existingKeys.includes(targetKey)) { diff --git a/src/commands/models/shared.ts b/src/commands/models/shared.ts index 793e7e4b8e3..604b594b613 100644 --- a/src/commands/models/shared.ts +++ b/src/commands/models/shared.ts @@ -2,6 +2,7 @@ import { listAgentIds } from "../../agents/agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; import { buildModelAliasIndex, + legacyModelKey, modelKey, parseModelRef, resolveModelRefFromString, @@ -14,6 +15,7 @@ import { } from "../../config/config.js"; import { formatConfigIssueLines } from "../../config/issue-format.js"; import { toAgentModelListLike } from "../../config/model-input.js"; +import type { AgentModelEntryConfig } from "../../config/types.agent-defaults.js"; import type { AgentModelConfig } from "../../config/types.agents-shared.js"; import { normalizeAgentId } from "../../routing/session-key.js"; @@ -163,6 +165,25 @@ export function resolveKnownAgentId(params: { export type PrimaryFallbackConfig = { primary?: string; fallbacks?: string[] }; +export function upsertCanonicalModelConfigEntry( + models: Record, + params: { provider: string; model: string }, +) { + const key = modelKey(params.provider, params.model); + const legacyKey = legacyModelKey(params.provider, params.model); + if (!models[key]) { + if (legacyKey && models[legacyKey]) { + models[key] = models[legacyKey]; + } else { + models[key] = {}; + } + } + if (legacyKey) { + delete models[legacyKey]; + } + return key; +} + export function mergePrimaryFallbackConfig( existing: PrimaryFallbackConfig | undefined, patch: { primary?: string; fallbacks?: string[] }, @@ -184,12 +205,10 @@ export function applyDefaultModelPrimaryUpdate(params: { field: "model" | "imageModel"; }): OpenClawConfig { const resolved = resolveModelTarget({ raw: params.modelRaw, cfg: params.cfg }); - const key = `${resolved.provider}/${resolved.model}`; - - const nextModels = { ...params.cfg.agents?.defaults?.models }; - if (!nextModels[key]) { - nextModels[key] = {}; - } + const nextModels = { + ...params.cfg.agents?.defaults?.models, + } as Record; + const key = upsertCanonicalModelConfigEntry(nextModels, resolved); const defaults = params.cfg.agents?.defaults ?? {}; const existing = toAgentModelListLike(