diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 09653f0a5..d58a1df75 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -863,6 +863,8 @@ type AiModel = { healthLastSuccessAt?: string | null; healthAutoDisabledAt?: string | null; healthAutoDisabledReason?: string | null; + healthRecoverySuccessStreak?: number; + healthObservationStartedAt?: string | null; isAgentModel?: boolean; agentCodingModelId?: string; isMultimodal?: boolean; @@ -15677,6 +15679,19 @@ export function AdminSettingsClient() { supportsImageGen: model.supportsImageGen, }); const healthStatus = model.healthStatus || "healthy"; + const isObservationPool = + healthStatus === "degraded" && + Boolean(model.healthObservationStartedAt); + const recoverySuccessStreak = Math.max( + 0, + Number(model.healthRecoverySuccessStreak || 0) + ); + const autoDisableCooldownUntil = model.healthAutoDisabledAt + ? new Date( + new Date(model.healthAutoDisabledAt).getTime() + + 2 * 60 * 60 * 1000 + ) + : null; const modelHealthEvents = modelHealthEventsByModelId.get(model.id) || []; const latestHealthEvent = modelHealthEvents[0]; @@ -15756,7 +15771,9 @@ export function AdminSettingsClient() { )} {healthStatus === "degraded" && ( - 健康观察中 + {isObservationPool + ? `观察池 ${recoverySuccessStreak}/3` + : "健康观察中"} )} @@ -15784,6 +15801,38 @@ export function AdminSettingsClient() { })} )} + {healthStatus === "auto_disabled" && + autoDisableCooldownUntil && ( + + 解冻时间: + {formatBeijingTime(autoDisableCooldownUntil, { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + })} + + )} + {isObservationPool && ( + + 观察成功:{recoverySuccessStreak}/3 + + )} + {isObservationPool && + model.healthObservationStartedAt && ( + + 观察开始: + {formatBeijingTime( + model.healthObservationStartedAt, + { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + } + )} + + )} {(model.healthLastError || latestHealthEvent?.errorMessage) && (
diff --git a/lib/ai/runtime/model-health.ts b/lib/ai/runtime/model-health.ts index a6d4a44f6..72c0e9f32 100644 --- a/lib/ai/runtime/model-health.ts +++ b/lib/ai/runtime/model-health.ts @@ -1,6 +1,6 @@ import "server-only"; -import { and, desc, eq, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, lte, sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { aiModel, @@ -20,6 +20,8 @@ export type AiModelHealthEventType = export type AiModelHealthSeverity = "info" | "warning" | "error" | "critical"; const AUTO_DISABLE_FAILURE_THRESHOLD = 3; +const AUTO_DISABLE_COOLDOWN_MS = 2 * 60 * 60 * 1000; +const OBSERVATION_RECOVERY_SUCCESS_THRESHOLD = 3; const RECENT_EVENT_LIMIT = 80; function normalizeText(value: unknown) { @@ -162,23 +164,157 @@ function buildMetadata(value?: Record) { } } +function isObservationPoolModel( + model: + | Pick< + AiModel, + | "healthStatus" + | "healthObservationStartedAt" + > + | null + | undefined +) { + return ( + model?.healthStatus === "degraded" && + Boolean(model.healthObservationStartedAt) + ); +} + +function getAutoDisableCooldownCutoff(now = new Date()) { + return new Date(now.getTime() - AUTO_DISABLE_COOLDOWN_MS); +} + +export async function reviveAiModelsFromCooldown(params?: { modelId?: string }) { + const now = new Date(); + const cooldownCutoff = getAutoDisableCooldownCutoff(now); + const conditions = [ + eq(aiModel.healthStatus, "auto_disabled"), + eq(aiModel.isEnabled, false), + eq(aiModel.excludeFromFailover, false), + lte(aiModel.healthAutoDisabledAt, cooldownCutoff), + ]; + + if (params?.modelId) { + conditions.push(eq(aiModel.id, normalizeText(params.modelId))); + } + + const dueModels = await db + .select({ + id: aiModel.id, + modelId: aiModel.modelId, + displayName: aiModel.displayName, + }) + .from(aiModel) + .where(and(...conditions)); + + if (dueModels.length === 0) { + return []; + } + + const revived = await db + .update(aiModel) + .set({ + isEnabled: true, + healthStatus: "degraded", + healthConsecutiveFailures: 0, + healthRecoverySuccessStreak: 0, + healthObservationStartedAt: now, + healthUpdatedAt: now, + updatedAt: now, + }) + .where( + inArray( + aiModel.id, + dueModels.map((item) => item.id) + ) + ) + .returning(); + + if (revived.length > 0) { + console.info("[AI Model Health] Revived cooled-down models into observation pool:", { + count: revived.length, + models: revived.map((item) => ({ + id: item.id, + modelId: item.modelId, + displayName: item.displayName, + })), + }); + } + + return revived; +} + export async function recordAiModelHealthSuccess(params: { - model?: Pick | null; + model?: + | Pick< + AiModel, + | "id" + | "healthStatus" + | "healthRecoverySuccessStreak" + | "healthObservationStartedAt" + > + | null; }) { const modelId = normalizeText(params.model?.id); if (!modelId) return; + const [existing] = await db + .select({ + id: aiModel.id, + healthStatus: aiModel.healthStatus, + healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak, + healthObservationStartedAt: aiModel.healthObservationStartedAt, + }) + .from(aiModel) + .where(eq(aiModel.id, modelId)) + .limit(1); + + if (!existing) return; + + const now = new Date(); + const isObservation = isObservationPoolModel(existing); + const nextRecoverySuccessStreak = isObservation + ? (existing.healthRecoverySuccessStreak || 0) + 1 + : 0; + const recoveredToHealthy = + isObservation && + nextRecoverySuccessStreak >= OBSERVATION_RECOVERY_SUCCESS_THRESHOLD; + + const updateData: Partial = { + healthStatus: + existing.healthStatus === "auto_disabled" + ? "auto_disabled" + : recoveredToHealthy + ? "healthy" + : isObservation + ? "degraded" + : "healthy", + healthConsecutiveFailures: 0, + healthLastEventType: "success", + healthLastError: null, + healthLastSuccessAt: now, + healthRecoverySuccessStreak: recoveredToHealthy + ? 0 + : isObservation + ? nextRecoverySuccessStreak + : 0, + healthObservationStartedAt: recoveredToHealthy + ? null + : isObservation + ? existing.healthObservationStartedAt + : null, + healthUpdatedAt: now, + updatedAt: now, + }; + + if (recoveredToHealthy) { + updateData.healthAutoDisabledAt = null; + updateData.healthAutoDisabledReason = null; + } + await db .update(aiModel) - .set({ - healthStatus: - params.model?.healthStatus === "auto_disabled" ? "auto_disabled" : "healthy", - healthConsecutiveFailures: 0, - healthLastEventType: "success", - healthLastError: null, - healthLastSuccessAt: new Date(), - healthUpdatedAt: new Date(), - }) + .set(updateData) .where(eq(aiModel.id, modelId)); } @@ -205,10 +341,15 @@ export async function recordAiModelHealthFailure(params: { const [existing] = await db .select({ id: aiModel.id, + modelId: aiModel.modelId, + displayName: aiModel.displayName, isEnabled: aiModel.isEnabled, excludeFromFailover: aiModel.excludeFromFailover, healthStatus: aiModel.healthStatus, healthConsecutiveFailures: aiModel.healthConsecutiveFailures, + healthRecoverySuccessStreak: aiModel.healthRecoverySuccessStreak, + healthObservationStartedAt: aiModel.healthObservationStartedAt, + healthAutoDisabledAt: aiModel.healthAutoDisabledAt, }) .from(aiModel) .where(eq(aiModel.id, modelId)) @@ -216,14 +357,19 @@ export async function recordAiModelHealthFailure(params: { if (!existing) return; + const isObservation = isObservationPoolModel(existing); const nextConsecutiveFailures = classification.counted - ? Math.max(1, (existing.healthConsecutiveFailures || 0) + 1) + ? Math.max( + 1, + (isObservation ? 0 : existing.healthConsecutiveFailures || 0) + 1 + ) : existing.healthConsecutiveFailures || 0; const shouldAutoDisable = classification.counted && !existing.excludeFromFailover && - existing.healthStatus !== "auto_disabled" && - nextConsecutiveFailures >= AUTO_DISABLE_FAILURE_THRESHOLD; + ((isObservation && existing.isEnabled) || + (existing.healthStatus !== "auto_disabled" && + nextConsecutiveFailures >= AUTO_DISABLE_FAILURE_THRESHOLD)); const nextStatus = shouldAutoDisable ? "auto_disabled" : classification.counted @@ -264,13 +410,23 @@ export async function recordAiModelHealthFailure(params: { healthLastEventType: classification.eventType, healthLastError: errorMessage, healthLastFailureAt: now, + healthRecoverySuccessStreak: shouldAutoDisable + ? 0 + : classification.counted + ? 0 + : existing.healthRecoverySuccessStreak || 0, + healthObservationStartedAt: shouldAutoDisable + ? null + : isObservation + ? existing.healthObservationStartedAt + : null, healthUpdatedAt: now, updatedAt: now, }; if (shouldAutoDisable) { updateData.healthAutoDisabledAt = now; updateData.healthAutoDisabledReason = autoDisabledReason || errorMessage; - } else if (existing.healthStatus !== "auto_disabled") { + } else if (existing.healthStatus !== "auto_disabled" && !isObservation) { updateData.healthAutoDisabledReason = null; } @@ -304,6 +460,8 @@ export async function restoreAiModelHealth(modelId: string) { healthLastFailureAt: null, healthAutoDisabledAt: null, healthAutoDisabledReason: null, + healthRecoverySuccessStreak: 0, + healthObservationStartedAt: null, healthUpdatedAt: new Date(), updatedAt: new Date(), }) diff --git a/lib/db/migrate.ts b/lib/db/migrate.ts index deb3307a2..d4bcbb7bc 100644 --- a/lib/db/migrate.ts +++ b/lib/db/migrate.ts @@ -110,6 +110,14 @@ const runMigrate = async () => { ALTER TABLE "AiModel" ADD COLUMN IF NOT EXISTS "isDefaultTextEditModel" boolean NOT NULL DEFAULT false; `); + await db.execute(sql` + ALTER TABLE "AiModel" + ADD COLUMN IF NOT EXISTS "healthRecoverySuccessStreak" integer NOT NULL DEFAULT 0; + `); + await db.execute(sql` + ALTER TABLE "AiModel" + ADD COLUMN IF NOT EXISTS "healthObservationStartedAt" timestamp; + `); for (const item of providerBaseUrlDefaults) { await db.execute(sql` UPDATE "AiProvider" diff --git a/lib/db/migrations/0080_add_ai_model_health_recovery.sql b/lib/db/migrations/0080_add_ai_model_health_recovery.sql new file mode 100644 index 000000000..db9266981 --- /dev/null +++ b/lib/db/migrations/0080_add_ai_model_health_recovery.sql @@ -0,0 +1,5 @@ +ALTER TABLE "AiModel" +ADD COLUMN IF NOT EXISTS "healthRecoverySuccessStreak" integer NOT NULL DEFAULT 0; + +ALTER TABLE "AiModel" +ADD COLUMN IF NOT EXISTS "healthObservationStartedAt" timestamp; diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json index bc98fadef..17d137ff0 100644 --- a/lib/db/migrations/meta/_journal.json +++ b/lib/db/migrations/meta/_journal.json @@ -463,6 +463,13 @@ "when": 1778252400000, "tag": "0079_add_ai_model_health", "breakpoints": true + }, + { + "idx": 66, + "version": "7", + "when": 1778461200000, + "tag": "0080_add_ai_model_health_recovery", + "breakpoints": true } ] } diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 6a87c2af5..68fc9d71c 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -21,6 +21,7 @@ import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import type { ArtifactKind } from "@/components/artifact"; import type { VisibilityType } from "@/components/visibility-selector"; +import { reviveAiModelsFromCooldown } from "@/lib/ai/runtime/model-health"; import { ChatSDKError } from "../errors"; import type { AppUsage } from "../usage"; import { generateUUID } from "../utils"; @@ -3912,6 +3913,7 @@ export async function deleteAiProvider({ id }: { id: string }) { export async function getAllAiModels(): Promise { try { + await reviveAiModelsFromCooldown(); // 暂时移除排序以避免 sortOrder 类型问题 return await db.select().from(aiModel); } catch (_error) { @@ -3925,6 +3927,7 @@ export async function getAllAiModels(): Promise { export async function getEnabledAiModels(): Promise { try { + await reviveAiModelsFromCooldown(); return await db .select() .from(aiModel) @@ -3943,6 +3946,7 @@ export async function getAiModelsByProviderId({ providerId: string; }): Promise { try { + await reviveAiModelsFromCooldown(); return await db .select() .from(aiModel) @@ -3961,6 +3965,7 @@ export async function getAiModelById({ id: string; }): Promise { try { + await reviveAiModelsFromCooldown({ modelId: id }); const models = await db .select() .from(aiModel) @@ -3977,6 +3982,7 @@ export async function getAiModelById({ export async function getDefaultAiModel(): Promise { try { + await reviveAiModelsFromCooldown(); const models = await db .select() .from(aiModel) @@ -4194,6 +4200,8 @@ export async function updateAiModel({ updateData.healthLastFailureAt = null; updateData.healthAutoDisabledAt = null; updateData.healthAutoDisabledReason = null; + updateData.healthRecoverySuccessStreak = 0; + updateData.healthObservationStartedAt = null; updateData.healthUpdatedAt = new Date(); } } @@ -4477,6 +4485,7 @@ export async function getEnabledAiModelsWithProvider( includeHidden: boolean = true ) { try { + await reviveAiModelsFromCooldown(); const conditions = [ eq(aiModel.isEnabled, true), eq(aiProvider.isEnabled, true), @@ -4499,7 +4508,23 @@ export async function getEnabledAiModelsWithProvider( .innerJoin(aiProvider, eq(aiModel.providerId, aiProvider.id)) .where(and(...conditions)); - return models; + const healthPriority = (status?: string | null) => { + if (status === "healthy") return 0; + if (status === "degraded") return 1; + if (status === "auto_disabled") return 2; + return 3; + }; + + return [...models].sort((left, right) => { + const byHealth = + healthPriority(left.model.healthStatus) - + healthPriority(right.model.healthStatus); + if (byHealth !== 0) return byHealth; + if (left.model.isDefault !== right.model.isDefault) { + return Number(right.model.isDefault) - Number(left.model.isDefault); + } + return (left.model.sortOrder ?? 0) - (right.model.sortOrder ?? 0); + }); } catch (_error) { throw new ChatSDKError( "bad_request:database", @@ -4528,6 +4553,7 @@ export async function getAgentAiModelWithProvider() { */ export async function getSubModelsByParentModelId(parentModelId: string) { try { + await reviveAiModelsFromCooldown(); const subModels = await db .select({ model: aiModel, diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 9482e61a8..8d42a99d9 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -349,6 +349,10 @@ export const aiModel = pgTable("AiModel", { healthLastSuccessAt: timestamp("healthLastSuccessAt"), healthAutoDisabledAt: timestamp("healthAutoDisabledAt"), healthAutoDisabledReason: text("healthAutoDisabledReason"), + healthRecoverySuccessStreak: integer("healthRecoverySuccessStreak") + .notNull() + .default(0), + healthObservationStartedAt: timestamp("healthObservationStartedAt"), healthUpdatedAt: timestamp("healthUpdatedAt").notNull().defaultNow(), // 🆕 图片生成模型特性配置(用于阿里生图、Gemini等) imageGenConfig: jsonb("imageGenConfig"), // { maxImages: 6, supportsSize: true, supportsPromptExtend: true, supportsNegativePrompt: true, enableLoadingPlaceholder: true }