Skip to content

Commit aec5efe

Browse files
committed
fix(agents): resolve model aliases before fallback
1 parent 06a0cd8 commit aec5efe

4 files changed

Lines changed: 194 additions & 17 deletions

File tree

src/agents/model-fallback.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,62 @@ describe("runWithModelFallback", () => {
358358
expect(run).toHaveBeenCalledWith("openai", "gpt-5.4");
359359
});
360360

361+
it("resolves a persisted bare primary alias before running", async () => {
362+
const cfg = makeCfg({
363+
agents: {
364+
defaults: {
365+
model: {
366+
primary: "anthropic/claude-sonnet-4-6",
367+
fallbacks: [],
368+
},
369+
models: {
370+
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
371+
},
372+
},
373+
},
374+
});
375+
const run = vi.fn().mockResolvedValueOnce("ok");
376+
377+
const result = await runWithModelFallback({
378+
cfg,
379+
provider: "anthropic",
380+
model: "sonnet",
381+
run,
382+
});
383+
384+
expect(result.result).toBe("ok");
385+
expect(run).toHaveBeenCalledTimes(1);
386+
expect(run).toHaveBeenCalledWith("anthropic", "claude-sonnet-4-6");
387+
});
388+
389+
it("resolves a slash-form primary alias before provider/model parsing", async () => {
390+
const cfg = makeCfg({
391+
agents: {
392+
defaults: {
393+
model: {
394+
primary: "openai/xiaomi/mimo-v2-pro-mit",
395+
fallbacks: [],
396+
},
397+
models: {
398+
"openai/xiaomi/mimo-v2-pro-mit": { alias: "xiaomi/mimo-v2-pro-mit" },
399+
},
400+
},
401+
},
402+
});
403+
const run = vi.fn().mockResolvedValueOnce("ok");
404+
405+
const result = await runWithModelFallback({
406+
cfg,
407+
provider: "xiaomi",
408+
model: "mimo-v2-pro-mit",
409+
run,
410+
});
411+
412+
expect(result.result).toBe("ok");
413+
expect(run).toHaveBeenCalledTimes(1);
414+
expect(run).toHaveBeenCalledWith("openai", "xiaomi/mimo-v2-pro-mit");
415+
});
416+
361417
it("falls back on unrecognized errors when candidates remain", async () => {
362418
const cfg = makeCfg();
363419
const run = vi.fn().mockRejectedValueOnce(new Error("bad request")).mockResolvedValueOnce("ok");
@@ -1747,6 +1803,39 @@ describe("runWithModelFallback", () => {
17471803
});
17481804
});
17491805

1806+
it("keeps alias-resolved primary models subject to transient cooldowns", async () => {
1807+
const { dir } = await makeAuthStoreWithCooldown("anthropic", "rate_limit");
1808+
const cfg = makeCfg({
1809+
agents: {
1810+
defaults: {
1811+
model: {
1812+
primary: "anthropic/claude-sonnet-4-6",
1813+
fallbacks: ["anthropic/claude-haiku-3-5", "groq/llama-3.3-70b-versatile"],
1814+
},
1815+
models: {
1816+
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
1817+
},
1818+
},
1819+
},
1820+
});
1821+
1822+
const run = vi.fn().mockResolvedValueOnce("haiku success");
1823+
1824+
const result = await runWithModelFallback({
1825+
cfg,
1826+
provider: "anthropic",
1827+
model: "sonnet",
1828+
run,
1829+
agentDir: dir,
1830+
});
1831+
1832+
expect(result.result).toBe("haiku success");
1833+
expect(run).toHaveBeenCalledTimes(1);
1834+
expect(run).toHaveBeenNthCalledWith(1, "anthropic", "claude-haiku-3-5", {
1835+
allowTransientCooldownProbe: true,
1836+
});
1837+
});
1838+
17501839
it("attempts same-provider fallbacks during overloaded cooldown", async () => {
17511840
const { dir } = await makeAuthStoreWithCooldown("anthropic", "overloaded");
17521841
const cfg = makeCfg({

src/agents/model-fallback.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -507,8 +507,23 @@ function resolveFallbackCandidates(params: {
507507
defaultProvider,
508508
});
509509
const { candidates, addExplicitCandidate } = createModelCandidateCollector(allowlist);
510+
const resolvedModelAlias = resolveModelRefFromString({
511+
raw: modelRaw,
512+
defaultProvider: providerRaw,
513+
aliasIndex,
514+
});
515+
const resolvedProviderModelAlias = resolveModelRefFromString({
516+
raw: `${providerRaw}/${modelRaw}`,
517+
defaultProvider,
518+
aliasIndex,
519+
});
520+
const resolvedPrimary =
521+
(resolvedModelAlias?.alias ? resolvedModelAlias.ref : null) ??
522+
(resolvedProviderModelAlias?.alias ? resolvedProviderModelAlias.ref : null) ??
523+
normalizedPrimary;
524+
const effectivePrimary = normalizeModelRef(resolvedPrimary.provider, resolvedPrimary.model);
510525

511-
addExplicitCandidate(normalizedPrimary);
526+
addExplicitCandidate(effectivePrimary);
512527

513528
const modelFallbacks = (() => {
514529
if (params.fallbacksOverride !== undefined) {
@@ -519,14 +534,14 @@ function resolveFallbackCandidates(params: {
519534
);
520535
// When user runs a different provider than config, only use configured fallbacks
521536
// if the current model is already in that chain (e.g. session on first fallback).
522-
if (normalizedPrimary.provider !== configuredPrimary.provider) {
537+
if (effectivePrimary.provider !== configuredPrimary.provider) {
523538
const isConfiguredFallback = configuredFallbacks.some((raw) => {
524539
const resolved = resolveModelRefFromString({
525540
raw,
526541
defaultProvider,
527542
aliasIndex,
528543
});
529-
return resolved ? sameModelCandidate(resolved.ref, normalizedPrimary) : false;
544+
return resolved ? sameModelCandidate(resolved.ref, effectivePrimary) : false;
530545
});
531546
return isConfiguredFallback ? configuredFallbacks : [];
532547
}
@@ -778,12 +793,14 @@ export async function runWithModelFallback<T>(params: {
778793
};
779794

780795
const hasFallbackCandidates = candidates.length > 1;
796+
const requestedCandidate = candidates[0];
781797

782798
for (let i = 0; i < candidates.length; i += 1) {
783799
const candidate = candidates[i];
784800
const isPrimary = i === 0;
785-
const requestedModel =
786-
params.provider === candidate.provider && params.model === candidate.model;
801+
const requestedModel = requestedCandidate
802+
? sameModelCandidate(candidate, requestedCandidate)
803+
: false;
787804
let runOptions: ModelFallbackRunOptions | undefined;
788805
let attemptedDuringCooldown = false;
789806
let transientProbeProviderForAttempt: string | null = null;

src/agents/model-selection-shared.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -450,12 +450,10 @@ export function resolveModelRefFromString(params: {
450450
if (!model) {
451451
return null;
452452
}
453-
if (!model.includes("/")) {
454-
const aliasKey = normalizeLowercaseStringOrEmpty(model);
455-
const aliasMatch = params.aliasIndex?.byAlias.get(aliasKey);
456-
if (aliasMatch) {
457-
return { ref: aliasMatch.ref, alias: aliasMatch.alias };
458-
}
453+
const aliasKey = normalizeLowercaseStringOrEmpty(model);
454+
const aliasMatch = params.aliasIndex?.byAlias.get(aliasKey);
455+
if (aliasMatch) {
456+
return { ref: aliasMatch.ref, alias: aliasMatch.alias };
459457
}
460458
const parsed = parseModelRefWithCompatAlias({
461459
cfg: params.cfg,
@@ -486,6 +484,12 @@ export function resolveConfiguredModelRef(params: {
486484
allowManifestNormalization: params.allowManifestNormalization,
487485
allowPluginNormalization: params.allowPluginNormalization,
488486
});
487+
const aliasKey = normalizeLowercaseStringOrEmpty(trimmed);
488+
const aliasMatch = aliasIndex.byAlias.get(aliasKey);
489+
if (aliasMatch) {
490+
return aliasMatch.ref;
491+
}
492+
489493
if (!trimmed.includes("/")) {
490494
const openrouterCompatRef = resolveConfiguredOpenRouterCompatAlias({
491495
cfg: params.cfg,
@@ -498,12 +502,6 @@ export function resolveConfiguredModelRef(params: {
498502
return openrouterCompatRef;
499503
}
500504

501-
const aliasKey = normalizeLowercaseStringOrEmpty(trimmed);
502-
const aliasMatch = aliasIndex.byAlias.get(aliasKey);
503-
if (aliasMatch) {
504-
return aliasMatch.ref;
505-
}
506-
507505
const inferredProvider = inferUniqueProviderFromConfiguredModels({
508506
cfg: params.cfg,
509507
model: trimmed,

src/agents/model-selection.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,32 @@ describe("model-selection", () => {
853853
ref: { provider: "opencode-go", model: "kimi-k2.6" },
854854
});
855855
});
856+
857+
it("resolves slash-form aliases before provider/model parsing", () => {
858+
const cfg = {
859+
agents: {
860+
defaults: {
861+
models: {
862+
"openai/xiaomi/mimo-v2-pro-mit": {
863+
alias: "xiaomi/mimo-v2-pro-mit",
864+
},
865+
},
866+
},
867+
},
868+
} as OpenClawConfig;
869+
870+
const result = resolveAllowedModelRef({
871+
cfg,
872+
catalog: [],
873+
raw: "xiaomi/mimo-v2-pro-mit",
874+
defaultProvider: "openai",
875+
});
876+
877+
expect(result).toEqual({
878+
key: "openai/xiaomi/mimo-v2-pro-mit",
879+
ref: { provider: "openai", model: "xiaomi/mimo-v2-pro-mit" },
880+
});
881+
});
856882
});
857883

858884
describe("resolveModelRefFromString", () => {
@@ -882,6 +908,30 @@ describe("model-selection", () => {
882908
expect(resolved?.ref).toEqual({ provider: "openai", model: "gpt-4" });
883909
});
884910

911+
it("prefers slash-form aliases over direct provider/model parsing", () => {
912+
const index = {
913+
byAlias: new Map([
914+
[
915+
"xiaomi/mimo-v2-pro-mit",
916+
{
917+
alias: "xiaomi/mimo-v2-pro-mit",
918+
ref: { provider: "openai", model: "xiaomi/mimo-v2-pro-mit" },
919+
},
920+
],
921+
]),
922+
byKey: new Map(),
923+
};
924+
925+
const resolved = resolveModelRefFromString({
926+
raw: "xiaomi/mimo-v2-pro-mit",
927+
defaultProvider: "anthropic",
928+
aliasIndex: index,
929+
});
930+
931+
expect(resolved?.ref).toEqual({ provider: "openai", model: "xiaomi/mimo-v2-pro-mit" });
932+
expect(resolved?.alias).toBe("xiaomi/mimo-v2-pro-mit");
933+
});
934+
885935
it("strips trailing profile suffix for simple model refs", () => {
886936
const resolved = resolveModelRefFromString({
887937
raw: "gpt-5@myprofile",
@@ -1090,6 +1140,29 @@ describe("model-selection", () => {
10901140
}
10911141
});
10921142

1143+
it("prefers slash-form aliases for configured default models", () => {
1144+
const cfg = {
1145+
agents: {
1146+
defaults: {
1147+
model: { primary: "xiaomi/mimo-v2-pro-mit" },
1148+
models: {
1149+
"openai/xiaomi/mimo-v2-pro-mit": {
1150+
alias: "xiaomi/mimo-v2-pro-mit",
1151+
},
1152+
},
1153+
},
1154+
},
1155+
} as OpenClawConfig;
1156+
1157+
const result = resolveConfiguredModelRef({
1158+
cfg,
1159+
defaultProvider: "anthropic",
1160+
defaultModel: "claude-sonnet-4-6",
1161+
});
1162+
1163+
expect(result).toEqual({ provider: "openai", model: "xiaomi/mimo-v2-pro-mit" });
1164+
});
1165+
10931166
it("should use default provider/model if config is empty", () => {
10941167
const cfg: Partial<OpenClawConfig> = {};
10951168
const result = resolveConfiguredModelRef({

0 commit comments

Comments
 (0)