From aec5efed8d4385ec384c27105f4c2cc844015784 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 20:35:55 +0100 Subject: [PATCH] fix(agents): resolve model aliases before fallback --- src/agents/model-fallback.test.ts | 89 ++++++++++++++++++++++++++++ src/agents/model-fallback.ts | 27 +++++++-- src/agents/model-selection-shared.ts | 22 ++++--- src/agents/model-selection.test.ts | 73 +++++++++++++++++++++++ 4 files changed, 194 insertions(+), 17 deletions(-) diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 6e621b3039b..b8af3233ee0 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -358,6 +358,62 @@ describe("runWithModelFallback", () => { expect(run).toHaveBeenCalledWith("openai", "gpt-5.4"); }); + it("resolves a persisted bare primary alias before running", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-sonnet-4-6", + fallbacks: [], + }, + models: { + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + }, + }, + }); + const run = vi.fn().mockResolvedValueOnce("ok"); + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "sonnet", + run, + }); + + expect(result.result).toBe("ok"); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith("anthropic", "claude-sonnet-4-6"); + }); + + it("resolves a slash-form primary alias before provider/model parsing", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "openai/xiaomi/mimo-v2-pro-mit", + fallbacks: [], + }, + models: { + "openai/xiaomi/mimo-v2-pro-mit": { alias: "xiaomi/mimo-v2-pro-mit" }, + }, + }, + }, + }); + const run = vi.fn().mockResolvedValueOnce("ok"); + + const result = await runWithModelFallback({ + cfg, + provider: "xiaomi", + model: "mimo-v2-pro-mit", + run, + }); + + expect(result.result).toBe("ok"); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith("openai", "xiaomi/mimo-v2-pro-mit"); + }); + it("falls back on unrecognized errors when candidates remain", async () => { const cfg = makeCfg(); const run = vi.fn().mockRejectedValueOnce(new Error("bad request")).mockResolvedValueOnce("ok"); @@ -1747,6 +1803,39 @@ describe("runWithModelFallback", () => { }); }); + it("keeps alias-resolved primary models subject to transient cooldowns", async () => { + const { dir } = await makeAuthStoreWithCooldown("anthropic", "rate_limit"); + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "anthropic/claude-sonnet-4-6", + fallbacks: ["anthropic/claude-haiku-3-5", "groq/llama-3.3-70b-versatile"], + }, + models: { + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + }, + }, + }, + }); + + const run = vi.fn().mockResolvedValueOnce("haiku success"); + + const result = await runWithModelFallback({ + cfg, + provider: "anthropic", + model: "sonnet", + run, + agentDir: dir, + }); + + expect(result.result).toBe("haiku success"); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenNthCalledWith(1, "anthropic", "claude-haiku-3-5", { + allowTransientCooldownProbe: true, + }); + }); + it("attempts same-provider fallbacks during overloaded cooldown", async () => { const { dir } = await makeAuthStoreWithCooldown("anthropic", "overloaded"); const cfg = makeCfg({ diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index bf10e92d757..402ff4f59fb 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -507,8 +507,23 @@ function resolveFallbackCandidates(params: { defaultProvider, }); const { candidates, addExplicitCandidate } = createModelCandidateCollector(allowlist); + const resolvedModelAlias = resolveModelRefFromString({ + raw: modelRaw, + defaultProvider: providerRaw, + aliasIndex, + }); + const resolvedProviderModelAlias = resolveModelRefFromString({ + raw: `${providerRaw}/${modelRaw}`, + defaultProvider, + aliasIndex, + }); + const resolvedPrimary = + (resolvedModelAlias?.alias ? resolvedModelAlias.ref : null) ?? + (resolvedProviderModelAlias?.alias ? resolvedProviderModelAlias.ref : null) ?? + normalizedPrimary; + const effectivePrimary = normalizeModelRef(resolvedPrimary.provider, resolvedPrimary.model); - addExplicitCandidate(normalizedPrimary); + addExplicitCandidate(effectivePrimary); const modelFallbacks = (() => { if (params.fallbacksOverride !== undefined) { @@ -519,14 +534,14 @@ function resolveFallbackCandidates(params: { ); // When user runs a different provider than config, only use configured fallbacks // if the current model is already in that chain (e.g. session on first fallback). - if (normalizedPrimary.provider !== configuredPrimary.provider) { + if (effectivePrimary.provider !== configuredPrimary.provider) { const isConfiguredFallback = configuredFallbacks.some((raw) => { const resolved = resolveModelRefFromString({ raw, defaultProvider, aliasIndex, }); - return resolved ? sameModelCandidate(resolved.ref, normalizedPrimary) : false; + return resolved ? sameModelCandidate(resolved.ref, effectivePrimary) : false; }); return isConfiguredFallback ? configuredFallbacks : []; } @@ -778,12 +793,14 @@ export async function runWithModelFallback(params: { }; const hasFallbackCandidates = candidates.length > 1; + const requestedCandidate = candidates[0]; for (let i = 0; i < candidates.length; i += 1) { const candidate = candidates[i]; const isPrimary = i === 0; - const requestedModel = - params.provider === candidate.provider && params.model === candidate.model; + const requestedModel = requestedCandidate + ? sameModelCandidate(candidate, requestedCandidate) + : false; let runOptions: ModelFallbackRunOptions | undefined; let attemptedDuringCooldown = false; let transientProbeProviderForAttempt: string | null = null; diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts index 8be9870d548..93a79a412c7 100644 --- a/src/agents/model-selection-shared.ts +++ b/src/agents/model-selection-shared.ts @@ -450,12 +450,10 @@ export function resolveModelRefFromString(params: { if (!model) { return null; } - if (!model.includes("/")) { - const aliasKey = normalizeLowercaseStringOrEmpty(model); - const aliasMatch = params.aliasIndex?.byAlias.get(aliasKey); - if (aliasMatch) { - return { ref: aliasMatch.ref, alias: aliasMatch.alias }; - } + const aliasKey = normalizeLowercaseStringOrEmpty(model); + const aliasMatch = params.aliasIndex?.byAlias.get(aliasKey); + if (aliasMatch) { + return { ref: aliasMatch.ref, alias: aliasMatch.alias }; } const parsed = parseModelRefWithCompatAlias({ cfg: params.cfg, @@ -486,6 +484,12 @@ export function resolveConfiguredModelRef(params: { allowManifestNormalization: params.allowManifestNormalization, allowPluginNormalization: params.allowPluginNormalization, }); + const aliasKey = normalizeLowercaseStringOrEmpty(trimmed); + const aliasMatch = aliasIndex.byAlias.get(aliasKey); + if (aliasMatch) { + return aliasMatch.ref; + } + if (!trimmed.includes("/")) { const openrouterCompatRef = resolveConfiguredOpenRouterCompatAlias({ cfg: params.cfg, @@ -498,12 +502,6 @@ export function resolveConfiguredModelRef(params: { return openrouterCompatRef; } - const aliasKey = normalizeLowercaseStringOrEmpty(trimmed); - const aliasMatch = aliasIndex.byAlias.get(aliasKey); - if (aliasMatch) { - return aliasMatch.ref; - } - const inferredProvider = inferUniqueProviderFromConfiguredModels({ cfg: params.cfg, model: trimmed, diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 5a52174fa21..765cb34deb4 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -853,6 +853,32 @@ describe("model-selection", () => { ref: { provider: "opencode-go", model: "kimi-k2.6" }, }); }); + + it("resolves slash-form aliases before provider/model parsing", () => { + const cfg = { + agents: { + defaults: { + models: { + "openai/xiaomi/mimo-v2-pro-mit": { + alias: "xiaomi/mimo-v2-pro-mit", + }, + }, + }, + }, + } as OpenClawConfig; + + const result = resolveAllowedModelRef({ + cfg, + catalog: [], + raw: "xiaomi/mimo-v2-pro-mit", + defaultProvider: "openai", + }); + + expect(result).toEqual({ + key: "openai/xiaomi/mimo-v2-pro-mit", + ref: { provider: "openai", model: "xiaomi/mimo-v2-pro-mit" }, + }); + }); }); describe("resolveModelRefFromString", () => { @@ -882,6 +908,30 @@ describe("model-selection", () => { expect(resolved?.ref).toEqual({ provider: "openai", model: "gpt-4" }); }); + it("prefers slash-form aliases over direct provider/model parsing", () => { + const index = { + byAlias: new Map([ + [ + "xiaomi/mimo-v2-pro-mit", + { + alias: "xiaomi/mimo-v2-pro-mit", + ref: { provider: "openai", model: "xiaomi/mimo-v2-pro-mit" }, + }, + ], + ]), + byKey: new Map(), + }; + + const resolved = resolveModelRefFromString({ + raw: "xiaomi/mimo-v2-pro-mit", + defaultProvider: "anthropic", + aliasIndex: index, + }); + + expect(resolved?.ref).toEqual({ provider: "openai", model: "xiaomi/mimo-v2-pro-mit" }); + expect(resolved?.alias).toBe("xiaomi/mimo-v2-pro-mit"); + }); + it("strips trailing profile suffix for simple model refs", () => { const resolved = resolveModelRefFromString({ raw: "gpt-5@myprofile", @@ -1090,6 +1140,29 @@ describe("model-selection", () => { } }); + it("prefers slash-form aliases for configured default models", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "xiaomi/mimo-v2-pro-mit" }, + models: { + "openai/xiaomi/mimo-v2-pro-mit": { + alias: "xiaomi/mimo-v2-pro-mit", + }, + }, + }, + }, + } as OpenClawConfig; + + const result = resolveConfiguredModelRef({ + cfg, + defaultProvider: "anthropic", + defaultModel: "claude-sonnet-4-6", + }); + + expect(result).toEqual({ provider: "openai", model: "xiaomi/mimo-v2-pro-mit" }); + }); + it("should use default provider/model if config is empty", () => { const cfg: Partial = {}; const result = resolveConfiguredModelRef({