feat: auto-disable permanently banned provider accounts (with Settings toggle) (#765)
* feat: auto-disable banned accounts setting with UI toggle
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 <oyi77@users.noreply.github.com>
* fix: address reviewer comments for auto-disable (use getCachedSettings, immediate disable on permanent bans)
---------
Co-authored-by: oyi77 <oyi77@github.com>
Co-authored-by: oyi77 <oyi77@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user