Files
clawdbot/src/agents/model-fallback.ts
2026-01-13 06:50:20 +00:00

354 lines
9.9 KiB
TypeScript

import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import {
coerceToFailoverError,
describeFailoverError,
isFailoverError,
} from "./failover-error.js";
import {
buildModelAliasIndex,
modelKey,
parseModelRef,
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "./model-selection.js";
import type { FailoverReason } from "./pi-embedded-helpers.js";
type ModelCandidate = {
provider: string;
model: string;
};
type FallbackAttempt = {
provider: string;
model: string;
error: string;
reason?: FailoverReason;
status?: number;
code?: string;
};
function isAbortError(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const name = "name" in err ? String(err.name) : "";
if (name === "AbortError") return true;
const message =
"message" in err && typeof err.message === "string"
? err.message.toLowerCase()
: "";
return message.includes("aborted");
}
function buildAllowedModelKeys(
cfg: ClawdbotConfig | undefined,
defaultProvider: string,
): Set<string> | null {
const rawAllowlist = (() => {
const modelMap = cfg?.agents?.defaults?.models ?? {};
return Object.keys(modelMap);
})();
if (rawAllowlist.length === 0) return null;
const keys = new Set<string>();
for (const raw of rawAllowlist) {
const parsed = parseModelRef(String(raw ?? ""), defaultProvider);
if (!parsed) continue;
keys.add(modelKey(parsed.provider, parsed.model));
}
return keys.size > 0 ? keys : null;
}
function resolveImageFallbackCandidates(params: {
cfg: ClawdbotConfig | undefined;
defaultProvider: string;
modelOverride?: string;
}): ModelCandidate[] {
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg ?? {},
defaultProvider: params.defaultProvider,
});
const allowlist = buildAllowedModelKeys(params.cfg, params.defaultProvider);
const seen = new Set<string>();
const candidates: ModelCandidate[] = [];
const addCandidate = (
candidate: ModelCandidate,
enforceAllowlist: boolean,
) => {
if (!candidate.provider || !candidate.model) return;
const key = modelKey(candidate.provider, candidate.model);
if (seen.has(key)) return;
if (enforceAllowlist && allowlist && !allowlist.has(key)) return;
seen.add(key);
candidates.push(candidate);
};
const addRaw = (raw: string, enforceAllowlist: boolean) => {
const resolved = resolveModelRefFromString({
raw: String(raw ?? ""),
defaultProvider: params.defaultProvider,
aliasIndex,
});
if (!resolved) return;
addCandidate(resolved.ref, enforceAllowlist);
};
if (params.modelOverride?.trim()) {
addRaw(params.modelOverride, false);
} else {
const imageModel = params.cfg?.agents?.defaults?.imageModel as
| { primary?: string }
| string
| undefined;
const primary =
typeof imageModel === "string" ? imageModel.trim() : imageModel?.primary;
if (primary?.trim()) addRaw(primary, false);
}
const imageFallbacks = (() => {
const imageModel = params.cfg?.agents?.defaults?.imageModel as
| { fallbacks?: string[] }
| string
| undefined;
if (imageModel && typeof imageModel === "object") {
return imageModel.fallbacks ?? [];
}
return [];
})();
for (const raw of imageFallbacks) {
addRaw(raw, true);
}
return candidates;
}
function resolveFallbackCandidates(params: {
cfg: ClawdbotConfig | undefined;
provider: string;
model: string;
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
}): ModelCandidate[] {
const provider = params.provider.trim() || DEFAULT_PROVIDER;
const model = params.model.trim() || DEFAULT_MODEL;
const primary = params.cfg
? resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
})
: null;
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg ?? {},
defaultProvider: DEFAULT_PROVIDER,
});
const allowlist = buildAllowedModelKeys(params.cfg, DEFAULT_PROVIDER);
const seen = new Set<string>();
const candidates: ModelCandidate[] = [];
const addCandidate = (
candidate: ModelCandidate,
enforceAllowlist: boolean,
) => {
if (!candidate.provider || !candidate.model) return;
const key = modelKey(candidate.provider, candidate.model);
if (seen.has(key)) return;
if (enforceAllowlist && allowlist && !allowlist.has(key)) return;
seen.add(key);
candidates.push(candidate);
};
addCandidate({ provider, model }, false);
const modelFallbacks = (() => {
if (params.fallbacksOverride !== undefined) return params.fallbacksOverride;
const model = params.cfg?.agents?.defaults?.model as
| { fallbacks?: string[] }
| string
| undefined;
if (model && typeof model === "object") return model.fallbacks ?? [];
return [];
})();
for (const raw of modelFallbacks) {
const resolved = resolveModelRefFromString({
raw: String(raw ?? ""),
defaultProvider: DEFAULT_PROVIDER,
aliasIndex,
});
if (!resolved) continue;
addCandidate(resolved.ref, true);
}
if (
params.fallbacksOverride === undefined &&
primary?.provider &&
primary.model
) {
addCandidate({ provider: primary.provider, model: primary.model }, false);
}
return candidates;
}
export async function runWithModelFallback<T>(params: {
cfg: ClawdbotConfig | undefined;
provider: string;
model: string;
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
run: (provider: string, model: string) => Promise<T>;
onError?: (attempt: {
provider: string;
model: string;
error: unknown;
attempt: number;
total: number;
}) => void | Promise<void>;
}): Promise<{
result: T;
provider: string;
model: string;
attempts: FallbackAttempt[];
}> {
const candidates = resolveFallbackCandidates({
cfg: params.cfg,
provider: params.provider,
model: params.model,
fallbacksOverride: params.fallbacksOverride,
});
const attempts: FallbackAttempt[] = [];
let lastError: unknown;
for (let i = 0; i < candidates.length; i += 1) {
const candidate = candidates[i] as ModelCandidate;
try {
const result = await params.run(candidate.provider, candidate.model);
return {
result,
provider: candidate.provider,
model: candidate.model,
attempts,
};
} catch (err) {
if (isAbortError(err)) throw err;
const normalized =
coerceToFailoverError(err, {
provider: candidate.provider,
model: candidate.model,
}) ?? err;
if (!isFailoverError(normalized)) throw err;
lastError = normalized;
const described = describeFailoverError(normalized);
attempts.push({
provider: candidate.provider,
model: candidate.model,
error: described.message,
reason: described.reason,
status: described.status,
code: described.code,
});
await params.onError?.({
provider: candidate.provider,
model: candidate.model,
error: normalized,
attempt: i + 1,
total: candidates.length,
});
}
}
if (attempts.length <= 1 && lastError) throw lastError;
const summary =
attempts.length > 0
? attempts
.map(
(attempt) =>
`${attempt.provider}/${attempt.model}: ${attempt.error}${
attempt.reason ? ` (${attempt.reason})` : ""
}`,
)
.join(" | ")
: "unknown";
throw new Error(
`All models failed (${attempts.length || candidates.length}): ${summary}`,
{ cause: lastError instanceof Error ? lastError : undefined },
);
}
export async function runWithImageModelFallback<T>(params: {
cfg: ClawdbotConfig | undefined;
modelOverride?: string;
run: (provider: string, model: string) => Promise<T>;
onError?: (attempt: {
provider: string;
model: string;
error: unknown;
attempt: number;
total: number;
}) => void | Promise<void>;
}): Promise<{
result: T;
provider: string;
model: string;
attempts: FallbackAttempt[];
}> {
const candidates = resolveImageFallbackCandidates({
cfg: params.cfg,
defaultProvider: DEFAULT_PROVIDER,
modelOverride: params.modelOverride,
});
if (candidates.length === 0) {
throw new Error(
"No image model configured. Set agents.defaults.imageModel.primary or agents.defaults.imageModel.fallbacks.",
);
}
const attempts: FallbackAttempt[] = [];
let lastError: unknown;
for (let i = 0; i < candidates.length; i += 1) {
const candidate = candidates[i] as ModelCandidate;
try {
const result = await params.run(candidate.provider, candidate.model);
return {
result,
provider: candidate.provider,
model: candidate.model,
attempts,
};
} catch (err) {
if (isAbortError(err)) throw err;
lastError = err;
attempts.push({
provider: candidate.provider,
model: candidate.model,
error: err instanceof Error ? err.message : String(err),
});
await params.onError?.({
provider: candidate.provider,
model: candidate.model,
error: err,
attempt: i + 1,
total: candidates.length,
});
}
}
if (attempts.length <= 1 && lastError) throw lastError;
const summary =
attempts.length > 0
? attempts
.map(
(attempt) =>
`${attempt.provider}/${attempt.model}: ${attempt.error}`,
)
.join(" | ")
: "unknown";
throw new Error(
`All image models failed (${attempts.length || candidates.length}): ${summary}`,
{ cause: lastError instanceof Error ? lastError : undefined },
);
}