mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
fix(agents): resolve model aliases before fallback
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user