From d13869aab94dd7b3417d0c1601fb31ad2e41159d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 18 Apr 2026 08:24:34 -0700 Subject: [PATCH] fix(models): resolve openrouter compat aliases (#68579) * fix(models): resolve openrouter compat aliases * fix(models): cover openrouter free interactive alias * fix(models): mirror openrouter compat aliases in runtime resolver * fix(models): align openrouter free allowlist aliases --- CHANGELOG.md | 1 + src/agents/model-selection-normalize.ts | 5 + src/agents/model-selection-resolve.test.ts | 58 +++++++ src/agents/model-selection-resolve.ts | 145 +++++++++++++++-- src/agents/model-selection.test.ts | 175 +++++++++++++++++++++ src/agents/model-selection.ts | 161 +++++++++++++++++-- 6 files changed, 527 insertions(+), 18 deletions(-) create mode 100644 src/agents/model-selection-resolve.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7caebc7ef78..93a2fdbd9bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -173,6 +173,7 @@ Docs: https://docs.openclaw.ai - Codex/gateway: fix gateway crash when the codex-acp subprocess terminates abruptly; an unhandled EPIPE on the child stdin stream now routes through graceful client shutdown, rejecting pending requests instead of propagating as an uncaught exception that crashes the entire gateway daemon and all connected channels. Fixes #67886. (#67947) thanks @openperf - Slack/streaming: resolve native streaming recipient teams from the inbound user when available, with a monitor-team fallback, so DM and shared-workspace streams target the right recipient more reliably. - OpenRouter/streaming: treat `reasoning_details.response.output_text` and `reasoning_details.response.text` as visible assistant output on OpenRouter-compatible completions streams, while keeping `reasoning.text` hidden and refusing to surface ambiguous bare `text` items by default so visible replies, thinking blocks, and tool calls can coexist in the same chunk. (#67410) Thanks @neeravmakwana. +- Models/OpenRouter aliases: resolve `openrouter:auto` to the canonical `openrouter/auto` model and map `openrouter:free` to the first configured concrete `openrouter/...:free` model instead of mis-resolving these compatibility aliases under the default provider. (#57066) Thanks @sumiisiaran. ## 2026.4.14 diff --git a/src/agents/model-selection-normalize.ts b/src/agents/model-selection-normalize.ts index f746c0e2c6b..c9c6cb856fe 100644 --- a/src/agents/model-selection-normalize.ts +++ b/src/agents/model-selection-normalize.ts @@ -1,3 +1,4 @@ +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { modelKey as sharedModelKey, normalizeStaticProviderModelId } from "./model-ref-shared.js"; import { findNormalizedProviderKey, @@ -69,6 +70,7 @@ export function normalizeModelRef( } type ParseModelRefOptions = ModelRefNormalizeOptions; +const OPENROUTER_AUTO_COMPAT_ALIAS = "openrouter:auto"; export function parseModelRef( raw: string, @@ -79,6 +81,9 @@ export function parseModelRef( if (!trimmed) { return null; } + if (normalizeLowercaseStringOrEmpty(trimmed) === OPENROUTER_AUTO_COMPAT_ALIAS) { + return normalizeModelRef("openrouter", "auto", options); + } const slash = trimmed.indexOf("/"); if (slash === -1) { return normalizeModelRef(defaultProvider, trimmed, options); diff --git a/src/agents/model-selection-resolve.test.ts b/src/agents/model-selection-resolve.test.ts new file mode 100644 index 00000000000..e51432166c6 --- /dev/null +++ b/src/agents/model-selection-resolve.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.js"; +import { resolveAllowedModelRef, resolveConfiguredModelRef } from "./model-selection-resolve.js"; + +describe("model-selection-resolve OpenRouter compat aliases", () => { + it("resolves openrouter:auto through the canonical OpenRouter auto model", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "openrouter:auto" }, + }, + }, + } as OpenClawConfig; + + expect( + resolveConfiguredModelRef({ + cfg, + defaultProvider: "anthropic", + defaultModel: "claude-sonnet-4-6", + }), + ).toEqual({ provider: "openrouter", model: "openrouter/auto" }); + }); + + it("resolves openrouter:free through the runtime allowlist path", () => { + const cfg = { + agents: { + defaults: { + models: { + "openrouter/meta-llama/llama-3.3-70b-instruct:free": {}, + }, + }, + }, + } as OpenClawConfig; + + const catalog = [ + { + provider: "openrouter", + id: "meta-llama/llama-3.3-70b-instruct:free", + name: "Llama 3.3 70B Free", + }, + ]; + + expect( + resolveAllowedModelRef({ + cfg, + catalog, + raw: "openrouter:free", + defaultProvider: "anthropic", + }), + ).toEqual({ + ref: { + provider: "openrouter", + model: "meta-llama/llama-3.3-70b-instruct:free", + }, + key: "openrouter/meta-llama/llama-3.3-70b-instruct:free", + }); + }); +}); diff --git a/src/agents/model-selection-resolve.ts b/src/agents/model-selection-resolve.ts index 834623910fd..eda53e881a9 100644 --- a/src/agents/model-selection-resolve.ts +++ b/src/agents/model-selection-resolve.ts @@ -15,13 +15,17 @@ import type { ModelCatalogEntry } from "./model-catalog.types.js"; import { splitTrailingAuthProfile } from "./model-ref-profile.js"; import { type ModelRef, + findNormalizedProviderValue, modelKey, + normalizeModelRef, normalizeProviderId, parseModelRef, } from "./model-selection-normalize.js"; let log: ReturnType | null = null; +const OPENROUTER_COMPAT_FREE_ALIAS = "openrouter:free"; + function getLog(): ReturnType { log ??= createSubsystemLogger("model-selection"); return log; @@ -32,6 +36,81 @@ export type ModelAliasIndex = { byKey: Map; }; +function isConcreteOpenRouterFreeModelRef(ref: ModelRef): boolean { + return ref.provider === "openrouter" && ref.model.includes("/") && ref.model.endsWith(":free"); +} + +function resolveConfiguredOpenRouterCompatFreeRef(params: { + cfg: OpenClawConfig; + defaultProvider: string; + allowPluginNormalization?: boolean; +}): ModelRef | null { + const configuredModels = params.cfg.agents?.defaults?.models ?? {}; + for (const raw of Object.keys(configuredModels)) { + if (!raw.includes("/")) { + continue; + } + const parsed = parseModelRef(raw, params.defaultProvider, { + allowPluginNormalization: params.allowPluginNormalization, + }); + if (parsed && isConcreteOpenRouterFreeModelRef(parsed)) { + return parsed; + } + } + + const openrouterProviderConfig = findNormalizedProviderValue( + params.cfg.models?.providers, + "openrouter", + ); + for (const entry of openrouterProviderConfig?.models ?? []) { + const modelId = entry?.id?.trim(); + if (!modelId || !modelId.includes("/") || !modelId.endsWith(":free")) { + continue; + } + return normalizeModelRef("openrouter", modelId, { + allowPluginNormalization: params.allowPluginNormalization, + }); + } + + return null; +} + +function resolveConfiguredOpenRouterCompatAlias(params: { + cfg?: OpenClawConfig; + raw: string; + defaultProvider: string; + allowPluginNormalization?: boolean; +}): ModelRef | null { + const normalized = normalizeLowercaseStringOrEmpty(params.raw); + if (normalized === "openrouter:auto") { + return normalizeModelRef("openrouter", "auto", { + allowPluginNormalization: params.allowPluginNormalization, + }); + } + if (normalized !== OPENROUTER_COMPAT_FREE_ALIAS || !params.cfg) { + return null; + } + return resolveConfiguredOpenRouterCompatFreeRef({ + cfg: params.cfg, + defaultProvider: params.defaultProvider, + allowPluginNormalization: params.allowPluginNormalization, + }); +} + +function parseModelRefWithCompatAlias(params: { + cfg?: OpenClawConfig; + raw: string; + defaultProvider: string; + allowPluginNormalization?: boolean; +}): ModelRef | null { + return ( + resolveConfiguredOpenRouterCompatAlias(params) ?? + parseModelRef(params.raw, params.defaultProvider, { + allowPluginNormalization: params.allowPluginNormalization, + }) + ); +} + function sanitizeModelWarningValue(value: string): string { const stripped = value ? stripAnsi(value) : ""; let controlBoundary = -1; @@ -113,8 +192,16 @@ export function inferUniqueProviderFromConfiguredModels(params: { return providers.values().next().value; } -function resolveAllowlistModelKey(raw: string, defaultProvider: string): string | null { - const parsed = parseModelRef(raw, defaultProvider); +function resolveAllowlistModelKey(params: { + cfg?: OpenClawConfig; + raw: string; + defaultProvider: string; +}): string | null { + const parsed = parseModelRefWithCompatAlias({ + cfg: params.cfg, + raw: params.raw, + defaultProvider: params.defaultProvider, + }); if (!parsed) { return null; } @@ -132,7 +219,11 @@ export function buildConfiguredAllowlistKeys(params: { const keys = new Set(); for (const raw of rawAllowlist) { - const key = resolveAllowlistModelKey(raw, params.defaultProvider); + const key = resolveAllowlistModelKey({ + cfg: params.cfg, + raw, + defaultProvider: params.defaultProvider, + }); if (key) { keys.add(key); } @@ -150,7 +241,10 @@ export function buildModelAliasIndex(params: { const rawModels = params.cfg.agents?.defaults?.models ?? {}; for (const [keyRaw, entryRaw] of Object.entries(rawModels)) { - const parsed = parseModelRef(keyRaw, params.defaultProvider, { + const parsed = parseModelRefWithCompatAlias({ + cfg: params.cfg, + raw: keyRaw, + defaultProvider: params.defaultProvider, allowPluginNormalization: params.allowPluginNormalization, }); if (!parsed) { @@ -189,7 +283,11 @@ function buildModelCatalogMetadata(params: { const aliasByKey = new Map(); const configuredModels = params.cfg.agents?.defaults?.models ?? {}; for (const [rawKey, entryRaw] of Object.entries(configuredModels)) { - const key = resolveAllowlistModelKey(rawKey, params.defaultProvider); + const key = resolveAllowlistModelKey({ + cfg: params.cfg, + raw: rawKey, + defaultProvider: params.defaultProvider, + }); if (!key) { continue; } @@ -251,6 +349,7 @@ function buildSyntheticAllowedCatalogEntry(params: { } export function resolveModelRefFromString(params: { + cfg?: OpenClawConfig; raw: string; defaultProvider: string; aliasIndex?: ModelAliasIndex; @@ -267,7 +366,10 @@ export function resolveModelRefFromString(params: { return { ref: aliasMatch.ref, alias: aliasMatch.alias }; } } - const parsed = parseModelRef(model, params.defaultProvider, { + const parsed = parseModelRefWithCompatAlias({ + cfg: params.cfg, + raw: model, + defaultProvider: params.defaultProvider, allowPluginNormalization: params.allowPluginNormalization, }); if (!parsed) { @@ -291,6 +393,16 @@ export function resolveConfiguredModelRef(params: { allowPluginNormalization: params.allowPluginNormalization, }); if (!trimmed.includes("/")) { + const openRouterCompatRef = resolveConfiguredOpenRouterCompatAlias({ + cfg: params.cfg, + raw: trimmed, + defaultProvider: params.defaultProvider, + allowPluginNormalization: params.allowPluginNormalization, + }); + if (openRouterCompatRef) { + return openRouterCompatRef; + } + const aliasKey = normalizeLowercaseStringOrEmpty(trimmed); const aliasMatch = aliasIndex.byAlias.get(aliasKey); if (aliasMatch) { @@ -314,6 +426,7 @@ export function resolveConfiguredModelRef(params: { } const resolved = resolveModelRefFromString({ + cfg: params.cfg, raw: trimmed, defaultProvider: params.defaultProvider, aliasIndex, @@ -362,7 +475,11 @@ export function buildAllowedModelSet(params: { const defaultModel = params.defaultModel?.trim(); const defaultRef = defaultModel && params.defaultProvider - ? parseModelRef(defaultModel, params.defaultProvider) + ? parseModelRefWithCompatAlias({ + cfg: params.cfg, + raw: defaultModel, + defaultProvider: params.defaultProvider, + }) : null; const defaultKey = defaultRef ? modelKey(defaultRef.provider, defaultRef.model) : undefined; const catalogKeys = new Set(catalog.map((entry) => modelKey(entry.provider, entry.id))); @@ -381,7 +498,11 @@ export function buildAllowedModelSet(params: { const allowedKeys = new Set(); const syntheticCatalogEntries = new Map(); for (const raw of rawAllowlist) { - const parsed = parseModelRef(raw, params.defaultProvider); + const parsed = parseModelRefWithCompatAlias({ + cfg: params.cfg, + raw, + defaultProvider: params.defaultProvider, + }); if (!parsed) { continue; } @@ -394,7 +515,11 @@ export function buildAllowedModelSet(params: { } for (const fallback of resolveAgentModelFallbackValues(params.cfg.agents?.defaults?.model)) { - const parsed = parseModelRef(fallback, params.defaultProvider); + const parsed = parseModelRefWithCompatAlias({ + cfg: params.cfg, + raw: fallback, + defaultProvider: params.defaultProvider, + }); if (!parsed) { continue; } @@ -523,6 +648,7 @@ export function resolveAllowedModelRef(params: { : params.defaultProvider; const resolved = resolveModelRefFromString({ + cfg: params.cfg, raw: trimmed, defaultProvider: effectiveDefaultProvider, aliasIndex, @@ -560,6 +686,7 @@ export function resolveHooksGmailModel(params: { }); const resolved = resolveModelRefFromString({ + cfg: params.cfg, raw: hooksModel, defaultProvider: params.defaultProvider, aliasIndex, diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index e7e0d350434..0601855c0a5 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -261,6 +261,12 @@ describe("model-selection", () => { defaultProvider: "openai", expected: { provider: "openai", model: "gpt-5.4" }, }, + { + name: "normalizes the openrouter:auto compatibility alias", + variants: ["openrouter:auto"], + defaultProvider: "anthropic", + expected: { provider: "openrouter", model: "openrouter/auto" }, + }, { name: "preserves openrouter native model prefixes", variants: ["openrouter/aurora-alpha"], @@ -1118,6 +1124,175 @@ describe("model-selection", () => { resetLogger(); } }); + + it("resolves openrouter:auto through the canonical OpenRouter auto model", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "openrouter:auto" }, + }, + }, + } as OpenClawConfig; + + const result = resolveConfiguredModelRef({ + cfg, + defaultProvider: "anthropic", + defaultModel: "claude-sonnet-4-6", + }); + + expect(result).toEqual({ provider: "openrouter", model: "openrouter/auto" }); + }); + + it("resolves openrouter:free to the first configured concrete OpenRouter free model", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "openrouter:free" }, + models: { + "openrouter/meta-llama/llama-3.3-70b-instruct:free": {}, + }, + }, + }, + } as OpenClawConfig; + + const result = resolveConfiguredModelRef({ + cfg, + defaultProvider: "anthropic", + defaultModel: "claude-sonnet-4-6", + }); + + expect(result).toEqual({ + provider: "openrouter", + model: "meta-llama/llama-3.3-70b-instruct:free", + }); + }); + + it("resolves openrouter:free from configured OpenRouter provider models when needed", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "openrouter:free" }, + }, + }, + models: { + providers: { + openrouter: { + baseUrl: "https://openrouter.ai/api/v1", + models: [ + { + id: "deepseek/deepseek-r1-0528:free", + name: "DeepSeek R1 Free", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }, + ], + }, + }, + }, + } as OpenClawConfig; + + const result = resolveConfiguredModelRef({ + cfg, + defaultProvider: "anthropic", + defaultModel: "claude-sonnet-4-6", + }); + + expect(result).toEqual({ + provider: "openrouter", + model: "deepseek/deepseek-r1-0528:free", + }); + }); + + it("resolves openrouter:free through the allowed-model interactive path", () => { + const cfg = { + agents: { + defaults: { + models: { + "openrouter/meta-llama/llama-3.3-70b-instruct:free": {}, + }, + }, + }, + } as OpenClawConfig; + + const catalog = [ + { + provider: "openrouter", + id: "meta-llama/llama-3.3-70b-instruct:free", + name: "Llama 3.3 70B Free", + }, + ]; + + expect( + resolveAllowedModelRef({ + cfg, + catalog, + raw: "openrouter:free", + defaultProvider: "anthropic", + }), + ).toEqual({ + ref: { + provider: "openrouter", + model: "meta-llama/llama-3.3-70b-instruct:free", + }, + key: "openrouter/meta-llama/llama-3.3-70b-instruct:free", + }); + }); + + it("treats raw openrouter:free allowlist entries as allowed in the legacy resolver path", () => { + const cfg = { + agents: { + defaults: { + models: { + "openrouter:free": {}, + }, + }, + }, + models: { + providers: { + openrouter: { + baseUrl: "https://openrouter.ai/api/v1", + models: [ + { + id: "deepseek/deepseek-r1-0528:free", + name: "DeepSeek R1 Free", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }, + ], + }, + }, + }, + } as OpenClawConfig; + + const catalog = [ + { + provider: "openrouter", + id: "deepseek/deepseek-r1-0528:free", + name: "DeepSeek R1 Free", + }, + ]; + + expect( + resolveAllowedModelRef({ + cfg, + catalog, + raw: "openrouter:free", + defaultProvider: "anthropic", + }), + ).toEqual({ + ref: { + provider: "openrouter", + model: "deepseek/deepseek-r1-0528:free", + }, + key: "openrouter/deepseek/deepseek-r1-0528:free", + }); + }); }); describe("resolveThinkingDefault", () => { diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 385558c43b8..2c94d2a5499 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -39,6 +39,8 @@ function getLog(): ReturnType { return log; } +const OPENROUTER_COMPAT_FREE_ALIAS = "openrouter:free"; + export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive"; export type ModelAliasIndex = { @@ -241,14 +243,104 @@ export function inferUniqueProviderFromConfiguredModels(params: { return providers.values().next().value; } -export function resolveAllowlistModelKey(raw: string, defaultProvider: string): string | null { - const parsed = parseModelRef(raw, defaultProvider); +export function resolveAllowlistModelKey( + raw: string, + defaultProvider: string, + cfg?: OpenClawConfig, +): string | null { + const parsed = parseModelRefWithCompatAlias({ + cfg, + raw, + defaultProvider, + }); if (!parsed) { return null; } return modelKey(parsed.provider, parsed.model); } +function isConcreteOpenRouterFreeModelRef(ref: ModelRef): boolean { + return ref.provider === "openrouter" && ref.model.includes("/") && ref.model.endsWith(":free"); +} + +function resolveConfiguredOpenRouterCompatFreeRef(params: { + cfg: OpenClawConfig; + defaultProvider: string; + allowPluginNormalization?: boolean; +}): ModelRef | null { + const configuredModels = params.cfg.agents?.defaults?.models ?? {}; + for (const raw of Object.keys(configuredModels)) { + if (!raw.includes("/")) { + continue; + } + const parsed = parseModelRef(raw, params.defaultProvider, { + allowPluginNormalization: params.allowPluginNormalization, + }); + if (parsed && isConcreteOpenRouterFreeModelRef(parsed)) { + return parsed; + } + } + + const openrouterProviderConfig = findNormalizedProviderValue( + params.cfg.models?.providers, + "openrouter", + ); + for (const entry of openrouterProviderConfig?.models ?? []) { + const modelId = entry?.id?.trim(); + if (!modelId || !modelId.includes("/") || !modelId.endsWith(":free")) { + continue; + } + return normalizeModelRef("openrouter", modelId, { + allowPluginNormalization: params.allowPluginNormalization, + }); + } + + return null; +} + +function resolveConfiguredOpenRouterCompatAlias(params: { + cfg: OpenClawConfig; + raw: string; + defaultProvider: string; + allowPluginNormalization?: boolean; +}): ModelRef | null { + const normalized = normalizeLowercaseStringOrEmpty(params.raw); + if (normalized === "openrouter:auto") { + return normalizeModelRef("openrouter", "auto", { + allowPluginNormalization: params.allowPluginNormalization, + }); + } + if (normalized !== OPENROUTER_COMPAT_FREE_ALIAS) { + return null; + } + return resolveConfiguredOpenRouterCompatFreeRef({ + cfg: params.cfg, + defaultProvider: params.defaultProvider, + allowPluginNormalization: params.allowPluginNormalization, + }); +} + +function parseModelRefWithCompatAlias(params: { + cfg?: OpenClawConfig; + raw: string; + defaultProvider: string; + allowPluginNormalization?: boolean; +}): ModelRef | null { + return ( + (params.cfg + ? resolveConfiguredOpenRouterCompatAlias({ + cfg: params.cfg, + raw: params.raw, + defaultProvider: params.defaultProvider, + allowPluginNormalization: params.allowPluginNormalization, + }) + : null) ?? + parseModelRef(params.raw, params.defaultProvider, { + allowPluginNormalization: params.allowPluginNormalization, + }) + ); +} + export function buildConfiguredAllowlistKeys(params: { cfg: OpenClawConfig | undefined; defaultProvider: string; @@ -260,7 +352,7 @@ export function buildConfiguredAllowlistKeys(params: { const keys = new Set(); for (const raw of rawAllowlist) { - const key = resolveAllowlistModelKey(raw, params.defaultProvider); + const key = resolveAllowlistModelKey(raw, params.defaultProvider, params.cfg); if (key) { keys.add(key); } @@ -278,7 +370,10 @@ export function buildModelAliasIndex(params: { const rawModels = params.cfg.agents?.defaults?.models ?? {}; for (const [keyRaw, entryRaw] of Object.entries(rawModels)) { - const parsed = parseModelRef(keyRaw, params.defaultProvider, { + const parsed = parseModelRefWithCompatAlias({ + cfg: params.cfg, + raw: keyRaw, + defaultProvider: params.defaultProvider, allowPluginNormalization: params.allowPluginNormalization, }); if (!parsed) { @@ -317,7 +412,7 @@ function buildModelCatalogMetadata(params: { const aliasByKey = new Map(); const configuredModels = params.cfg.agents?.defaults?.models ?? {}; for (const [rawKey, entryRaw] of Object.entries(configuredModels)) { - const key = resolveAllowlistModelKey(rawKey, params.defaultProvider); + const key = resolveAllowlistModelKey(rawKey, params.defaultProvider, params.cfg); if (!key) { continue; } @@ -379,6 +474,7 @@ function buildSyntheticAllowedCatalogEntry(params: { } export function resolveModelRefFromString(params: { + cfg?: OpenClawConfig; raw: string; defaultProvider: string; aliasIndex?: ModelAliasIndex; @@ -395,7 +491,10 @@ export function resolveModelRefFromString(params: { return { ref: aliasMatch.ref, alias: aliasMatch.alias }; } } - const parsed = parseModelRef(model, params.defaultProvider, { + const parsed = parseModelRefWithCompatAlias({ + cfg: params.cfg, + raw: model, + defaultProvider: params.defaultProvider, allowPluginNormalization: params.allowPluginNormalization, }); if (!parsed) { @@ -419,6 +518,16 @@ export function resolveConfiguredModelRef(params: { allowPluginNormalization: params.allowPluginNormalization, }); if (!trimmed.includes("/")) { + const openrouterCompatRef = resolveConfiguredOpenRouterCompatAlias({ + cfg: params.cfg, + raw: trimmed, + defaultProvider: params.defaultProvider, + allowPluginNormalization: params.allowPluginNormalization, + }); + if (openrouterCompatRef) { + return openrouterCompatRef; + } + const aliasKey = normalizeLowercaseStringOrEmpty(trimmed); const aliasMatch = aliasIndex.byAlias.get(aliasKey); if (aliasMatch) { @@ -443,6 +552,7 @@ export function resolveConfiguredModelRef(params: { } const resolved = resolveModelRefFromString({ + cfg: params.cfg, raw: trimmed, defaultProvider: params.defaultProvider, aliasIndex, @@ -569,7 +679,11 @@ export function buildAllowedModelSet(params: { const defaultModel = params.defaultModel?.trim(); const defaultRef = defaultModel && params.defaultProvider - ? parseModelRef(defaultModel, params.defaultProvider) + ? parseModelRefWithCompatAlias({ + cfg: params.cfg, + raw: defaultModel, + defaultProvider: params.defaultProvider, + }) : null; const defaultKey = defaultRef ? modelKey(defaultRef.provider, defaultRef.model) : undefined; const catalogKeys = new Set(catalog.map((entry) => modelKey(entry.provider, entry.id))); @@ -588,7 +702,11 @@ export function buildAllowedModelSet(params: { const allowedKeys = new Set(); const syntheticCatalogEntries = new Map(); for (const raw of rawAllowlist) { - const parsed = parseModelRef(raw, params.defaultProvider); + const parsed = parseModelRefWithCompatAlias({ + cfg: params.cfg, + raw, + defaultProvider: params.defaultProvider, + }); if (!parsed) { continue; } @@ -606,7 +724,11 @@ export function buildAllowedModelSet(params: { cfg: params.cfg, agentId: params.agentId, })) { - const parsed = parseModelRef(fallback, params.defaultProvider); + const parsed = parseModelRefWithCompatAlias({ + cfg: params.cfg, + raw: fallback, + defaultProvider: params.defaultProvider, + }); if (parsed) { const key = modelKey(parsed.provider, parsed.model); allowedKeys.add(key); @@ -728,6 +850,25 @@ export function resolveAllowedModelRef(params: { defaultProvider: params.defaultProvider, }); + const openrouterCompatRef = resolveConfiguredOpenRouterCompatAlias({ + cfg: params.cfg, + raw: trimmed, + defaultProvider: params.defaultProvider, + }); + if (openrouterCompatRef) { + const status = getModelRefStatus({ + cfg: params.cfg, + catalog: params.catalog, + ref: openrouterCompatRef, + defaultProvider: params.defaultProvider, + defaultModel: params.defaultModel, + }); + if (!status.allowed) { + return { error: `model not allowed: ${status.key}` }; + } + return { ref: openrouterCompatRef, key: status.key }; + } + // When the model string has no provider prefix ("/"), try to infer the // correct provider from the configured allowlist before falling back to the // session's current default provider. This prevents provider prefix drift @@ -738,6 +879,7 @@ export function resolveAllowedModelRef(params: { : params.defaultProvider; const resolved = resolveModelRefFromString({ + cfg: params.cfg, raw: trimmed, defaultProvider: effectiveDefaultProvider, aliasIndex, @@ -794,6 +936,7 @@ export function resolveHooksGmailModel(params: { }); const resolved = resolveModelRefFromString({ + cfg: params.cfg, raw: hooksModel, defaultProvider: params.defaultProvider, aliasIndex,