Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@

## [Unreleased]

### Fixed

- 账号轮转现在同时考虑 primary 和 secondary(周额度等)rate limit,防止 secondary 打满时账号仍被选中
- `skip_exhausted` 配置项正式接入 `acquire()` 硬过滤逻辑,此前仅存在于配置/UI 层
- 429 backoff 计算使用 `max(primary.reset_at, secondary.reset_at)`,避免 secondary 满时给过短 backoff
- 被动 quota 采集增加 secondary window 同步(`syncRateLimitWindow`)

### Added

- 加强伪装:Rust native transport(reqwest + rustls),TLS 指纹精确匹配真实 Codex Desktop;补齐 `x-openai-internal-codex-residency`、`x-client-request-id`、`x-codex-turn-state` 请求头
Expand Down
142 changes: 142 additions & 0 deletions src/auth/__tests__/account-pool-quota.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,146 @@ describe("AccountPool quota methods", () => {
expect(acquired).toBeNull();
});
});

describe("acquire respects skip_exhausted config", () => {
it("skips account with primary limit_reached when skip_exhausted=true", () => {
setConfigForTesting(createMockConfig({ quota: { skip_exhausted: true } }));
const id1 = pool.addAccount(createValidJwt({ accountId: "se1" }));
const id2 = pool.addAccount(createValidJwt({ accountId: "se2" }));

pool.updateCachedQuota(id1, makeQuota({
rate_limit: { allowed: true, limit_reached: true, used_percent: 100, reset_at: Math.floor(Date.now() / 1000) + 7200, limit_window_seconds: 3600 },
}));

const acquired = pool.acquire();
expect(acquired).not.toBeNull();
expect(acquired!.entryId).toBe(id2);
pool.release(acquired!.entryId);
});

it("skips account with secondary limit_reached when skip_exhausted=true", () => {
setConfigForTesting(createMockConfig({ quota: { skip_exhausted: true } }));
const id1 = pool.addAccount(createValidJwt({ accountId: "se3" }));
const id2 = pool.addAccount(createValidJwt({ accountId: "se4" }));

pool.updateCachedQuota(id1, makeQuota({
secondary_rate_limit: { limit_reached: true, used_percent: 100, reset_at: Math.floor(Date.now() / 1000) + 86400, limit_window_seconds: 604800 },
}));

const acquired = pool.acquire();
expect(acquired).not.toBeNull();
expect(acquired!.entryId).toBe(id2);
pool.release(acquired!.entryId);
});

it("does not skip exhausted account when skip_exhausted=false", () => {
setConfigForTesting(createMockConfig({ quota: { skip_exhausted: false } }));
const id1 = pool.addAccount(createValidJwt({ accountId: "se5" }));

pool.updateCachedQuota(id1, makeQuota({
rate_limit: { allowed: true, limit_reached: true, used_percent: 100, reset_at: Math.floor(Date.now() / 1000) + 7200, limit_window_seconds: 3600 },
}));

const acquired = pool.acquire();
expect(acquired).not.toBeNull();
expect(acquired!.entryId).toBe(id1);
pool.release(acquired!.entryId);
});

it("returns null when all accounts exhausted and skip_exhausted=true", () => {
setConfigForTesting(createMockConfig({ quota: { skip_exhausted: true } }));
const id1 = pool.addAccount(createValidJwt({ accountId: "se6" }));

pool.updateCachedQuota(id1, makeQuota({
rate_limit: { allowed: true, limit_reached: true, used_percent: 100, reset_at: Math.floor(Date.now() / 1000) + 7200, limit_window_seconds: 3600 },
}));

const acquired = pool.acquire();
expect(acquired).toBeNull();
});
});

describe("refreshStatus respects cachedQuota", () => {
it("transitions to quota_exhausted (not active) when rate_limit expires but cachedQuota still shows limit_reached", () => {
const id = pool.addAccount(createValidJwt({ accountId: "rs1" }));
const pastResetUnix = Math.floor(Date.now() / 1000) - 10; // already expired

// Mark quota exhausted with a reset time in the past
pool.markQuotaExhausted(id, pastResetUnix);

// Set cachedQuota showing limit still reached (with a future reset)
pool.updateCachedQuota(id, makeQuota({
rate_limit: { allowed: true, limit_reached: true, used_percent: 100, reset_at: Math.floor(Date.now() / 1000) + 7200, limit_window_seconds: 3600 },
}));

// getAccounts() triggers refreshStatus — rate_limit_until is in the past,
// but cachedQuota.limit_reached is true, so status should be quota_exhausted
const accounts = pool.getAccounts();
const acct = accounts.find((a) => a.id === id);
expect(acct?.status).toBe("quota_exhausted");
});

it("transitions to active when rate_limit expires and cachedQuota is clear", () => {
const id = pool.addAccount(createValidJwt({ accountId: "rs2" }));
const pastResetUnix = Math.floor(Date.now() / 1000) - 10;

pool.markQuotaExhausted(id, pastResetUnix);
// No cachedQuota set — should revert to active

const accounts = pool.getAccounts();
const acct = accounts.find((a) => a.id === id);
expect(acct?.status).toBe("active");
});

it("transitions to quota_exhausted when secondary limit is reached", () => {
const id = pool.addAccount(createValidJwt({ accountId: "rs3" }));
const pastResetUnix = Math.floor(Date.now() / 1000) - 10;

pool.markQuotaExhausted(id, pastResetUnix);

// Primary OK, but secondary exhausted
pool.updateCachedQuota(id, makeQuota({
secondary_rate_limit: { limit_reached: true, used_percent: 100, reset_at: Math.floor(Date.now() / 1000) + 86400, limit_window_seconds: 604800 },
}));

const accounts = pool.getAccounts();
const acct = accounts.find((a) => a.id === id);
expect(acct?.status).toBe("quota_exhausted");
});

it("reverts quota_exhausted to active when cachedQuota expires", () => {
const id = pool.addAccount(createValidJwt({ accountId: "rs4" }));

// Set up: account is active with an exhausted cachedQuota whose reset is in the past
const entry = pool.getEntry(id)!;
entry.status = "quota_exhausted";
entry.cachedQuota = makeQuota({
rate_limit: { allowed: true, limit_reached: true, used_percent: 100, reset_at: Math.floor(Date.now() / 1000) - 10, limit_window_seconds: 3600 },
});

// getAccounts() triggers refreshStatus — cachedQuota.reset_at is in the past,
// so cachedQuota should be cleared and status reverted to active
const accounts = pool.getAccounts();
const acct = accounts.find((a) => a.id === id);
expect(acct?.status).toBe("active");
expect(pool.getEntry(id)?.cachedQuota).toBeNull();
});

it("keeps quota_exhausted when secondary reset is still in the future", () => {
const id = pool.addAccount(createValidJwt({ accountId: "rs5" }));

const entry = pool.getEntry(id)!;
entry.status = "quota_exhausted";
entry.cachedQuota = makeQuota({
rate_limit: { allowed: true, limit_reached: false, used_percent: 50, reset_at: Math.floor(Date.now() / 1000) - 10, limit_window_seconds: 3600 },
secondary_rate_limit: { limit_reached: true, used_percent: 100, reset_at: Math.floor(Date.now() / 1000) + 86400, limit_window_seconds: 604800 },
});

// Primary reset is in the past but secondary is in the future — maxResetAt picks secondary
const accounts = pool.getAccounts();
const acct = accounts.find((a) => a.id === id);
expect(acct?.status).toBe("quota_exhausted");
expect(pool.getEntry(id)?.cachedQuota).not.toBeNull();
});
});
});
79 changes: 79 additions & 0 deletions src/auth/__tests__/quota-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,82 @@ describe("toQuota", () => {
expect(quota.rate_limit.limit_window_seconds).toBeNull();
});
});

// ── isAnyLimitReached / maxResetAt ──────────────────────────────────

import { isAnyLimitReached, maxResetAt } from "../quota-utils.js";

function makeQuota(overrides?: {
primaryReached?: boolean;
primaryReset?: number | null;
secondaryReached?: boolean;
secondaryReset?: number | null;
}) {
const o = overrides ?? {};
const hasSecondary = "secondaryReached" in o || "secondaryReset" in o;
return {
plan_type: "plus",
rate_limit: {
allowed: true,
limit_reached: o.primaryReached ?? false,
used_percent: o.primaryReached ? 100 : 50,
reset_at: "primaryReset" in o ? o.primaryReset : 1700000000,
limit_window_seconds: 3600,
},
secondary_rate_limit: hasSecondary
? {
limit_reached: o.secondaryReached ?? false,
used_percent: o.secondaryReached ? 100 : 50,
reset_at: "secondaryReset" in o ? o.secondaryReset : 1700500000,
limit_window_seconds: 604800,
}
: null,
code_review_rate_limit: null,
} satisfies ReturnType<typeof toQuota>;
}

describe("isAnyLimitReached", () => {
it("returns false for null/undefined quota", () => {
expect(isAnyLimitReached(null)).toBe(false);
expect(isAnyLimitReached(undefined)).toBe(false);
});

it("returns false when neither limit reached", () => {
expect(isAnyLimitReached(makeQuota())).toBe(false);
});

it("returns true when only primary reached", () => {
expect(isAnyLimitReached(makeQuota({ primaryReached: true }))).toBe(true);
});

it("returns true when only secondary reached", () => {
expect(isAnyLimitReached(makeQuota({ secondaryReached: true }))).toBe(true);
});

it("returns true when both reached", () => {
expect(isAnyLimitReached(makeQuota({ primaryReached: true, secondaryReached: true }))).toBe(true);
});
});

describe("maxResetAt", () => {
it("returns null for null/undefined quota", () => {
expect(maxResetAt(null)).toBeNull();
expect(maxResetAt(undefined)).toBeNull();
});

it("returns primary when no secondary", () => {
expect(maxResetAt(makeQuota({ primaryReset: 1700000000 }))).toBe(1700000000);
});

it("returns secondary when secondary is later", () => {
expect(maxResetAt(makeQuota({ primaryReset: 1700000000, secondaryReset: 1700500000 }))).toBe(1700500000);
});

it("returns primary when primary is later", () => {
expect(maxResetAt(makeQuota({ primaryReset: 1700500000, secondaryReset: 1700000000 }))).toBe(1700500000);
});

it("returns null when both reset_at are null", () => {
expect(maxResetAt(makeQuota({ primaryReset: null, secondaryReset: null }))).toBeNull();
});
});
17 changes: 17 additions & 0 deletions src/auth/__tests__/rotation-strategy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,23 @@ describe("rotation-strategy", () => {
expect(strategy.select([a, b], state).id).toBe("b");
});

it("deprioritizes accounts with secondary_rate_limit.limit_reached", () => {
const secondaryExhausted = makeEntry(
"secExhausted",
{ request_count: 0, window_reset_at: Date.now() + 1 * 86400_000 },
{
rate_limit: { allowed: true, limit_reached: false, used_percent: 30, reset_at: null, limit_window_seconds: null },
secondary_rate_limit: { limit_reached: true, used_percent: 100, reset_at: null, limit_window_seconds: null },
},
);
const healthy = makeEntry(
"healthy",
{ request_count: 5, window_reset_at: Date.now() + 7 * 86400_000 },
{ rate_limit: { allowed: true, limit_reached: false, used_percent: 30, reset_at: null, limit_window_seconds: null } },
);
expect(strategy.select([secondaryExhausted, healthy], state).id).toBe("healthy");
});

it("treats accounts without cached quota as non-exhausted", () => {
const noQuota = makeEntry("noQuota", { request_count: 2, window_reset_at: Date.now() + 7 * 86400_000 });
const exhausted = makeEntry(
Expand Down
8 changes: 6 additions & 2 deletions src/auth/account-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import { getConfig } from "../config.js";
import { getModelPlanTypes, isPlanFetched } from "../models/model-store.js";
import { isAnyLimitReached } from "./quota-utils.js";
import { getRotationStrategy } from "./rotation-strategy.js";
import type { RotationStrategy, RotationState, RotationStrategyName } from "./rotation-strategy.js";
import type { AccountRegistry } from "./account-registry.js";
Expand Down Expand Up @@ -70,14 +71,17 @@ export class AccountLifecycle {
}
}

const maxConcurrent = getConfig().auth.max_concurrent_per_account ?? 3;
const config = getConfig();
const maxConcurrent = config.auth.max_concurrent_per_account ?? 3;
const skipExhausted = config.quota?.skip_exhausted ?? true;
const excludeSet = options?.excludeIds?.length ? new Set(options.excludeIds) : null;

const available = entries.filter(
(a) =>
a.status === "active" &&
this.slotCount(a.id) < maxConcurrent &&
(!excludeSet || !excludeSet.has(a.id)),
(!excludeSet || !excludeSet.has(a.id)) &&
(!skipExhausted || !isAnyLimitReached(a.cachedQuota)),
);

if (available.length === 0) return null;
Expand Down
13 changes: 10 additions & 3 deletions src/auth/account-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { randomBytes } from "crypto";
import { getConfig } from "../config.js";
import { jitter } from "../utils/jitter.js";
import { isAnyLimitReached, maxResetAt } from "./quota-utils.js";
import {
decodeJwtPayload,
extractChatGptAccountId,
Expand Down Expand Up @@ -374,8 +375,10 @@ export class AccountRegistry {
refreshStatus(entry: AccountEntry, now: Date): void {
if (entry.status === "rate_limited" && entry.usage.rate_limit_until) {
if (now >= new Date(entry.usage.rate_limit_until)) {
entry.status = "active";
entry.usage.rate_limit_until = null;
// If cached quota still shows limit reached, mark as quota_exhausted
// instead of blindly reverting to active
entry.status = isAnyLimitReached(entry.cachedQuota) ? "quota_exhausted" : "active";
}
}

Expand All @@ -402,11 +405,15 @@ export class AccountRegistry {
this.schedulePersist();
}

// Clear stale cached quota when its own reset time has passed
const quotaReset = entry.cachedQuota?.rate_limit?.reset_at;
// Clear stale cached quota when both primary and secondary reset times have passed
const quotaReset = maxResetAt(entry.cachedQuota);
if (quotaReset != null && nowSec >= quotaReset) {
entry.cachedQuota = null;
entry.quotaFetchedAt = null;
// Quota data expired — if account was stuck as quota_exhausted, revert to active
if (entry.status === "quota_exhausted") {
entry.status = "active";
}
this.schedulePersist();
}
}
Expand Down
16 changes: 16 additions & 0 deletions src/auth/quota-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@
import type { CodexQuota } from "./types.js";
import type { CodexUsageResponse } from "../proxy/codex-api.js";

/** True if either primary or secondary rate limit has been reached. */
export function isAnyLimitReached(quota: CodexQuota | null | undefined): boolean {
if (!quota) return false;
return quota.rate_limit.limit_reached
|| (quota.secondary_rate_limit?.limit_reached ?? false);
}

/** Latest reset_at (Unix seconds) across primary and secondary windows. Returns null if neither has one. */
export function maxResetAt(quota: CodexQuota | null | undefined): number | null {
if (!quota) return null;
const primary = quota.rate_limit.reset_at;
const secondary = quota.secondary_rate_limit?.reset_at ?? null;
if (primary == null && secondary == null) return null;
return Math.max(primary ?? 0, secondary ?? 0);
}

export function toQuota(usage: CodexUsageResponse): CodexQuota {
const sw = usage.rate_limit.secondary_window;
return {
Expand Down
7 changes: 4 additions & 3 deletions src/auth/rotation-strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import type { AccountEntry } from "./types.js";
import { isAnyLimitReached } from "./quota-utils.js";

export type RotationStrategyName = "least_used" | "round_robin" | "sticky";

Expand All @@ -18,9 +19,9 @@ export interface RotationStrategy {
const leastUsed: RotationStrategy = {
select(candidates) {
const sorted = [...candidates].sort((a, b) => {
// Primary: deprioritize quota-exhausted accounts
const aExhausted = a.cachedQuota?.rate_limit?.limit_reached ? 1 : 0;
const bExhausted = b.cachedQuota?.rate_limit?.limit_reached ? 1 : 0;
// Primary: deprioritize quota-exhausted accounts (primary OR secondary)
const aExhausted = isAnyLimitReached(a.cachedQuota) ? 1 : 0;
const bExhausted = isAnyLimitReached(b.cachedQuota) ? 1 : 0;
if (aExhausted !== bExhausted) return aExhausted - bExhausted;
// Secondary: prefer account whose quota resets soonest (use it before it resets)
const aReset = a.usage.window_reset_at ?? Infinity;
Expand Down
5 changes: 3 additions & 2 deletions src/routes/shared/proxy-error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "../../proxy/error-classification.js";
import type { CodexApiError } from "../../proxy/codex-types.js";
import type { StatusCode } from "hono/utils/http-status";
import { isAnyLimitReached, maxResetAt } from "../../auth/quota-utils.js";

/** Clamp an HTTP status to a valid error StatusCode, defaulting to 502 for non-error codes. */
export function toErrorStatus(status: number): StatusCode {
Expand Down Expand Up @@ -83,8 +84,8 @@ export function handleCodexApiError(
// instead of the short default backoff (prevents exhausted accounts from
// cycling back to "active" after 60s only to get 429'd again)
const entry = pool.getEntry(entryId);
const cachedReset = entry?.cachedQuota?.rate_limit?.reset_at;
const effectiveRetry = (entry?.cachedQuota?.rate_limit?.limit_reached && cachedReset)
const cachedReset = maxResetAt(entry?.cachedQuota ?? null);
const effectiveRetry = (isAnyLimitReached(entry?.cachedQuota ?? null) && cachedReset != null)
? Math.max(retryAfterSec ?? 0, cachedReset - Math.floor(Date.now() / 1000))
: retryAfterSec;

Expand Down
Loading
Loading