diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 066c072c..be2be3e8 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -17,6 +17,10 @@ export const ACCOUNT_DEACTIVATED_SIGNALS = [ "account has been disabled", "your account has been suspended", "this account is deactivated", + // AG (Antigravity/Google Cloud Code) permanent ban signals + "verify your account to continue", + "this service has been disabled in this account for violation", + "this service has been disabled in this account", ]; // T10 (sub2api PR #1169): Signals that indicate billing credits are exhausted. diff --git a/src/app/api/settings/auto-disable-accounts/route.ts b/src/app/api/settings/auto-disable-accounts/route.ts new file mode 100644 index 00000000..7cf2b4e9 --- /dev/null +++ b/src/app/api/settings/auto-disable-accounts/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; +import { getSettings, updateSettings } from "@/lib/localDb"; +import { updateAutoDisableAccountsSchema } from "@/shared/validation/schemas"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; + +export async function GET() { + try { + const settings = await getSettings(); + return NextResponse.json({ + enabled: settings.autoDisableBannedAccounts ?? false, + threshold: settings.autoDisableBannedThreshold ?? 3, + }); + } catch (error) { + console.error("Error reading auto-disable accounts config:", error); + return NextResponse.json( + { error: "Failed to read auto-disable accounts config" }, + { status: 500 } + ); + } +} + +export async function PUT(request: Request) { + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json( + { error: { message: "Invalid request", details: [{ field: "body", message: "Invalid JSON body" }] } }, + { status: 400 } + ); + } + + try { + const validation = validateBody(updateAutoDisableAccountsSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + const body = validation.data; + + await updateSettings({ + autoDisableBannedAccounts: body.enabled, + ...(body.threshold !== undefined && { autoDisableBannedThreshold: body.threshold }), + }); + + const settings = await getSettings(); + return NextResponse.json({ + enabled: settings.autoDisableBannedAccounts ?? false, + threshold: settings.autoDisableBannedThreshold ?? 3, + }); + } catch (error) { + console.error("Error updating auto-disable accounts config:", error); + return NextResponse.json( + { error: "Failed to update auto-disable accounts config" }, + { status: 500 } + ); + } +} diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 6af6e4a2..d4e12d41 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1313,3 +1313,11 @@ export const v1SearchResponseSchema = z.object({ ) .optional(), }); + +// ─── Auto-disable banned/error accounts ─────────────────────────────────── +export const updateAutoDisableAccountsSchema = z + .object({ + enabled: z.boolean(), + threshold: z.number().int().min(1).max(10).optional(), + }) + .strict(); diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 571d6e7a..29beb577 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -3,6 +3,7 @@ import { validateApiKey, updateProviderConnection, getSettings, + getCachedSettings, } from "@/lib/localDb"; import { getQuotaWindowStatus, isAccountQuotaExhausted } from "@/domain/quotaCache"; import { @@ -773,13 +774,14 @@ export async function markAccountUnavailable( } } - const { shouldFallback, cooldownMs, newBackoffLevel, reason } = checkFallbackError( + const result = checkFallbackError( status, errorText, backoffLevel, model, provider // ← Now passes provider for profile-aware cooldowns ); + const { shouldFallback, cooldownMs, newBackoffLevel, reason } = result; if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 }; // ── Local provider 404: model-only lockout, connection stays active ── @@ -845,6 +847,28 @@ export async function markAccountUnavailable( backoffLevel: newBackoffLevel ?? backoffLevel, }); + // T-AUTODISABLE: If auto-disable setting is enabled and error is permanent/terminal, + // mark account as inactive so it is never retried again. + // Uses getCachedSettings() to avoid DB overhead on hot error path. + // NOTE: For permanent bans we disable immediately — no threshold needed, + // because a permanent ban (403 "Verify your account" / ToS violation) will + // NEVER recover, so retrying is pointless regardless of attempt count. + if (result.permanent) { + try { + const settings = await getCachedSettings(); + const autoDisableEnabled = settings.autoDisableBannedAccounts ?? false; + if (autoDisableEnabled) { + await updateProviderConnection(connectionId, { isActive: false }); + log.info( + "AUTH", + `Auto-disabled ${connectionId.slice(0, 8)} — permanent ban detected (autoDisableBannedAccounts=true)` + ); + } + } catch (e) { + log.info("AUTH", `Auto-disable check failed (non-fatal): ${e}`); + } + } + // Per-model lockout: lock the specific model if known if (provider && model && cooldownMs > 0) { lockModel(provider, connectionId, model, reason || "unknown", cooldownMs);