forked from boos/BoosAPI
Handle codex OAuth usage-limit cooldowns (#316)
* Handle codex oauth usage-limit cooldowns * Generalize short-window 429 limit cooldowns * Fan out short-window cooldowns by credential
This commit is contained in:
@@ -175,6 +175,373 @@ describe('TokenRouter runtime cache', () => {
|
|||||||
expect(thirdCooldownMs).toBeLessThanOrEqual(35_000);
|
expect(thirdCooldownMs).toBeLessThanOrEqual(35_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses codex oauth reset hints for usage-limit cooldowns', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'codex-oauth-site',
|
||||||
|
url: 'https://codex-oauth.example.com',
|
||||||
|
platform: 'codex',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'codex-oauth-user',
|
||||||
|
accessToken: 'codex-access-token',
|
||||||
|
status: 'active',
|
||||||
|
oauthProvider: 'codex',
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
credentialMode: 'session',
|
||||||
|
oauth: {
|
||||||
|
provider: 'codex',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const route = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-5.4',
|
||||||
|
routingStrategy: 'weighted',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const channel = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: account.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const router = new TokenRouter();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await router.recordFailure(channel.id, {
|
||||||
|
status: 429,
|
||||||
|
errorText: JSON.stringify({
|
||||||
|
error: {
|
||||||
|
type: 'usage_limit_reached',
|
||||||
|
resets_in_seconds: 600,
|
||||||
|
message: 'quota exceeded',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
modelName: 'gpt-5.4',
|
||||||
|
});
|
||||||
|
|
||||||
|
const record = await db.select().from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.id, channel.id))
|
||||||
|
.get();
|
||||||
|
const cooldownMs = Date.parse(String(record?.cooldownUntil || '')) - startedAt;
|
||||||
|
|
||||||
|
expect(cooldownMs).toBeGreaterThanOrEqual(9 * 60 * 1000);
|
||||||
|
expect(cooldownMs).toBeLessThanOrEqual(11 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a short cooldown for codex oauth usage-limit failures without reset hints', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'codex-oauth-fallback-site',
|
||||||
|
url: 'https://codex-oauth-fallback.example.com',
|
||||||
|
platform: 'codex',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'codex-oauth-fallback-user',
|
||||||
|
accessToken: 'codex-fallback-access-token',
|
||||||
|
status: 'active',
|
||||||
|
oauthProvider: 'codex',
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
credentialMode: 'session',
|
||||||
|
oauth: {
|
||||||
|
provider: 'codex',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const route = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-5.4',
|
||||||
|
routingStrategy: 'weighted',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const channel = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: account.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const router = new TokenRouter();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await router.recordFailure(channel.id, {
|
||||||
|
status: 429,
|
||||||
|
errorText: JSON.stringify({
|
||||||
|
error: {
|
||||||
|
type: 'usage_limit_reached',
|
||||||
|
message: 'The usage limit has been reached',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
modelName: 'gpt-5.4',
|
||||||
|
});
|
||||||
|
|
||||||
|
const record = await db.select().from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.id, channel.id))
|
||||||
|
.get();
|
||||||
|
const cooldownMs = Date.parse(String(record?.cooldownUntil || '')) - startedAt;
|
||||||
|
|
||||||
|
expect(cooldownMs).toBeGreaterThanOrEqual(4 * 60 * 1000);
|
||||||
|
expect(cooldownMs).toBeLessThanOrEqual(6 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses stored codex oauth reset hints when the latest 429 omits reset timing', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'codex-oauth-stored-reset-site',
|
||||||
|
url: 'https://codex-oauth-stored-reset.example.com',
|
||||||
|
platform: 'codex',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const resetAt = new Date(Date.now() + 8 * 60 * 1000).toISOString();
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'codex-oauth-stored-reset-user',
|
||||||
|
accessToken: 'codex-stored-reset-access-token',
|
||||||
|
status: 'active',
|
||||||
|
oauthProvider: 'codex',
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
credentialMode: 'session',
|
||||||
|
oauth: {
|
||||||
|
provider: 'codex',
|
||||||
|
quota: {
|
||||||
|
status: 'supported',
|
||||||
|
source: 'reverse_engineered',
|
||||||
|
lastLimitResetAt: resetAt,
|
||||||
|
providerMessage: 'current codex oauth signals do not expose stable 5h/7d remaining values',
|
||||||
|
windows: {
|
||||||
|
fiveHour: {
|
||||||
|
supported: false,
|
||||||
|
message: 'official 5h quota window is not exposed by current codex oauth artifacts',
|
||||||
|
},
|
||||||
|
sevenDay: {
|
||||||
|
supported: false,
|
||||||
|
message: 'official 7d quota window is not exposed by current codex oauth artifacts',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const route = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-5.4',
|
||||||
|
routingStrategy: 'weighted',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const channel = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: account.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const router = new TokenRouter();
|
||||||
|
await router.recordFailure(channel.id, {
|
||||||
|
status: 429,
|
||||||
|
errorText: JSON.stringify({
|
||||||
|
error: {
|
||||||
|
type: 'usage_limit_reached',
|
||||||
|
message: 'The usage limit has been reached',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
modelName: 'gpt-5.4',
|
||||||
|
});
|
||||||
|
|
||||||
|
const record = await db.select().from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.id, channel.id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
expect(record?.cooldownUntil).toBe(resetAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats non-oauth 429 limit failures as short-window cooldowns', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'limit-site',
|
||||||
|
url: 'https://limit-site.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'limit-user',
|
||||||
|
accessToken: 'limit-access-token',
|
||||||
|
apiToken: 'limit-api-token',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const route = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-4o-mini',
|
||||||
|
routingStrategy: 'weighted',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const channel = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: account.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const router = new TokenRouter();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await router.recordFailure(channel.id, {
|
||||||
|
status: 429,
|
||||||
|
errorText: JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: 'rate limit exceeded',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
modelName: 'gpt-4o-mini',
|
||||||
|
});
|
||||||
|
|
||||||
|
const record = await db.select().from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.id, channel.id))
|
||||||
|
.get();
|
||||||
|
const cooldownMs = Date.parse(String(record?.cooldownUntil || '')) - startedAt;
|
||||||
|
|
||||||
|
expect(cooldownMs).toBeGreaterThanOrEqual(4 * 60 * 1000);
|
||||||
|
expect(cooldownMs).toBeLessThanOrEqual(6 * 60 * 1000);
|
||||||
|
expect(record?.failCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps generic 429 backoff when the error is not limit-related', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'generic-429-site',
|
||||||
|
url: 'https://generic-429.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'generic-429-user',
|
||||||
|
accessToken: 'generic-429-access-token',
|
||||||
|
apiToken: 'generic-429-api-token',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const route = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-4o-mini',
|
||||||
|
routingStrategy: 'weighted',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const channel = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: account.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const router = new TokenRouter();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await router.recordFailure(channel.id, {
|
||||||
|
status: 429,
|
||||||
|
errorText: JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: 'upstream overloaded',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
modelName: 'gpt-4o-mini',
|
||||||
|
});
|
||||||
|
|
||||||
|
const record = await db.select().from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.id, channel.id))
|
||||||
|
.get();
|
||||||
|
const cooldownMs = Date.parse(String(record?.cooldownUntil || '')) - startedAt;
|
||||||
|
|
||||||
|
expect(cooldownMs).toBeGreaterThanOrEqual(10_000);
|
||||||
|
expect(cooldownMs).toBeLessThanOrEqual(20_000);
|
||||||
|
expect(record?.failCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies short-window cooldown to sibling channels that share the same account-level credential', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'shared-credential-site',
|
||||||
|
url: 'https://shared-credential.example.com',
|
||||||
|
platform: 'codex',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'shared-credential-user',
|
||||||
|
accessToken: 'shared-credential-access-token',
|
||||||
|
status: 'active',
|
||||||
|
oauthProvider: 'codex',
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
credentialMode: 'session',
|
||||||
|
oauth: {
|
||||||
|
provider: 'codex',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const primaryRoute = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-5.4',
|
||||||
|
routingStrategy: 'weighted',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const siblingRoute = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-4o-mini',
|
||||||
|
routingStrategy: 'weighted',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const primaryChannel = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: primaryRoute.id,
|
||||||
|
accountId: account.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const siblingChannel = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: siblingRoute.id,
|
||||||
|
accountId: account.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const router = new TokenRouter();
|
||||||
|
const initialSiblingSelection = await router.selectChannel('gpt-4o-mini');
|
||||||
|
expect(initialSiblingSelection?.channel.id).toBe(siblingChannel.id);
|
||||||
|
|
||||||
|
await router.recordFailure(primaryChannel.id, {
|
||||||
|
status: 429,
|
||||||
|
errorText: JSON.stringify({
|
||||||
|
error: {
|
||||||
|
type: 'usage_limit_reached',
|
||||||
|
message: 'The usage limit has been reached',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
modelName: 'gpt-5.4',
|
||||||
|
});
|
||||||
|
|
||||||
|
const cooledSibling = await db.select().from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.id, siblingChannel.id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
expect(cooledSibling?.cooldownUntil).toBeTruthy();
|
||||||
|
expect(cooledSibling?.failCount).toBe(0);
|
||||||
|
expect(await router.selectChannel('gpt-4o-mini')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('round robins across all available channels regardless of priority', async () => {
|
it('round robins across all available channels regardless of priority', async () => {
|
||||||
const site = await db.insert(schema.sites).values({
|
const site = await db.insert(schema.sites).values({
|
||||||
name: 'round-robin-site',
|
name: 'round-robin-site',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq, inArray } from 'drizzle-orm';
|
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
import { upsertSetting } from '../db/upsertSetting.js';
|
import { upsertSetting } from '../db/upsertSetting.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
|
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
|
||||||
import { isUsableAccountToken } from './accountTokenService.js';
|
import { isUsableAccountToken } from './accountTokenService.js';
|
||||||
import { getOauthInfoFromAccount } from './oauth/oauthAccount.js';
|
import { getOauthInfoFromAccount } from './oauth/oauthAccount.js';
|
||||||
|
import { parseCodexQuotaResetHint } from './oauth/quota.js';
|
||||||
import {
|
import {
|
||||||
isExactTokenRouteModelPattern,
|
isExactTokenRouteModelPattern,
|
||||||
isTokenRouteRegexPattern,
|
isTokenRouteRegexPattern,
|
||||||
@@ -70,6 +71,7 @@ type SiteRuntimeHealthState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FAILURE_BACKOFF_BASE_SEC = 15;
|
const FAILURE_BACKOFF_BASE_SEC = 15;
|
||||||
|
const SHORT_WINDOW_LIMIT_COOLDOWN_MS = 5 * 60 * 1000;
|
||||||
const MIN_EFFECTIVE_UNIT_COST = 1e-6;
|
const MIN_EFFECTIVE_UNIT_COST = 1e-6;
|
||||||
const ROUND_ROBIN_FAILURE_THRESHOLD = 3;
|
const ROUND_ROBIN_FAILURE_THRESHOLD = 3;
|
||||||
const ROUND_ROBIN_COOLDOWN_LEVELS_SEC = [0, 10 * 60, 60 * 60, 24 * 60 * 60] as const;
|
const ROUND_ROBIN_COOLDOWN_LEVELS_SEC = [0, 10 * 60, 60 * 60, 24 * 60 * 60] as const;
|
||||||
@@ -145,6 +147,14 @@ const SITE_TRANSIENT_FAILURE_PATTERNS: RegExp[] = [
|
|||||||
/econnrefused/i,
|
/econnrefused/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const USAGE_LIMIT_RATE_LIMIT_PATTERNS: RegExp[] = [
|
||||||
|
/usage_limit_reached/i,
|
||||||
|
/usage\s+limit\s+has\s+been\s+reached/i,
|
||||||
|
/quota\s+exceeded/i,
|
||||||
|
/rate\s+limit/i,
|
||||||
|
/\blimit\b/i,
|
||||||
|
];
|
||||||
|
|
||||||
type SiteRuntimeHealthPersistencePayload = {
|
type SiteRuntimeHealthPersistencePayload = {
|
||||||
version: 1;
|
version: 1;
|
||||||
savedAtMs: number;
|
savedAtMs: number;
|
||||||
@@ -203,6 +213,12 @@ function matchesAnyPattern(patterns: RegExp[], input?: string | null): boolean {
|
|||||||
return patterns.some((pattern) => pattern.test(text));
|
return patterns.some((pattern) => pattern.test(text));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUsageLimitRateLimitFailure(context: SiteRuntimeFailureContext = {}): boolean {
|
||||||
|
const status = typeof context.status === 'number' ? context.status : 0;
|
||||||
|
if (status !== 429) return false;
|
||||||
|
return matchesAnyPattern(USAGE_LIMIT_RATE_LIMIT_PATTERNS, context.errorText);
|
||||||
|
}
|
||||||
|
|
||||||
function clampNumber(value: number, min: number, max: number): number {
|
function clampNumber(value: number, min: number, max: number): number {
|
||||||
return Math.max(min, Math.min(max, value));
|
return Math.max(min, Math.min(max, value));
|
||||||
}
|
}
|
||||||
@@ -230,6 +246,10 @@ function resolveSiteRuntimeFailurePenalty(context: SiteRuntimeFailureContext = {
|
|||||||
const status = typeof context.status === 'number' ? context.status : 0;
|
const status = typeof context.status === 'number' ? context.status : 0;
|
||||||
const errorText = (context.errorText || '').trim();
|
const errorText = (context.errorText || '').trim();
|
||||||
|
|
||||||
|
if (isUsageLimitRateLimitFailure({ status, errorText })) {
|
||||||
|
return 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
if (status >= 500 || matchesAnyPattern(SITE_TRANSIENT_FAILURE_PATTERNS, errorText)) {
|
if (status >= 500 || matchesAnyPattern(SITE_TRANSIENT_FAILURE_PATTERNS, errorText)) {
|
||||||
return 2.5;
|
return 2.5;
|
||||||
}
|
}
|
||||||
@@ -264,9 +284,63 @@ function resolveSiteRuntimeFailurePenalty(context: SiteRuntimeFailureContext = {
|
|||||||
function isTransientSiteRuntimeFailure(context: SiteRuntimeFailureContext = {}): boolean {
|
function isTransientSiteRuntimeFailure(context: SiteRuntimeFailureContext = {}): boolean {
|
||||||
const status = typeof context.status === 'number' ? context.status : 0;
|
const status = typeof context.status === 'number' ? context.status : 0;
|
||||||
const errorText = (context.errorText || '').trim();
|
const errorText = (context.errorText || '').trim();
|
||||||
|
if (isUsageLimitRateLimitFailure({ status, errorText })) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return status >= 500 || status === 429 || matchesAnyPattern(SITE_TRANSIENT_FAILURE_PATTERNS, errorText);
|
return status >= 500 || status === 429 || matchesAnyPattern(SITE_TRANSIENT_FAILURE_PATTERNS, errorText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveShortWindowLimitCooldown(
|
||||||
|
account: typeof schema.accounts.$inferSelect,
|
||||||
|
context: SiteRuntimeFailureContext = {},
|
||||||
|
nowMs = Date.now(),
|
||||||
|
): string | null {
|
||||||
|
const status = typeof context.status === 'number' ? context.status : 0;
|
||||||
|
const errorText = (context.errorText || '').trim();
|
||||||
|
if (!isUsageLimitRateLimitFailure({ status, errorText })) return null;
|
||||||
|
|
||||||
|
const resetHint = parseCodexQuotaResetHint(status, errorText, nowMs);
|
||||||
|
if (resetHint) {
|
||||||
|
const hintMs = Date.parse(resetHint.resetAt);
|
||||||
|
if (Number.isFinite(hintMs) && hintMs > nowMs) {
|
||||||
|
return new Date(hintMs).toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauth = getOauthInfoFromAccount(account);
|
||||||
|
const storedResetAt = oauth?.quota?.lastLimitResetAt;
|
||||||
|
if (oauth?.provider === 'codex' && storedResetAt) {
|
||||||
|
const storedMs = Date.parse(storedResetAt);
|
||||||
|
if (Number.isFinite(storedMs) && storedMs > nowMs) {
|
||||||
|
return new Date(storedMs).toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(nowMs + SHORT_WINDOW_LIMIT_COOLDOWN_MS).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCredentialScopedChannelIds(
|
||||||
|
channel: typeof schema.routeChannels.$inferSelect,
|
||||||
|
accountId: number,
|
||||||
|
): Promise<number[]> {
|
||||||
|
if (typeof channel.tokenId === 'number' && channel.tokenId > 0) {
|
||||||
|
const rows = await db.select({ id: schema.routeChannels.id })
|
||||||
|
.from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.tokenId, channel.tokenId))
|
||||||
|
.all();
|
||||||
|
return rows.map((row) => row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.select({ id: schema.routeChannels.id })
|
||||||
|
.from(schema.routeChannels)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.routeChannels.accountId, accountId),
|
||||||
|
isNull(schema.routeChannels.tokenId),
|
||||||
|
))
|
||||||
|
.all();
|
||||||
|
return rows.map((row) => row.id);
|
||||||
|
}
|
||||||
|
|
||||||
function getDecayedSiteRuntimePenalty(state: SiteRuntimeHealthState, nowMs: number): number {
|
function getDecayedSiteRuntimePenalty(state: SiteRuntimeHealthState, nowMs: number): number {
|
||||||
if (!Number.isFinite(state.penaltyScore) || state.penaltyScore <= 0) return 0;
|
if (!Number.isFinite(state.penaltyScore) || state.penaltyScore <= 0) return 0;
|
||||||
const elapsedMs = Math.max(0, nowMs - state.lastUpdatedAtMs);
|
const elapsedMs = Math.max(0, nowMs - state.lastUpdatedAtMs);
|
||||||
@@ -1709,16 +1783,24 @@ export class TokenRouter {
|
|||||||
const route = row.token_routes;
|
const route = row.token_routes;
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const nowIso = new Date(nowMs).toISOString();
|
const nowIso = new Date(nowMs).toISOString();
|
||||||
const failCount = (ch.failCount ?? 0) + 1;
|
|
||||||
const normalizedContext: SiteRuntimeFailureContext = typeof context === 'string'
|
const normalizedContext: SiteRuntimeFailureContext = typeof context === 'string'
|
||||||
? { modelName: context }
|
? { modelName: context }
|
||||||
: (context ?? {});
|
: (context ?? {});
|
||||||
|
const shortWindowLimitCooldownUntil = resolveShortWindowLimitCooldown(account, normalizedContext, nowMs);
|
||||||
|
const failCount = shortWindowLimitCooldownUntil ? 0 : ((ch.failCount ?? 0) + 1);
|
||||||
const routeStrategy = resolveRouteStrategy(route);
|
const routeStrategy = resolveRouteStrategy(route);
|
||||||
|
const affectedChannelIds = shortWindowLimitCooldownUntil
|
||||||
|
? await loadCredentialScopedChannelIds(ch, account.id)
|
||||||
|
: [channelId];
|
||||||
let cooldownUntil: string | null = null;
|
let cooldownUntil: string | null = null;
|
||||||
let consecutiveFailCount = Math.max(0, ch.consecutiveFailCount ?? 0) + 1;
|
let consecutiveFailCount = Math.max(0, ch.consecutiveFailCount ?? 0) + 1;
|
||||||
let cooldownLevel = Math.max(0, ch.cooldownLevel ?? 0);
|
let cooldownLevel = Math.max(0, ch.cooldownLevel ?? 0);
|
||||||
|
|
||||||
if (routeStrategy === 'round_robin') {
|
if (shortWindowLimitCooldownUntil) {
|
||||||
|
cooldownUntil = shortWindowLimitCooldownUntil;
|
||||||
|
consecutiveFailCount = 0;
|
||||||
|
cooldownLevel = 0;
|
||||||
|
} else if (routeStrategy === 'round_robin') {
|
||||||
if (consecutiveFailCount >= ROUND_ROBIN_FAILURE_THRESHOLD) {
|
if (consecutiveFailCount >= ROUND_ROBIN_FAILURE_THRESHOLD) {
|
||||||
cooldownLevel = Math.min(cooldownLevel + 1, ROUND_ROBIN_COOLDOWN_LEVELS_SEC.length - 1);
|
cooldownLevel = Math.min(cooldownLevel + 1, ROUND_ROBIN_COOLDOWN_LEVELS_SEC.length - 1);
|
||||||
const cooldownSec = resolveRoundRobinCooldownSec(cooldownLevel);
|
const cooldownSec = resolveRoundRobinCooldownSec(cooldownLevel);
|
||||||
@@ -1738,15 +1820,17 @@ export class TokenRouter {
|
|||||||
consecutiveFailCount,
|
consecutiveFailCount,
|
||||||
cooldownLevel,
|
cooldownLevel,
|
||||||
cooldownUntil,
|
cooldownUntil,
|
||||||
}).where(eq(schema.routeChannels.id, channelId)).run();
|
}).where(inArray(schema.routeChannels.id, affectedChannelIds)).run();
|
||||||
|
|
||||||
patchCachedChannel(channelId, (channel) => {
|
for (const affectedChannelId of affectedChannelIds) {
|
||||||
channel.failCount = failCount;
|
patchCachedChannel(affectedChannelId, (channel) => {
|
||||||
channel.lastFailAt = nowIso;
|
channel.failCount = failCount;
|
||||||
channel.cooldownUntil = cooldownUntil;
|
channel.lastFailAt = nowIso;
|
||||||
channel.consecutiveFailCount = consecutiveFailCount;
|
channel.cooldownUntil = cooldownUntil;
|
||||||
channel.cooldownLevel = cooldownLevel;
|
channel.consecutiveFailCount = consecutiveFailCount;
|
||||||
});
|
channel.cooldownLevel = cooldownLevel;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
recordSiteRuntimeFailure(account.siteId, normalizedContext, nowMs);
|
recordSiteRuntimeFailure(account.siteId, normalizedContext, nowMs);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user