fix: canonicalize openrouter native model keys

This commit is contained in:
Peter Steinberger 2026-03-12 16:50:31 +00:00
parent 115f24819e
commit 0b34671de3
No known key found for this signature in database
7 changed files with 149 additions and 14 deletions

View File

@ -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

View File

@ -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: {

View File

@ -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" ||

View File

@ -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) => {

View File

@ -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();

View File

@ -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<string, unknown>;
if (!nextModels[targetKey]) {
nextModels[targetKey] = {};
}
const nextModels = {
...cfg.agents?.defaults?.models,
} as Record<string, AgentModelEntryConfig>;
const targetKey = upsertCanonicalModelConfigEntry(nextModels, resolved);
const existing = getFallbacks(cfg, params.key);
const existingKeys = resolveModelKeysFromEntries({ cfg, entries: existing });
if (existingKeys.includes(targetKey)) {

View File

@ -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<string, AgentModelEntryConfig>,
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<string, AgentModelEntryConfig>;
const key = upsertCanonicalModelConfigEntry(nextModels, resolved);
const defaults = params.cfg.agents?.defaults ?? {};
const existing = toAgentModelListLike(