From 82dd4aa403f718e22041d1bb781f33f66e466c2d Mon Sep 17 00:00:00 2001 From: oyi77 Date: Sun, 29 Mar 2026 23:24:27 +0700 Subject: [PATCH] feat: auto-disable banned accounts setting with UI toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a configurable setting to automatically disable provider accounts that return permanent/terminal errors (403 banned, ToS violation, etc.) Changes: - open-sse/services/accountFallback.ts: extend ACCOUNT_DEACTIVATED_SIGNALS with AG-specific ban messages ('verify your account', 'service disabled for violation') - src/app/api/settings/auto-disable-accounts/route.ts: new GET/PUT endpoint for the setting (enabled bool + threshold int) - src/shared/validation/schemas.ts: updateAutoDisableAccountsSchema - src/sse/services/auth.ts: in markAccountUnavailable(), capture result.permanent from checkFallbackError() and — when autoDisableBannedAccounts is enabled and backoffLevel >= threshold — set isActive=false on the connection Default: disabled (backward-compatible). Enable via Settings UI or PUT /api/settings/auto-disable-accounts { "enabled": true, "threshold": 3 } Fixes: antigravity accounts with 403/Verify-your-account errors being retried indefinitely in the rotation pool. Co-authored-by: oyi77 --- open-sse/services/accountFallback.ts | 4 ++ .../settings/auto-disable-accounts/route.ts | 57 +++++++++++++++++++ src/shared/validation/schemas.ts | 8 +++ src/sse/services/auth.ts | 23 +++++++- 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 src/app/api/settings/auto-disable-accounts/route.ts diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 066c072cd2..be2be3e8c1 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 0000000000..7cf2b4e953 --- /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 0c32f398c6..6633ef517c 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1311,3 +1311,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 20ba37e35e..b9b6573eaf 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -748,13 +748,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 ── @@ -820,6 +821,26 @@ 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. + if (result.permanent) { + try { + const settings = await getSettings(); + const autoDisableEnabled = settings.autoDisableBannedAccounts ?? false; + const threshold = Number(settings.autoDisableBannedThreshold ?? 3); + const newBackoff = newBackoffLevel ?? backoffLevel; + if (autoDisableEnabled && newBackoff >= threshold) { + await updateProviderConnection(connectionId, { isActive: false }); + log.info( + "AUTH", + `Auto-disabled ${connectionId.slice(0, 8)} — permanent error after ${newBackoff} failures (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);