Spaces:
Paused
feat: detect banned accounts and show status in dashboard (#142)
Browse files* feat: detect banned accounts and show status in dashboard
When upstream returns non-Cloudflare 403, the account is now marked
as "banned" instead of silently failing quota fetches with no UI
feedback.
- Add "banned" to AccountStatus type
- usage-refresher: detect 403 ban during quota fetch, auto-recover on success
- proxy-handler: detect 403 ban during requests, fallback to next account
- Dashboard: rose-colored "Banned"/"已封禁" badge in card/table views
- Status filter dropdown includes "Banned" option
- acquire() naturally skips banned (status !== "active")
* feat: handle 401 token invalidation with auto-retry
Previously 401 "token has been invalidated" was passed through to
the client without marking the account or trying alternatives.
- proxy-handler: 401 → markStatus("expired") + try next account
- usage-refresher: 401 → markStatus("expired") during quota fetch
- Refresh scheduler will attempt token renewal for expired accounts
---------
Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>
- CHANGELOG.md +10 -0
- shared/i18n/translations.ts +2 -0
- src/auth/__tests__/ban-detection.test.ts +109 -0
- src/auth/account-pool.ts +6 -3
- src/auth/types.ts +2 -1
- src/auth/usage-refresher.ts +39 -4
- src/routes/shared/proxy-handler.ts +59 -0
- web/src/components/AccountCard.tsx +4 -0
- web/src/components/AccountTable.tsx +5 -0
|
@@ -6,6 +6,16 @@
|
|
| 6 |
|
| 7 |
## [Unreleased]
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
### Fixed
|
| 10 |
|
| 11 |
- Responses SSE 新事件(`response.output_item.added` with `item.type=message`、`response.content_part.added/done`)未被识别,导致 `[CodexEvents] Unknown event` 日志刷屏
|
|
|
|
| 6 |
|
| 7 |
## [Unreleased]
|
| 8 |
|
| 9 |
+
### Added
|
| 10 |
+
|
| 11 |
+
- 账号封禁检测:上游返回非 Cloudflare 的 403 时自动标记为 `banned` 状态
|
| 12 |
+
- Dashboard 卡片/表格显示玫红色 `Banned`/`已封禁` 状态徽章
|
| 13 |
+
- 状态筛选下拉新增 `Banned` 选项
|
| 14 |
+
- 被封账号自动跳过(`acquire()` 仅选 active),请求时自动切换到其他账号
|
| 15 |
+
- 后台额度刷新周期性重试 banned 账号,成功即自动解封
|
| 16 |
+
- 上游 401 token 吊销("token has been invalidated")自动标记过期并切换下一个账号
|
| 17 |
+
- 之前 401 直接透传给客户端,不标记也不重试
|
| 18 |
+
|
| 19 |
### Fixed
|
| 20 |
|
| 21 |
- Responses SSE 新事件(`response.output_item.added` with `item.type=message`、`response.content_part.added/done`)未被识别,导致 `[CodexEvents] Unknown event` 日志刷屏
|
|
@@ -16,6 +16,7 @@ export const translations = {
|
|
| 16 |
rateLimited: "Rate Limited",
|
| 17 |
refreshing: "Refreshing",
|
| 18 |
disabled: "Disabled",
|
|
|
|
| 19 |
freeTier: "Free Tier",
|
| 20 |
totalRequests: "Total Requests",
|
| 21 |
tokensUsed: "Tokens Used",
|
|
@@ -220,6 +221,7 @@ export const translations = {
|
|
| 220 |
rateLimited: "\u5df2\u9650\u901f",
|
| 221 |
refreshing: "\u5237\u65b0\u4e2d",
|
| 222 |
disabled: "\u5df2\u7981\u7528",
|
|
|
|
| 223 |
freeTier: "\u514d\u8d39\u7248",
|
| 224 |
totalRequests: "\u603b\u8bf7\u6c42\u6570",
|
| 225 |
tokensUsed: "Token \u7528\u91cf",
|
|
|
|
| 16 |
rateLimited: "Rate Limited",
|
| 17 |
refreshing: "Refreshing",
|
| 18 |
disabled: "Disabled",
|
| 19 |
+
banned: "Banned",
|
| 20 |
freeTier: "Free Tier",
|
| 21 |
totalRequests: "Total Requests",
|
| 22 |
tokensUsed: "Tokens Used",
|
|
|
|
| 221 |
rateLimited: "\u5df2\u9650\u901f",
|
| 222 |
refreshing: "\u5237\u65b0\u4e2d",
|
| 223 |
disabled: "\u5df2\u7981\u7528",
|
| 224 |
+
banned: "\u5df2\u5c01\u7981",
|
| 225 |
freeTier: "\u514d\u8d39\u7248",
|
| 226 |
totalRequests: "\u603b\u8bf7\u6c42\u6570",
|
| 227 |
tokensUsed: "Token \u7528\u91cf",
|
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Tests for banned account detection.
|
| 3 |
+
*
|
| 4 |
+
* Verifies:
|
| 5 |
+
* 1. Non-CF 403 from quota fetch marks account as banned
|
| 6 |
+
* 2. CF 403 (challenge page) does NOT mark as banned
|
| 7 |
+
* 3. Banned accounts are skipped by acquire()
|
| 8 |
+
* 4. Banned accounts auto-recover when quota fetch succeeds
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
| 12 |
+
|
| 13 |
+
vi.mock("../../config.js", () => ({
|
| 14 |
+
getConfig: vi.fn(() => ({
|
| 15 |
+
server: {},
|
| 16 |
+
model: { default: "gpt-5.2-codex" },
|
| 17 |
+
api: { base_url: "https://chatgpt.com/backend-api" },
|
| 18 |
+
client: { app_version: "1.0.0" },
|
| 19 |
+
auth: { refresh_margin_seconds: 300 },
|
| 20 |
+
quota: {
|
| 21 |
+
refresh_interval_minutes: 5,
|
| 22 |
+
skip_exhausted: true,
|
| 23 |
+
warning_thresholds: { primary: [80, 90], secondary: [80, 90] },
|
| 24 |
+
},
|
| 25 |
+
})),
|
| 26 |
+
}));
|
| 27 |
+
|
| 28 |
+
vi.mock("../../paths.js", () => ({
|
| 29 |
+
getConfigDir: vi.fn(() => "/tmp/test-config"),
|
| 30 |
+
getDataDir: vi.fn(() => "/tmp/test-data"),
|
| 31 |
+
}));
|
| 32 |
+
|
| 33 |
+
vi.mock("fs", async (importOriginal) => {
|
| 34 |
+
const actual = await importOriginal<typeof import("fs")>();
|
| 35 |
+
return {
|
| 36 |
+
...actual,
|
| 37 |
+
readFileSync: vi.fn(() => "models: []\naliases: {}"),
|
| 38 |
+
writeFileSync: vi.fn(),
|
| 39 |
+
writeFile: vi.fn((_p: string, _d: string, _e: string, cb: (err: Error | null) => void) => cb(null)),
|
| 40 |
+
existsSync: vi.fn(() => false),
|
| 41 |
+
mkdirSync: vi.fn(),
|
| 42 |
+
};
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
vi.mock("js-yaml", () => ({
|
| 46 |
+
default: {
|
| 47 |
+
load: vi.fn(() => ({ models: [], aliases: {} })),
|
| 48 |
+
dump: vi.fn(() => ""),
|
| 49 |
+
},
|
| 50 |
+
}));
|
| 51 |
+
|
| 52 |
+
import type { AccountEntry, AccountStatus } from "../types.js";
|
| 53 |
+
import { CodexApiError } from "../../proxy/codex-types.js";
|
| 54 |
+
|
| 55 |
+
// Inline a minimal AccountPool mock to test ban logic
|
| 56 |
+
function makeEntry(overrides: Partial<AccountEntry> = {}): AccountEntry {
|
| 57 |
+
return {
|
| 58 |
+
id: overrides.id ?? "test-1",
|
| 59 |
+
token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0IiwiZXhwIjo5OTk5OTk5OTk5fQ.fake",
|
| 60 |
+
refreshToken: null,
|
| 61 |
+
email: overrides.email ?? "test@example.com",
|
| 62 |
+
accountId: "acc-1",
|
| 63 |
+
userId: "user-1",
|
| 64 |
+
planType: "free",
|
| 65 |
+
proxyApiKey: "key-1",
|
| 66 |
+
status: overrides.status ?? "active",
|
| 67 |
+
usage: {
|
| 68 |
+
request_count: 0,
|
| 69 |
+
input_tokens: 0,
|
| 70 |
+
output_tokens: 0,
|
| 71 |
+
empty_response_count: 0,
|
| 72 |
+
last_used: null,
|
| 73 |
+
rate_limit_until: null,
|
| 74 |
+
},
|
| 75 |
+
addedAt: new Date().toISOString(),
|
| 76 |
+
cachedQuota: null,
|
| 77 |
+
quotaFetchedAt: null,
|
| 78 |
+
};
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
describe("ban detection", () => {
|
| 82 |
+
it("non-CF 403 is detected as ban error", () => {
|
| 83 |
+
// Import the isBanError logic inline (it's a private function, test via behavior)
|
| 84 |
+
const err = new CodexApiError(403, '{"detail": "Your account has been flagged"}');
|
| 85 |
+
expect(err.status).toBe(403);
|
| 86 |
+
// Verify it's NOT a CF error
|
| 87 |
+
const body = err.body.toLowerCase();
|
| 88 |
+
expect(body).not.toContain("cf_chl");
|
| 89 |
+
expect(body).not.toContain("<!doctype");
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
it("CF 403 is NOT a ban error", () => {
|
| 93 |
+
const err = new CodexApiError(403, '<!DOCTYPE html><html><body>cf_chl_managed</body></html>');
|
| 94 |
+
const body = err.body.toLowerCase();
|
| 95 |
+
expect(body).toContain("cf_chl");
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
it("banned accounts are skipped by acquire (status !== active)", () => {
|
| 99 |
+
const entry = makeEntry({ status: "banned" });
|
| 100 |
+
// acquire() filters: a.status === "active"
|
| 101 |
+
expect(entry.status).toBe("banned");
|
| 102 |
+
expect(entry.status === "active").toBe(false);
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
it("AccountStatus type includes banned", () => {
|
| 106 |
+
const status: AccountStatus = "banned";
|
| 107 |
+
expect(status).toBe("banned");
|
| 108 |
+
});
|
| 109 |
+
});
|
|
@@ -325,8 +325,8 @@ export class AccountPool {
|
|
| 325 |
markQuotaExhausted(entryId: string, resetAtUnix: number | null): void {
|
| 326 |
const entry = this.accounts.get(entryId);
|
| 327 |
if (!entry) return;
|
| 328 |
-
// Don't override disabled or
|
| 329 |
-
if (entry.status === "disabled" || entry.status === "expired") return;
|
| 330 |
|
| 331 |
const until = resetAtUnix
|
| 332 |
? new Date(resetAtUnix * 1000).toISOString()
|
|
@@ -506,9 +506,10 @@ export class AccountPool {
|
|
| 506 |
rate_limited: number;
|
| 507 |
refreshing: number;
|
| 508 |
disabled: number;
|
|
|
|
| 509 |
} {
|
| 510 |
const now = new Date();
|
| 511 |
-
let active = 0, expired = 0, rate_limited = 0, refreshing = 0, disabled = 0;
|
| 512 |
for (const entry of this.accounts.values()) {
|
| 513 |
this.refreshStatus(entry, now);
|
| 514 |
switch (entry.status) {
|
|
@@ -517,6 +518,7 @@ export class AccountPool {
|
|
| 517 |
case "rate_limited": rate_limited++; break;
|
| 518 |
case "refreshing": refreshing++; break;
|
| 519 |
case "disabled": disabled++; break;
|
|
|
|
| 520 |
}
|
| 521 |
}
|
| 522 |
return {
|
|
@@ -526,6 +528,7 @@ export class AccountPool {
|
|
| 526 |
rate_limited,
|
| 527 |
refreshing,
|
| 528 |
disabled,
|
|
|
|
| 529 |
};
|
| 530 |
}
|
| 531 |
|
|
|
|
| 325 |
markQuotaExhausted(entryId: string, resetAtUnix: number | null): void {
|
| 326 |
const entry = this.accounts.get(entryId);
|
| 327 |
if (!entry) return;
|
| 328 |
+
// Don't override disabled, expired, or banned states
|
| 329 |
+
if (entry.status === "disabled" || entry.status === "expired" || entry.status === "banned") return;
|
| 330 |
|
| 331 |
const until = resetAtUnix
|
| 332 |
? new Date(resetAtUnix * 1000).toISOString()
|
|
|
|
| 506 |
rate_limited: number;
|
| 507 |
refreshing: number;
|
| 508 |
disabled: number;
|
| 509 |
+
banned: number;
|
| 510 |
} {
|
| 511 |
const now = new Date();
|
| 512 |
+
let active = 0, expired = 0, rate_limited = 0, refreshing = 0, disabled = 0, banned = 0;
|
| 513 |
for (const entry of this.accounts.values()) {
|
| 514 |
this.refreshStatus(entry, now);
|
| 515 |
switch (entry.status) {
|
|
|
|
| 518 |
case "rate_limited": rate_limited++; break;
|
| 519 |
case "refreshing": refreshing++; break;
|
| 520 |
case "disabled": disabled++; break;
|
| 521 |
+
case "banned": banned++; break;
|
| 522 |
}
|
| 523 |
}
|
| 524 |
return {
|
|
|
|
| 528 |
rate_limited,
|
| 529 |
refreshing,
|
| 530 |
disabled,
|
| 531 |
+
banned,
|
| 532 |
};
|
| 533 |
}
|
| 534 |
|
|
@@ -7,7 +7,8 @@ export type AccountStatus =
|
|
| 7 |
| "expired"
|
| 8 |
| "rate_limited"
|
| 9 |
| "refreshing"
|
| 10 |
-
| "disabled"
|
|
|
|
| 11 |
|
| 12 |
export interface AccountUsage {
|
| 13 |
request_count: number;
|
|
|
|
| 7 |
| "expired"
|
| 8 |
| "rate_limited"
|
| 9 |
| "refreshing"
|
| 10 |
+
| "disabled"
|
| 11 |
+
| "banned";
|
| 12 |
|
| 13 |
export interface AccountUsage {
|
| 14 |
request_count: number;
|
|
@@ -9,6 +9,7 @@
|
|
| 9 |
*/
|
| 10 |
|
| 11 |
import { CodexApi } from "../proxy/codex-api.js";
|
|
|
|
| 12 |
import { getConfig } from "../config.js";
|
| 13 |
import { jitter } from "../utils/jitter.js";
|
| 14 |
import { toQuota } from "./quota-utils.js";
|
|
@@ -21,6 +22,22 @@ import type { AccountPool } from "./account-pool.js";
|
|
| 21 |
import type { CookieJar } from "../proxy/cookie-jar.js";
|
| 22 |
import type { ProxyPool } from "../proxy/proxy-pool.js";
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
const INITIAL_DELAY_MS = 3_000; // 3s after startup
|
| 25 |
|
| 26 |
let _refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -35,13 +52,13 @@ async function fetchQuotaForAllAccounts(
|
|
| 35 |
): Promise<void> {
|
| 36 |
if (!pool.isAuthenticated()) return;
|
| 37 |
|
| 38 |
-
const entries = pool.getAllEntries().filter((e) => e.status === "active" || e.status === "rate_limited");
|
| 39 |
if (entries.length === 0) return;
|
| 40 |
|
| 41 |
const config = getConfig();
|
| 42 |
const thresholds = config.quota.warning_thresholds;
|
| 43 |
|
| 44 |
-
console.log(`[QuotaRefresh] Refreshing quota for ${entries.length} active/rate-limited account(s)`);
|
| 45 |
|
| 46 |
const results = await Promise.allSettled(
|
| 47 |
entries.map(async (entry) => {
|
|
@@ -50,6 +67,12 @@ async function fetchQuotaForAllAccounts(
|
|
| 50 |
const usage = await api.getUsage();
|
| 51 |
const quota = toQuota(usage);
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
// Cache quota on the account
|
| 54 |
pool.updateCachedQuota(entry.id, quota);
|
| 55 |
|
|
@@ -98,12 +121,24 @@ async function fetchQuotaForAllAccounts(
|
|
| 98 |
);
|
| 99 |
|
| 100 |
let succeeded = 0;
|
| 101 |
-
for (
|
|
|
|
| 102 |
if (r.status === "fulfilled") {
|
| 103 |
succeeded++;
|
| 104 |
} else {
|
|
|
|
| 105 |
const msg = r.reason instanceof Error ? r.reason.message : String(r.reason);
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
}
|
| 108 |
}
|
| 109 |
|
|
|
|
| 9 |
*/
|
| 10 |
|
| 11 |
import { CodexApi } from "../proxy/codex-api.js";
|
| 12 |
+
import { CodexApiError } from "../proxy/codex-types.js";
|
| 13 |
import { getConfig } from "../config.js";
|
| 14 |
import { jitter } from "../utils/jitter.js";
|
| 15 |
import { toQuota } from "./quota-utils.js";
|
|
|
|
| 22 |
import type { CookieJar } from "../proxy/cookie-jar.js";
|
| 23 |
import type { ProxyPool } from "../proxy/proxy-pool.js";
|
| 24 |
|
| 25 |
+
/** Check if a CodexApiError indicates the account is banned/suspended (non-CF 403). */
|
| 26 |
+
function isBanError(err: unknown): boolean {
|
| 27 |
+
if (!(err instanceof CodexApiError)) return false;
|
| 28 |
+
if (err.status !== 403) return false;
|
| 29 |
+
// Cloudflare challenge pages contain cf_chl or are HTML — not a ban
|
| 30 |
+
const body = err.body.toLowerCase();
|
| 31 |
+
if (body.includes("cf_chl") || body.includes("<!doctype") || body.includes("<html")) return false;
|
| 32 |
+
return true;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
/** Check if a CodexApiError is a 401 token invalidation. */
|
| 36 |
+
function isTokenInvalidError(err: unknown): boolean {
|
| 37 |
+
if (!(err instanceof CodexApiError)) return false;
|
| 38 |
+
return err.status === 401;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
const INITIAL_DELAY_MS = 3_000; // 3s after startup
|
| 42 |
|
| 43 |
let _refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
| 52 |
): Promise<void> {
|
| 53 |
if (!pool.isAuthenticated()) return;
|
| 54 |
|
| 55 |
+
const entries = pool.getAllEntries().filter((e) => e.status === "active" || e.status === "rate_limited" || e.status === "banned");
|
| 56 |
if (entries.length === 0) return;
|
| 57 |
|
| 58 |
const config = getConfig();
|
| 59 |
const thresholds = config.quota.warning_thresholds;
|
| 60 |
|
| 61 |
+
console.log(`[QuotaRefresh] Refreshing quota for ${entries.length} active/rate-limited/banned account(s)`);
|
| 62 |
|
| 63 |
const results = await Promise.allSettled(
|
| 64 |
entries.map(async (entry) => {
|
|
|
|
| 67 |
const usage = await api.getUsage();
|
| 68 |
const quota = toQuota(usage);
|
| 69 |
|
| 70 |
+
// Auto-recover banned accounts that respond successfully
|
| 71 |
+
if (entry.status === "banned") {
|
| 72 |
+
pool.markStatus(entry.id, "active");
|
| 73 |
+
console.log(`[QuotaRefresh] Account ${entry.id} (${entry.email ?? "?"}) unbanned — quota fetch succeeded`);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
// Cache quota on the account
|
| 77 |
pool.updateCachedQuota(entry.id, quota);
|
| 78 |
|
|
|
|
| 121 |
);
|
| 122 |
|
| 123 |
let succeeded = 0;
|
| 124 |
+
for (let i = 0; i < results.length; i++) {
|
| 125 |
+
const r = results[i];
|
| 126 |
if (r.status === "fulfilled") {
|
| 127 |
succeeded++;
|
| 128 |
} else {
|
| 129 |
+
const entry = entries[i];
|
| 130 |
const msg = r.reason instanceof Error ? r.reason.message : String(r.reason);
|
| 131 |
+
|
| 132 |
+
// Detect banned accounts (non-CF 403)
|
| 133 |
+
if (isBanError(r.reason)) {
|
| 134 |
+
pool.markStatus(entry.id, "banned");
|
| 135 |
+
console.warn(`[QuotaRefresh] Account ${entry.id} (${entry.email ?? "?"}) banned — 403 from upstream`);
|
| 136 |
+
} else if (isTokenInvalidError(r.reason)) {
|
| 137 |
+
pool.markStatus(entry.id, "expired");
|
| 138 |
+
console.warn(`[QuotaRefresh] Account ${entry.id} (${entry.email ?? "?"}) token invalidated — 401 from upstream`);
|
| 139 |
+
} else {
|
| 140 |
+
console.warn(`[QuotaRefresh] Account ${entry.id} quota fetch failed: ${msg}`);
|
| 141 |
+
}
|
| 142 |
}
|
| 143 |
}
|
| 144 |
|
|
@@ -80,6 +80,19 @@ function extractRetryAfterSec(body: string): number | undefined {
|
|
| 80 |
return undefined;
|
| 81 |
}
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
/** Check if a CodexApiError indicates the model is not supported on the account's plan. */
|
| 84 |
function isModelNotSupportedError(err: CodexApiError): boolean {
|
| 85 |
// Only 4xx client errors (exclude 429 rate-limit)
|
|
@@ -310,6 +323,52 @@ export async function handleProxyRequest(
|
|
| 310 |
c.status(429);
|
| 311 |
return c.json(fmt.format429(err.message));
|
| 312 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
accountPool.release(activeEntryId);
|
| 314 |
const code = toErrorStatus(err.status);
|
| 315 |
c.status(code);
|
|
|
|
| 80 |
return undefined;
|
| 81 |
}
|
| 82 |
|
| 83 |
+
/** Check if a CodexApiError indicates the account is banned/suspended (non-CF 403). */
|
| 84 |
+
function isBanError(err: CodexApiError): boolean {
|
| 85 |
+
if (err.status !== 403) return false;
|
| 86 |
+
const body = err.body.toLowerCase();
|
| 87 |
+
if (body.includes("cf_chl") || body.includes("<!doctype") || body.includes("<html")) return false;
|
| 88 |
+
return true;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
/** Check if a CodexApiError is a 401 token invalidation (revoked/expired upstream). */
|
| 92 |
+
function isTokenInvalidError(err: CodexApiError): boolean {
|
| 93 |
+
return err.status === 401;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
/** Check if a CodexApiError indicates the model is not supported on the account's plan. */
|
| 97 |
function isModelNotSupportedError(err: CodexApiError): boolean {
|
| 98 |
// Only 4xx client errors (exclude 429 rate-limit)
|
|
|
|
| 323 |
c.status(429);
|
| 324 |
return c.json(fmt.format429(err.message));
|
| 325 |
}
|
| 326 |
+
if (isBanError(err)) {
|
| 327 |
+
accountPool.markStatus(activeEntryId, "banned");
|
| 328 |
+
const failedEmail = accountPool.getEntry(activeEntryId)?.email ?? "?";
|
| 329 |
+
console.warn(
|
| 330 |
+
`[${fmt.tag}] Account ${activeEntryId} (${failedEmail}) | 403 banned, trying different account...`,
|
| 331 |
+
);
|
| 332 |
+
|
| 333 |
+
const retry = accountPool.acquire({
|
| 334 |
+
model: req.codexRequest.model,
|
| 335 |
+
excludeIds: triedEntryIds,
|
| 336 |
+
});
|
| 337 |
+
if (retry) {
|
| 338 |
+
activeEntryId = retry.entryId;
|
| 339 |
+
triedEntryIds.push(retry.entryId);
|
| 340 |
+
const retryProxyUrl = proxyPool?.resolveProxyUrl(retry.entryId);
|
| 341 |
+
codexApi = new CodexApi(retry.token, retry.accountId, cookieJar, retry.entryId, retryProxyUrl);
|
| 342 |
+
console.log(`[${fmt.tag}] 403 ban fallback → account ${retry.entryId}`);
|
| 343 |
+
continue;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
c.status(403);
|
| 347 |
+
return c.json(fmt.formatError(403, err.message));
|
| 348 |
+
}
|
| 349 |
+
if (isTokenInvalidError(err)) {
|
| 350 |
+
accountPool.markStatus(activeEntryId, "expired");
|
| 351 |
+
const failedEmail = accountPool.getEntry(activeEntryId)?.email ?? "?";
|
| 352 |
+
console.warn(
|
| 353 |
+
`[${fmt.tag}] Account ${activeEntryId} (${failedEmail}) | 401 token invalidated, trying different account...`,
|
| 354 |
+
);
|
| 355 |
+
|
| 356 |
+
const retry = accountPool.acquire({
|
| 357 |
+
model: req.codexRequest.model,
|
| 358 |
+
excludeIds: triedEntryIds,
|
| 359 |
+
});
|
| 360 |
+
if (retry) {
|
| 361 |
+
activeEntryId = retry.entryId;
|
| 362 |
+
triedEntryIds.push(retry.entryId);
|
| 363 |
+
const retryProxyUrl = proxyPool?.resolveProxyUrl(retry.entryId);
|
| 364 |
+
codexApi = new CodexApi(retry.token, retry.accountId, cookieJar, retry.entryId, retryProxyUrl);
|
| 365 |
+
console.log(`[${fmt.tag}] 401 fallback → account ${retry.entryId}`);
|
| 366 |
+
continue;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
c.status(401);
|
| 370 |
+
return c.json(fmt.formatError(401, err.message));
|
| 371 |
+
}
|
| 372 |
accountPool.release(activeEntryId);
|
| 373 |
const code = toErrorStatus(err.status);
|
| 374 |
c.status(code);
|
|
@@ -33,6 +33,10 @@ const statusStyles: Record<string, [string, string]> = {
|
|
| 33 |
"bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700/30",
|
| 34 |
"disabled",
|
| 35 |
],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
};
|
| 37 |
|
| 38 |
interface AccountCardProps {
|
|
|
|
| 33 |
"bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700/30",
|
| 34 |
"disabled",
|
| 35 |
],
|
| 36 |
+
banned: [
|
| 37 |
+
"bg-rose-100 text-rose-700 border-rose-300 dark:bg-rose-900/30 dark:text-rose-400 dark:border-rose-800/40",
|
| 38 |
+
"banned",
|
| 39 |
+
],
|
| 40 |
};
|
| 41 |
|
| 42 |
interface AccountCardProps {
|
|
@@ -27,6 +27,10 @@ const statusStyles: Record<string, [string, TranslationKey]> = {
|
|
| 27 |
"bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700/30",
|
| 28 |
"disabled",
|
| 29 |
],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
};
|
| 31 |
|
| 32 |
interface AccountTableProps {
|
|
@@ -159,6 +163,7 @@ export function AccountTable({
|
|
| 159 |
<option value="active">{t("active")}</option>
|
| 160 |
<option value="expired">{t("expired")}</option>
|
| 161 |
<option value="rate_limited">{t("rateLimited")}</option>
|
|
|
|
| 162 |
<option value="disabled">{t("disabled")}</option>
|
| 163 |
</select>
|
| 164 |
</div>
|
|
|
|
| 27 |
"bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700/30",
|
| 28 |
"disabled",
|
| 29 |
],
|
| 30 |
+
banned: [
|
| 31 |
+
"bg-rose-100 text-rose-700 border-rose-300 dark:bg-rose-900/30 dark:text-rose-400 dark:border-rose-800/40",
|
| 32 |
+
"banned",
|
| 33 |
+
],
|
| 34 |
};
|
| 35 |
|
| 36 |
interface AccountTableProps {
|
|
|
|
| 163 |
<option value="active">{t("active")}</option>
|
| 164 |
<option value="expired">{t("expired")}</option>
|
| 165 |
<option value="rate_limited">{t("rateLimited")}</option>
|
| 166 |
+
<option value="banned">{t("banned")}</option>
|
| 167 |
<option value="disabled">{t("disabled")}</option>
|
| 168 |
</select>
|
| 169 |
</div>
|