From 82dd4aa403f718e22041d1bb781f33f66e466c2d Mon Sep 17 00:00:00 2001 From: oyi77 Date: Sun, 29 Mar 2026 23:24:27 +0700 Subject: [PATCH 1/3] 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); From d5bf0d1199731dba72a8db1a10cc6ca1f0591176 Mon Sep 17 00:00:00 2001 From: oyi77 Date: Mon, 30 Mar 2026 01:47:28 +0700 Subject: [PATCH 2/3] fix: address reviewer comments for auto-disable (use getCachedSettings, immediate disable on permanent bans) --- src/sse/services/auth.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index b9b6573eaf..97af742ab7 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 { @@ -823,17 +824,19 @@ export async function markAccountUnavailable( // 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 getSettings(); + const settings = await getCachedSettings(); const autoDisableEnabled = settings.autoDisableBannedAccounts ?? false; - const threshold = Number(settings.autoDisableBannedThreshold ?? 3); - const newBackoff = newBackoffLevel ?? backoffLevel; - if (autoDisableEnabled && newBackoff >= threshold) { + if (autoDisableEnabled) { await updateProviderConnection(connectionId, { isActive: false }); log.info( "AUTH", - `Auto-disabled ${connectionId.slice(0, 8)} — permanent error after ${newBackoff} failures (autoDisableBannedAccounts=true)` + `Auto-disabled ${connectionId.slice(0, 8)} — permanent ban detected (autoDisableBannedAccounts=true)` ); } } catch (e) { From d0c172830c3031f97ee08f9ac4c6de851e4b578e Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 29 Mar 2026 15:57:19 -0300 Subject: [PATCH 3/3] feat(ui): add AutoDisableCard to Resilience settings (#765) --- .../settings/components/AutoDisableCard.tsx | 132 ++++++++++++++++++ .../settings/components/ResilienceTab.tsx | 3 + src/i18n/messages/en.json | 4 + 3 files changed, 139 insertions(+) create mode 100644 src/app/(dashboard)/dashboard/settings/components/AutoDisableCard.tsx diff --git a/src/app/(dashboard)/dashboard/settings/components/AutoDisableCard.tsx b/src/app/(dashboard)/dashboard/settings/components/AutoDisableCard.tsx new file mode 100644 index 0000000000..3d3d0c3387 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/AutoDisableCard.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, Button, Input } from "@/shared/components"; +import { useTranslations } from "next-intl"; +import { useNotificationStore } from "@/store/notificationStore"; + +export default function AutoDisableCard() { + const [data, setData] = useState({ enabled: false, threshold: 3 }); + const [draft, setDraft] = useState({ enabled: false, threshold: 3 }); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [editMode, setEditMode] = useState(false); + const t = useTranslations("settings"); + const tc = useTranslations("common"); + const notify = useNotificationStore(); + + useEffect(() => { + fetch("/api/settings/auto-disable-accounts") + .then((res) => res.json()) + .then((json) => { + setData(json); + setDraft(json); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, []); + + const handleSave = async () => { + setSaving(true); + try { + const res = await fetch("/api/settings/auto-disable-accounts", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(draft), + }); + if (!res.ok) throw new Error("Failed to save auto-disable config"); + const savedData = await res.json(); + setData(savedData); + setEditMode(false); + notify.success(t("savedSuccessfully") || "Saved successfully"); + } catch (err) { + notify.error(err instanceof Error ? err.message : "Error saving"); + } finally { + setSaving(false); + } + }; + + if (loading) return null; + + return ( + +
+
+
+ +

{t("autoDisableBannedAccounts")}

+
+ {editMode ? ( +
+ + +
+ ) : ( + + )} +
+ +

{t("autoDisableDescription")}

+ +
+
+ +
+ +
+

+ {t("autoDisableThreshold")} +

+ {editMode ? ( + + setDraft((prev) => ({ ...prev, threshold: parseInt(e.target.value) || 1 })) + } + disabled={!draft.enabled} + /> + ) : ( + + {data.threshold} {t("failures", { count: data.threshold })} + + )} +

{t("autoDisableThresholdDesc")}

+
+
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx index 4ee1bc349f..6e127dac38 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react"; import { Card, Button } from "@/shared/components"; import { useNotificationStore } from "@/store/notificationStore"; import { useLocale, useTranslations } from "next-intl"; +import AutoDisableCard from "./AutoDisableCard"; // ─── State colors and labels ────────────────────────────────────────────── const STATE_STYLES = { @@ -656,6 +657,8 @@ export default function ResilienceTab() { onSave={handleSaveProfiles} saving={saving} /> + {/* 1.5 Auto Disable Banned Accounts */} + {/* 2. Rate Limiting (editable defaults + active limiters) */}