Skip to content
Merged
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
13 changes: 10 additions & 3 deletions src/auth/account-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Does NOT own acquire locks (that's AccountLifecycle's concern).
*/

import { randomBytes } from "crypto";
import { randomBytes, timingSafeEqual } from "crypto";
import { readFileSync } from "fs";
import { resolve } from "path";
import { getConfig } from "../config.js";
Expand All @@ -25,6 +25,13 @@ import type {
} from "./types.js";
import { hasReachedCachedQuota } from "./quota-skip.js";

function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}

type ResettableQuotaWindow = {
used_percent: number | null;
reset_at: number | null;
Expand Down Expand Up @@ -349,9 +356,9 @@ export class AccountRegistry {

validateProxyApiKey(key: string): boolean {
const configKey = getConfig().server.proxy_api_key;
if (configKey && key === configKey) return true;
if (configKey && safeEqual(key, configKey)) return true;
for (const entry of this.accounts.values()) {
if (entry.proxyApiKey === key) return true;
if (entry.proxyApiKey && safeEqual(key, entry.proxyApiKey)) return true;
}
return false;
}
Expand Down
11 changes: 8 additions & 3 deletions src/auth/refresh-scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export class RefreshScheduler {
private _queue: Array<() => void> = [];
/** Accounts currently being refreshed (prevents concurrent refresh of same account). */
private _inFlight: Set<string> = new Set();
/** Set after destroy() to prevent queued waiters from proceeding. */
private _destroyed = false;

/** Check if an account is currently being refreshed. Used by health-check to avoid racing. */
isRefreshing(entryId: string): boolean {
Expand Down Expand Up @@ -150,12 +152,13 @@ export class RefreshScheduler {

/** Cancel all timers and drain the semaphore queue. */
destroy(): void {
this._destroyed = true;
for (const timer of this.timers.values()) {
clearTimeout(timer);
}
this.timers.clear();
// Unblock any waiters so their promises resolve (doRefresh will
// bail out via getEntry returning null or scheduler being dead).
// Unblock any waiters so their promises resolve — acquireSlot
// checks _destroyed after wakeup and bails.
for (const resolve of this._queue) resolve();
this._queue.length = 0;
this._running = 0;
Expand All @@ -164,14 +167,16 @@ export class RefreshScheduler {

// ── Internal ────────────────────────────────────────────────────

/** Acquire a semaphore slot, waiting if at capacity. */
/** Acquire a semaphore slot, waiting if at capacity. Throws if destroyed while waiting. */
private async acquireSlot(): Promise<void> {
if (this._destroyed) throw new Error("scheduler destroyed");
const limit = getConfig().auth.refresh_concurrency;
if (this._running < limit) {
this._running++;
return;
}
await new Promise<void>((resolve) => this._queue.push(resolve));
if (this._destroyed) throw new Error("scheduler destroyed");
this._running++;
}

Expand Down
8 changes: 4 additions & 4 deletions src/routes/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ function checkProxyApiKey(c: Context, accountPool: AccountPool) {
if (!config.server.proxy_api_key) return null;

const authHeader = c.req.header("Authorization");
const providedKey = authHeader?.replace("Bearer ", "");
const providedKey = authHeader?.replace(/^bearer\s+/i, "");
if (!providedKey || !accountPool.validateProxyApiKey(providedKey)) {
c.status(401);
return c.json({
Expand Down Expand Up @@ -192,14 +192,14 @@ export function createChatRoutes(
});
}

const authError = checkProxyApiKey(c, accountPool);
if (authError) return authError;

const summary = accountPool.getPoolSummary();
if (summary.active === 0) {
return handleProxyRequest({ c, accountPool, cookieJar, req: proxyReq, fmt, proxyPool });
}

const authError = checkProxyApiKey(c, accountPool);
if (authError) return authError;

return handleProxyRequest({ c, accountPool, cookieJar, req: proxyReq, fmt, proxyPool });
});

Expand Down
9 changes: 7 additions & 2 deletions src/routes/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ function checkAuth(
const config = getConfig();
if (config.server.proxy_api_key) {
const authHeader = c.req.header("Authorization");
const providedKey = authHeader?.replace("Bearer ", "");
const providedKey = authHeader?.replace(/^bearer\s+/i, "");
if (!providedKey || !accountPool.validateProxyApiKey(providedKey)) {
c.status(401);
return c.json({
Expand Down Expand Up @@ -689,7 +689,8 @@ async function handleCompact(

await staggerIfNeeded(acquired.prevSlotMs);

for (;;) {
const MAX_COMPACT_RETRIES = 8;
for (let attempt = 0; attempt < MAX_COMPACT_RETRIES; attempt++) {
try {
const result = await withRetry(
() => codexApi.createCompactResponse(compactRequest, c.req.raw.signal),
Expand Down Expand Up @@ -743,6 +744,10 @@ async function handleCompact(
continue;
}
}

releaseAccount(accountPool, entryId, compactImageFailedUsage, released);
c.status(502);
return c.json(formatResponsesError(502, "Compact failed after maximum retry attempts"));
}

// ── Route ──────────────────────────────────────────────────────────
Expand Down
Loading