ai-chatbot/lib/ai/runtime/agent-model-failover.ts

105 lines
7.2 KiB
TypeScript

import { is3DModelDescriptor, isImageGenerationModelDescriptor, isVideoModelDescriptor } from "../model-classification";
import { isMediaInputFailure } from "./request-failure-classification";
import { isDeepSeekOfficialUrl } from "../deepseek-compatible-fetch";
import { createLogger } from "@/lib/observability/logger";
import type { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
export type AgentModelEntry = Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>[number];
export const AGENT_MODEL_COOLDOWN_MS = 30 * 60_000;
export const AGENT_MODEL_ATTEMPT_MS = 45_000;
export type AgentModelFault = { reason: string; status: number | null };
export interface AgentCircuitStore {
isPaused(entry: AgentModelEntry): Promise<boolean>;
fail(entry: AgentModelEntry, fault: AgentModelFault): Promise<void>;
}
export class AgentModelProtocolError extends Error {
constructor(readonly diagnostic: "invalid_response" | "vision_json" | "vision_schema" | "completion_envelope" | "completion_empty" | "tool_envelope" | "tool_arguments" = "invalid_response") {
super("Agent model returned an invalid response"); this.name = "AgentModelProtocolError";
}
}
const logger = createLogger("canvas-agent-model");
const COMPATIBLE = new Set(["vip98k", "openai", "openai-compatible", "alibaba", "qwen", "local-qwen", "local", "zhipu", "moonshot", "deepseek", "xai", "siliconflow", "custom"]);
export function isAgentMultimodalCandidate(entry: AgentModelEntry) {
const { model, provider } = entry;
const descriptor = { ...model, providerType: provider.type };
const caps = model.capabilities as Record<string, unknown> | null;
return model.isEnabled && provider.isEnabled && model.supportsVision &&
model.healthStatus !== "auto_disabled" && COMPATIBLE.has(provider.type) &&
!["image", "image-tool", "image-edit", "audio", "speech", "embedding", "rerank"].includes(String(caps?.kind || "")) &&
caps?.supportsTools !== false && caps?.supportsToolCalling !== false && caps?.supportsToolCalls !== false && caps?.supportsFunctionCalling !== false &&
!isImageGenerationModelDescriptor(descriptor) && !isVideoModelDescriptor(descriptor) && !is3DModelDescriptor(descriptor);
}
export function agentModelCandidates(primary: AgentModelEntry | undefined, entries: AgentModelEntry[]) {
const eligible = entries.filter(isAgentMultimodalCandidate);
const ordered = [primary?.model.id, primary?.model.fallbackModelId1, primary?.model.fallbackModelId2];
return [...eligible].filter(entry => entry.model.id === primary?.model.id || !entry.model.excludeFromFailover)
.sort((a, b) => {
const rank = (entry: AgentModelEntry) => { const index = ordered.indexOf(entry.model.id); return index < 0 ? 3 : index; };
return rank(a) - rank(b) || Number(b.model.isDefault) - Number(a.model.isDefault) ||
(a.model.sortOrder ?? 0) - (b.model.sortOrder ?? 0) || a.model.id.localeCompare(b.model.id);
});
}
export function agentModelFault(error: unknown, signal?: AbortSignal): AgentModelFault | null {
if (signal?.aborted) return null;
const e = error as { status?: number; statusCode?: number; message?: string; name?: string; cause?: unknown } | null;
const status = Number(e?.statusCode || e?.status) || null;
const message = String(e?.message || "").toLowerCase();
if (isMediaInputFailure(message) || /content.?policy|content.?filter|moderation|safety|违规|敏感|user cancel|user abort/.test(message)) return null;
if (/tools?.*(?:not supported|unsupported)|does not support.*(?:tools?|function.call)/.test(message)) return { reason: "tools_unsupported", status };
if (status && [400, 413, 415, 422].includes(status)) return null;
if (status === 401 || /invalid authentication|invalid.api.key/.test(message)) return { reason: "authentication", status };
if (status === 403) return { reason: "access_denied", status };
if (status === 429 || status === 402) return { reason: "rate_or_quota", status };
if (status === 404 || /model.not.found|invalid.model/.test(message)) return { reason: "model_unavailable", status };
if (status === 408) return { reason: "connection_or_timeout", status };
if (status && status >= 500) return { reason: "upstream_unavailable", status };
if (["AgentModelProtocolError", "AI_NoOutputGeneratedError", "AI_InvalidResponseDataError", "AI_TypeValidationError", "AI_JSONParseError"].includes(String(e?.name)) ||
/未返回可用的回答|未返回所要求的工具|empty.response|no.output.generated/.test(message)) return { reason: "invalid_response", status };
if (/timeout|timed out|fetch failed|econnreset|econnrefused|network|socket|terminated/.test(message) || e?.name === "TimeoutError") return { reason: "connection_or_timeout", status };
return e?.cause ? agentModelFault(e.cause, signal) : null;
}
// Retries only the model computation. Callers must never include business tools
// in execute(): native tool calls are exposed only after a complete valid reply.
export function createAgentModelFailover(entries: AgentModelEntry[], circuit: AgentCircuitStore) {
let preferred: string | undefined;
return async function run<T>(execute: (entry: AgentModelEntry, signal: AbortSignal) => Promise<T>, signal?: AbortSignal): Promise<T> {
const deadline = AbortSignal.timeout(150_000);
const totalSignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
// The official DeepSeek channel is always tried first, including after a
// previous round used a backup. It is explicitly exempt from quarantine.
const official = (entry: AgentModelEntry) => isDeepSeekOfficialUrl(entry.provider.baseUrl);
const ordered = [...entries].sort((a, b) => Number(official(b)) - Number(official(a)) || Number(b.model.id === preferred) - Number(a.model.id === preferred));
let last: unknown;
for (const entry of ordered) {
totalSignal.throwIfAborted();
if (!official(entry) && await circuit.isPaused(entry)) continue;
const attemptSignal = AbortSignal.any([totalSignal, AbortSignal.timeout(AGENT_MODEL_ATTEMPT_MS)]);
try {
const result = await execute(entry, attemptSignal);
totalSignal.throwIfAborted();
preferred = entry.model.id;
// A late success must never erase a concurrent request's newer fault.
return result;
} catch (error) {
const fault = agentModelFault(attemptSignal.aborted && !totalSignal.aborted
? Object.assign(new Error("Agent model timed out"), { name: "TimeoutError" }) : error, totalSignal);
if (!fault) throw error;
// Record only fixed diagnostic codes, never upstream text, prompts or keys.
logger.warn("model_attempt_incomplete", { modelId: entry.model.id, status: fault.status,
diagnostic: error instanceof AgentModelProtocolError ? error.diagnostic : fault.reason,
quarantined: !official(entry) });
// A single failed request can still use a backup, but must neither
// classify nor suspend the official channel for subsequent requests.
if (!official(entry)) await circuit.fail(entry, fault);
last = new Error("Agent 模型请求暂未完成,正在尝试可用通道");
}
}
throw last || new Error("Agent 多模态通道暂不可用,故障通道正在冷却,请稍后重试");
};
}