fix(agents): resolve model aliases before fallback

This commit is contained in:
Peter Steinberger
2026-04-28 20:35:55 +01:00
parent 06a0cd88fb
commit aec5efed8d
4 changed files with 194 additions and 17 deletions

View File

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

View File

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

View File

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

View File

@@ -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<OpenClawConfig> = {};
const result = resolveConfiguredModelRef({