icebear icebear0828 Claude Opus 4.6 commited on
Commit
90c89b6
·
unverified ·
1 Parent(s): e9b5796

feat: model-aware multi-plan account routing (#57)

Browse files

* feat: model-aware multi-plan account routing

Different ChatGPT plans have different model access (e.g. Team has
gpt-5.4, Free has gpt-oss-*). This adds plan→model mapping discovery
and intelligent request routing to prevent model-not-supported errors.

- model-store: track which plans can access each model via planMap
- model-fetcher: query backend per distinct plan type in parallel
- account-pool: model-aware acquire() prefers matching planType accounts
- proxy-handler: auto-retry with different account on model-not-supported 4xx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: patch model-aware routing bugs from PR review

- Fix double release when model retry has no available account
- Tighten isModelNotSupportedError: add 4xx status guard, drop broad "invalid" keyword
- Reuse selectByStrategy() in getDistinctPlanAccounts() instead of duplicated sort
- Remove redundant triedEntryIds push

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

src/auth/account-pool.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
  extractUserProfile,
22
  isTokenExpired,
23
  } from "./jwt-utils.js";
 
24
  import type {
25
  AccountEntry,
26
  AccountInfo,
@@ -65,9 +66,11 @@ export class AccountPool {
65
  /**
66
  * Acquire the best available account for a request.
67
  * Returns null if no accounts are available.
 
 
 
68
  */
69
- acquire(): AcquiredAccount | null {
70
- const config = getConfig();
71
  const now = new Date();
72
  const nowMs = now.getTime();
73
 
@@ -84,30 +87,30 @@ export class AccountPool {
84
  }
85
  }
86
 
 
 
87
  // Filter available accounts
88
  const available = [...this.accounts.values()].filter(
89
- (a) => a.status === "active" && !this.acquireLocks.has(a.id),
90
  );
91
 
92
  if (available.length === 0) return null;
93
 
94
- let selected: AccountEntry;
95
- if (config.auth.rotation_strategy === "round_robin") {
96
- this.roundRobinIndex = this.roundRobinIndex % available.length;
97
- selected = available[this.roundRobinIndex];
98
- this.roundRobinIndex++;
99
- } else {
100
- // least_used: sort by request_count asc, then by last_used asc (LRU)
101
- available.sort((a, b) => {
102
- const diff = a.usage.request_count - b.usage.request_count;
103
- if (diff !== 0) return diff;
104
- const aTime = a.usage.last_used ? new Date(a.usage.last_used).getTime() : 0;
105
- const bTime = b.usage.last_used ? new Date(b.usage.last_used).getTime() : 0;
106
- return aTime - bTime;
107
- });
108
- selected = available[0];
109
  }
110
 
 
111
  this.acquireLocks.set(selected.id, Date.now());
112
  return {
113
  entryId: selected.id,
@@ -116,6 +119,69 @@ export class AccountPool {
116
  };
117
  }
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  /**
120
  * Release an account after a request completes.
121
  */
 
21
  extractUserProfile,
22
  isTokenExpired,
23
  } from "./jwt-utils.js";
24
+ import { getModelPlanTypes } from "../models/model-store.js";
25
  import type {
26
  AccountEntry,
27
  AccountInfo,
 
66
  /**
67
  * Acquire the best available account for a request.
68
  * Returns null if no accounts are available.
69
+ *
70
+ * @param options.model - Prefer accounts whose planType matches this model's known plans
71
+ * @param options.excludeIds - Entry IDs to exclude (e.g. already tried)
72
  */
73
+ acquire(options?: { model?: string; excludeIds?: string[] }): AcquiredAccount | null {
 
74
  const now = new Date();
75
  const nowMs = now.getTime();
76
 
 
87
  }
88
  }
89
 
90
+ const excludeSet = new Set(options?.excludeIds ?? []);
91
+
92
  // Filter available accounts
93
  const available = [...this.accounts.values()].filter(
94
+ (a) => a.status === "active" && !this.acquireLocks.has(a.id) && !excludeSet.has(a.id),
95
  );
96
 
97
  if (available.length === 0) return null;
98
 
99
+ // Model-aware selection: prefer accounts whose planType matches the model's known plans
100
+ let candidates = available;
101
+ if (options?.model) {
102
+ const preferredPlans = getModelPlanTypes(options.model);
103
+ if (preferredPlans.length > 0) {
104
+ const planSet = new Set(preferredPlans);
105
+ const matched = available.filter((a) => a.planType && planSet.has(a.planType));
106
+ if (matched.length > 0) {
107
+ candidates = matched;
108
+ }
109
+ // else: fallback to all available (graceful degradation)
110
+ }
 
 
 
111
  }
112
 
113
+ const selected = this.selectByStrategy(candidates);
114
  this.acquireLocks.set(selected.id, Date.now());
115
  return {
116
  entryId: selected.id,
 
119
  };
120
  }
121
 
122
+ /**
123
+ * Select an account from candidates using the configured rotation strategy.
124
+ */
125
+ private selectByStrategy(candidates: AccountEntry[]): AccountEntry {
126
+ const config = getConfig();
127
+ if (config.auth.rotation_strategy === "round_robin") {
128
+ this.roundRobinIndex = this.roundRobinIndex % candidates.length;
129
+ const selected = candidates[this.roundRobinIndex];
130
+ this.roundRobinIndex++;
131
+ return selected;
132
+ }
133
+ // least_used: sort by request_count asc, then by last_used asc (LRU)
134
+ candidates.sort((a, b) => {
135
+ const diff = a.usage.request_count - b.usage.request_count;
136
+ if (diff !== 0) return diff;
137
+ const aTime = a.usage.last_used ? new Date(a.usage.last_used).getTime() : 0;
138
+ const bTime = b.usage.last_used ? new Date(b.usage.last_used).getTime() : 0;
139
+ return aTime - bTime;
140
+ });
141
+ return candidates[0];
142
+ }
143
+
144
+ /**
145
+ * Get one account per distinct planType for model discovery.
146
+ * Each returned account is locked (caller must release).
147
+ */
148
+ getDistinctPlanAccounts(): Array<{ planType: string; entryId: string; token: string; accountId: string | null }> {
149
+ const now = new Date();
150
+ for (const entry of this.accounts.values()) {
151
+ this.refreshStatus(entry, now);
152
+ }
153
+
154
+ const available = [...this.accounts.values()].filter(
155
+ (a) => a.status === "active" && !this.acquireLocks.has(a.id) && a.planType,
156
+ );
157
+
158
+ // Group by planType, pick least-used from each group
159
+ const byPlan = new Map<string, AccountEntry[]>();
160
+ for (const a of available) {
161
+ const plan = a.planType!;
162
+ let group = byPlan.get(plan);
163
+ if (!group) {
164
+ group = [];
165
+ byPlan.set(plan, group);
166
+ }
167
+ group.push(a);
168
+ }
169
+
170
+ const result: Array<{ planType: string; entryId: string; token: string; accountId: string | null }> = [];
171
+ for (const [plan, group] of byPlan) {
172
+ const selected = this.selectByStrategy(group);
173
+ this.acquireLocks.set(selected.id, Date.now());
174
+ result.push({
175
+ planType: plan,
176
+ entryId: selected.id,
177
+ token: selected.token,
178
+ accountId: selected.accountId,
179
+ });
180
+ }
181
+
182
+ return result;
183
+ }
184
+
185
  /**
186
  * Release an account after a request completes.
187
  */
src/models/model-fetcher.ts CHANGED
@@ -7,7 +7,7 @@
7
  */
8
 
9
  import { CodexApi } from "../proxy/codex-api.js";
10
- import { applyBackendModels, type BackendModelEntry } from "./model-store.js";
11
  import type { AccountPool } from "../auth/account-pool.js";
12
  import type { CookieJar } from "../proxy/cookie-jar.js";
13
  import type { ProxyPool } from "../proxy/proxy-pool.js";
@@ -22,7 +22,8 @@ let _cookieJar: CookieJar | null = null;
22
  let _proxyPool: ProxyPool | null = null;
23
 
24
  /**
25
- * Fetch models from the Codex backend using an available account.
 
26
  */
27
  async function fetchModelsFromBackend(
28
  accountPool: AccountPool,
@@ -31,34 +32,37 @@ async function fetchModelsFromBackend(
31
  ): Promise<void> {
32
  if (!accountPool.isAuthenticated()) return; // silently skip when no accounts
33
 
34
- const acquired = accountPool.acquire();
35
- if (!acquired) {
36
- console.warn("[ModelFetcher] No available account — skipping model fetch");
37
  return;
38
  }
39
 
40
- try {
41
- const proxyUrl = proxyPool?.resolveProxyUrl(acquired.entryId);
42
- const api = new CodexApi(
43
- acquired.token,
44
- acquired.accountId,
45
- cookieJar,
46
- acquired.entryId,
47
- proxyUrl,
48
- );
49
 
50
- const models = await api.getModels();
51
- if (models && models.length > 0) {
52
- applyBackendModels(models);
53
- console.log(`[ModelFetcher] Fetched ${models.length} models from backend`);
54
- } else {
55
- console.log("[ModelFetcher] Backend returned empty model list — keeping static models");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  }
57
- } catch (err) {
58
- const msg = err instanceof Error ? err.message : String(err);
59
- console.warn(`[ModelFetcher] Backend fetch failed: ${msg}`);
60
- } finally {
61
- accountPool.release(acquired.entryId);
62
  }
63
  }
64
 
 
7
  */
8
 
9
  import { CodexApi } from "../proxy/codex-api.js";
10
+ import { applyBackendModelsForPlan } from "./model-store.js";
11
  import type { AccountPool } from "../auth/account-pool.js";
12
  import type { CookieJar } from "../proxy/cookie-jar.js";
13
  import type { ProxyPool } from "../proxy/proxy-pool.js";
 
22
  let _proxyPool: ProxyPool | null = null;
23
 
24
  /**
25
+ * Fetch models from the Codex backend, one query per distinct plan type.
26
+ * This discovers plan-specific model availability (e.g. Team has gpt-5.4, Free has gpt-oss-*).
27
  */
28
  async function fetchModelsFromBackend(
29
  accountPool: AccountPool,
 
32
  ): Promise<void> {
33
  if (!accountPool.isAuthenticated()) return; // silently skip when no accounts
34
 
35
+ const planAccounts = accountPool.getDistinctPlanAccounts();
36
+ if (planAccounts.length === 0) {
37
+ console.warn("[ModelFetcher] No available accounts — skipping model fetch");
38
  return;
39
  }
40
 
41
+ console.log(`[ModelFetcher] Fetching models for ${planAccounts.length} plan(s): ${planAccounts.map((p) => p.planType).join(", ")}`);
 
 
 
 
 
 
 
 
42
 
43
+ const results = await Promise.allSettled(
44
+ planAccounts.map(async (pa) => {
45
+ try {
46
+ const proxyUrl = proxyPool?.resolveProxyUrl(pa.entryId);
47
+ const api = new CodexApi(pa.token, pa.accountId, cookieJar, pa.entryId, proxyUrl);
48
+ const models = await api.getModels();
49
+ if (models && models.length > 0) {
50
+ applyBackendModelsForPlan(pa.planType, models);
51
+ console.log(`[ModelFetcher] Plan "${pa.planType}": ${models.length} models`);
52
+ } else {
53
+ console.log(`[ModelFetcher] Plan "${pa.planType}": empty model list — keeping existing`);
54
+ }
55
+ } finally {
56
+ accountPool.release(pa.entryId);
57
+ }
58
+ }),
59
+ );
60
+
61
+ for (const r of results) {
62
+ if (r.status === "rejected") {
63
+ const msg = r.reason instanceof Error ? r.reason.message : String(r.reason);
64
+ console.warn(`[ModelFetcher] Plan fetch failed: ${msg}`);
65
  }
 
 
 
 
 
66
  }
67
  }
68
 
src/models/model-store.ts CHANGED
@@ -39,6 +39,8 @@ interface ModelsConfig {
39
  let _catalog: CodexModelInfo[] = [];
40
  let _aliases: Record<string, string> = {};
41
  let _lastFetchTime: string | null = null;
 
 
42
 
43
  // ── Static loading ─────────────────────────────────────────────────
44
 
@@ -53,6 +55,7 @@ export function loadStaticModels(configDir?: string): void {
53
 
54
  _catalog = (raw.models ?? []).map((m) => ({ ...m, source: "static" as const }));
55
  _aliases = raw.aliases ?? {};
 
56
  console.log(`[ModelStore] Loaded ${_catalog.length} static models, ${Object.keys(_aliases).length} aliases`);
57
  }
58
 
@@ -206,6 +209,45 @@ export function applyBackendModels(backendModels: BackendModelEntry[]): void {
206
  );
207
  }
208
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  // ── Model name suffix parsing ───────────────────────────────────────
210
 
211
  export interface ParsedModelName {
@@ -314,8 +356,13 @@ export function getModelStoreDebug(): {
314
  aliasCount: number;
315
  lastFetchTime: string | null;
316
  models: Array<{ id: string; source: string }>;
 
317
  } {
318
  const backendCount = _catalog.filter((m) => m.source === "backend").length;
 
 
 
 
319
  return {
320
  totalModels: _catalog.length,
321
  backendModels: backendCount,
@@ -323,5 +370,6 @@ export function getModelStoreDebug(): {
323
  aliasCount: Object.keys(_aliases).length,
324
  lastFetchTime: _lastFetchTime,
325
  models: _catalog.map((m) => ({ id: m.id, source: m.source ?? "static" })),
 
326
  };
327
  }
 
39
  let _catalog: CodexModelInfo[] = [];
40
  let _aliases: Record<string, string> = {};
41
  let _lastFetchTime: string | null = null;
42
+ /** modelId → Set<planType> — tracks which plans can access each model */
43
+ let _modelPlanMap: Map<string, Set<string>> = new Map();
44
 
45
  // ── Static loading ─────────────────────────────────────────────────
46
 
 
55
 
56
  _catalog = (raw.models ?? []).map((m) => ({ ...m, source: "static" as const }));
57
  _aliases = raw.aliases ?? {};
58
+ _modelPlanMap = new Map(); // Reset plan map on reload
59
  console.log(`[ModelStore] Loaded ${_catalog.length} static models, ${Object.keys(_aliases).length} aliases`);
60
  }
61
 
 
209
  );
210
  }
211
 
212
+ /**
213
+ * Merge backend models for a specific plan type.
214
+ * Clears old records for this planType, applies merge, then records plan→model mappings.
215
+ */
216
+ export function applyBackendModelsForPlan(planType: string, backendModels: BackendModelEntry[]): void {
217
+ // Clear old planType records
218
+ for (const [modelId, plans] of _modelPlanMap) {
219
+ plans.delete(planType);
220
+ if (plans.size === 0) _modelPlanMap.delete(modelId);
221
+ }
222
+
223
+ // Merge into catalog (existing logic)
224
+ applyBackendModels(backendModels);
225
+
226
+ // Record which models this plan can access (only admitted models)
227
+ const staticIds = new Set(_catalog.map((m) => m.id));
228
+ for (const raw of backendModels) {
229
+ const id = raw.slug ?? raw.id ?? raw.name ?? "";
230
+ if (staticIds.has(id) || isCodexCompatibleId(id)) {
231
+ let plans = _modelPlanMap.get(id);
232
+ if (!plans) {
233
+ plans = new Set();
234
+ _modelPlanMap.set(id, plans);
235
+ }
236
+ plans.add(planType);
237
+ }
238
+ }
239
+
240
+ console.log(`[ModelStore] Plan "${planType}" has ${backendModels.length} backend models, ${_modelPlanMap.size} models tracked across plans`);
241
+ }
242
+
243
+ /**
244
+ * Get which plan types are known to support a given model.
245
+ * Empty array means unknown (static-only or not yet fetched).
246
+ */
247
+ export function getModelPlanTypes(modelId: string): string[] {
248
+ return [...(_modelPlanMap.get(modelId) ?? [])];
249
+ }
250
+
251
  // ── Model name suffix parsing ───────────────────────────────────────
252
 
253
  export interface ParsedModelName {
 
356
  aliasCount: number;
357
  lastFetchTime: string | null;
358
  models: Array<{ id: string; source: string }>;
359
+ planMap: Record<string, string[]>;
360
  } {
361
  const backendCount = _catalog.filter((m) => m.source === "backend").length;
362
+ const planMap: Record<string, string[]> = {};
363
+ for (const [modelId, plans] of _modelPlanMap) {
364
+ planMap[modelId] = [...plans];
365
+ }
366
  return {
367
  totalModels: _catalog.length,
368
  backendModels: backendCount,
 
370
  aliasCount: Object.keys(_aliases).length,
371
  lastFetchTime: _lastFetchTime,
372
  models: _catalog.map((m) => ({ id: m.id, source: m.source ?? "static" })),
373
+ planMap,
374
  };
375
  }
src/routes/shared/proxy-handler.ts CHANGED
@@ -54,6 +54,17 @@ export interface FormatAdapter {
54
  *
55
  * Handles: acquire, session lookup, retry, stream/collect, release, error formatting.
56
  */
 
 
 
 
 
 
 
 
 
 
 
57
  export async function handleProxyRequest(
58
  c: Context,
59
  accountPool: AccountPool,
@@ -62,8 +73,8 @@ export async function handleProxyRequest(
62
  fmt: FormatAdapter,
63
  proxyPool?: ProxyPool,
64
  ): Promise<Response> {
65
- // 1. Acquire account
66
- const acquired = accountPool.acquire();
67
  if (!acquired) {
68
  c.status(fmt.noAccountStatus);
69
  return c.json(fmt.formatNoAccount());
@@ -71,9 +82,12 @@ export async function handleProxyRequest(
71
 
72
  const { entryId, token, accountId } = acquired;
73
  const proxyUrl = proxyPool?.resolveProxyUrl(entryId);
74
- const codexApi = new CodexApi(token, accountId, cookieJar, entryId, proxyUrl);
75
  // Tracks which account the outer catch should release (updated by retry loop)
76
  let activeEntryId = entryId;
 
 
 
77
 
78
  console.log(
79
  `[${fmt.tag}] Account ${entryId} | Codex request:`,
@@ -86,138 +100,173 @@ export async function handleProxyRequest(
86
  const abortController = new AbortController();
87
  c.req.raw.signal.addEventListener("abort", () => abortController.abort(), { once: true });
88
 
89
- try {
90
- // 3. Retry + send to Codex
91
- const rawResponse = await withRetry(
92
- () => codexApi.createResponse(req.codexRequest, abortController.signal),
93
- { tag: fmt.tag },
94
- );
95
-
96
- // 4. Stream or collect
97
- if (req.isStreaming) {
98
- c.header("Content-Type", "text/event-stream");
99
- c.header("Cache-Control", "no-cache");
100
- c.header("Connection", "keep-alive");
101
-
102
- return stream(c, async (s) => {
103
- s.onAbort(() => abortController.abort());
104
- try {
105
- for await (const chunk of fmt.streamTranslator(
106
- codexApi,
107
- rawResponse,
108
- req.model,
109
- (u) => {
110
- usageInfo = u;
111
- },
112
- () => {},
113
- )) {
114
- await s.write(chunk);
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  }
116
- } catch (err) {
117
- // P2-8: Send error SSE event to client before closing
 
 
 
 
 
 
 
118
  try {
119
- const errMsg = err instanceof Error ? err.message : "Stream interrupted";
120
- await s.write(`data: ${JSON.stringify({ error: { message: errMsg, type: "stream_error" } })}\n\n`);
121
- } catch { /* client already gone */ }
122
- throw err;
123
- } finally {
124
- // P0-2: Kill curl subprocess if still running
125
- abortController.abort();
126
- accountPool.release(entryId, usageInfo);
127
- }
128
- });
129
- } else {
130
- // Non-streaming: retry loop for empty responses (switch accounts)
131
- const MAX_EMPTY_RETRIES = 2;
132
- let currentEntryId = entryId;
133
- let currentCodexApi = codexApi;
134
- let currentRawResponse = rawResponse;
135
-
136
- for (let attempt = 1; ; attempt++) {
137
- try {
138
- const result = await fmt.collectTranslator(
139
- currentCodexApi,
140
- currentRawResponse,
141
- req.model,
142
- );
143
- accountPool.release(currentEntryId, result.usage);
144
- return c.json(result.response);
145
- } catch (collectErr) {
146
- if (collectErr instanceof EmptyResponseError && attempt <= MAX_EMPTY_RETRIES) {
147
- const emptyEmail = accountPool.getEntry(currentEntryId)?.email ?? "?";
148
- console.warn(
149
- `[${fmt.tag}] Account ${currentEntryId} (${emptyEmail}) | Empty response (attempt ${attempt}/${MAX_EMPTY_RETRIES + 1}), switching account...`,
150
  );
151
- accountPool.recordEmptyResponse(currentEntryId);
152
- accountPool.release(currentEntryId, collectErr.usage);
 
 
 
 
 
 
 
 
153
 
154
- // Acquire a new account
155
- const newAcquired = accountPool.acquire();
156
- if (!newAcquired) {
157
- console.warn(`[${fmt.tag}] No available account for retry`);
158
- c.status(502);
159
- return c.json(fmt.formatError(502, "Codex returned an empty response and no other accounts are available for retry"));
160
- }
161
 
162
- currentEntryId = newAcquired.entryId;
163
- activeEntryId = currentEntryId;
164
- const retryProxyUrl = proxyPool?.resolveProxyUrl(newAcquired.entryId);
165
- currentCodexApi = new CodexApi(newAcquired.token, newAcquired.accountId, cookieJar, newAcquired.entryId, retryProxyUrl);
166
- try {
167
- currentRawResponse = await withRetry(
168
- () => currentCodexApi.createResponse(req.codexRequest, abortController.signal),
169
- { tag: fmt.tag },
170
- );
171
- } catch (retryErr) {
172
- accountPool.release(currentEntryId);
173
- if (retryErr instanceof CodexApiError) {
174
- const code = (retryErr.status >= 400 && retryErr.status < 600 ? retryErr.status : 502) as StatusCode;
175
- c.status(code);
176
- return c.json(fmt.formatError(code, retryErr.message));
 
 
177
  }
178
- throw retryErr;
179
  }
180
- continue;
181
- }
182
 
183
- // Not an empty response error, or retries exhausted
184
- accountPool.release(currentEntryId);
185
- if (collectErr instanceof EmptyResponseError) {
186
- const exhaustedEmail = accountPool.getEntry(currentEntryId)?.email ?? "?";
187
- console.warn(
188
- `[${fmt.tag}] Account ${currentEntryId} (${exhaustedEmail}) | Empty response (attempt ${attempt}/${MAX_EMPTY_RETRIES + 1}), all retries exhausted`,
189
- );
190
- accountPool.recordEmptyResponse(currentEntryId);
 
 
 
 
191
  c.status(502);
192
- return c.json(fmt.formatError(502, "Codex returned empty responses across all available accounts"));
193
  }
194
- const msg = collectErr instanceof Error ? collectErr.message : "Unknown error";
195
- c.status(502);
196
- return c.json(fmt.formatError(502, msg));
197
  }
198
  }
199
- }
200
- } catch (err) {
201
- // 5. Error handling with format-specific responses
202
- if (err instanceof CodexApiError) {
203
- console.error(
204
- `[${fmt.tag}] Account ${activeEntryId} | Codex API error:`,
205
- err.message,
206
- );
207
- if (err.status === 429) {
208
- // P1-6: Count 429s as requests via encapsulated API (no direct entry mutation)
209
- accountPool.markRateLimited(activeEntryId, { countRequest: true });
210
- c.status(429);
211
- return c.json(fmt.format429(err.message));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  }
213
  accountPool.release(activeEntryId);
214
- const code = (
215
- err.status >= 400 && err.status < 600 ? err.status : 502
216
- ) as StatusCode;
217
- c.status(code);
218
- return c.json(fmt.formatError(code, err.message));
219
  }
220
- accountPool.release(activeEntryId);
221
- throw err;
222
  }
 
 
 
 
223
  }
 
54
  *
55
  * Handles: acquire, session lookup, retry, stream/collect, release, error formatting.
56
  */
57
+ /** Check if a CodexApiError indicates the model is not supported on the account's plan. */
58
+ function isModelNotSupportedError(err: CodexApiError): boolean {
59
+ // Only 4xx client errors (exclude 429 rate-limit)
60
+ if (err.status < 400 || err.status >= 500 || err.status === 429) return false;
61
+ const lower = err.message.toLowerCase();
62
+ // Must contain "model" to avoid false positives like "feature not supported"
63
+ if (!lower.includes("model")) return false;
64
+ return lower.includes("not supported") || lower.includes("not_supported")
65
+ || lower.includes("not available") || lower.includes("not_available");
66
+ }
67
+
68
  export async function handleProxyRequest(
69
  c: Context,
70
  accountPool: AccountPool,
 
73
  fmt: FormatAdapter,
74
  proxyPool?: ProxyPool,
75
  ): Promise<Response> {
76
+ // 1. Acquire account (model-aware)
77
+ const acquired = accountPool.acquire({ model: req.codexRequest.model });
78
  if (!acquired) {
79
  c.status(fmt.noAccountStatus);
80
  return c.json(fmt.formatNoAccount());
 
82
 
83
  const { entryId, token, accountId } = acquired;
84
  const proxyUrl = proxyPool?.resolveProxyUrl(entryId);
85
+ let codexApi = new CodexApi(token, accountId, cookieJar, entryId, proxyUrl);
86
  // Tracks which account the outer catch should release (updated by retry loop)
87
  let activeEntryId = entryId;
88
+ // Track tried accounts for model retry exclusion
89
+ const triedEntryIds: string[] = [entryId];
90
+ let modelRetried = false;
91
 
92
  console.log(
93
  `[${fmt.tag}] Account ${entryId} | Codex request:`,
 
100
  const abortController = new AbortController();
101
  c.req.raw.signal.addEventListener("abort", () => abortController.abort(), { once: true });
102
 
103
+ for (;;) { // model retry loop (max 1 retry)
104
+ try {
105
+ // 3. Retry + send to Codex
106
+ const rawResponse = await withRetry(
107
+ () => codexApi.createResponse(req.codexRequest, abortController.signal),
108
+ { tag: fmt.tag },
109
+ );
110
+
111
+ // 4. Stream or collect
112
+ if (req.isStreaming) {
113
+ c.header("Content-Type", "text/event-stream");
114
+ c.header("Cache-Control", "no-cache");
115
+ c.header("Connection", "keep-alive");
116
+
117
+ return stream(c, async (s) => {
118
+ s.onAbort(() => abortController.abort());
119
+ try {
120
+ for await (const chunk of fmt.streamTranslator(
121
+ codexApi,
122
+ rawResponse,
123
+ req.model,
124
+ (u) => {
125
+ usageInfo = u;
126
+ },
127
+ () => {},
128
+ )) {
129
+ await s.write(chunk);
130
+ }
131
+ } catch (err) {
132
+ // P2-8: Send error SSE event to client before closing
133
+ try {
134
+ const errMsg = err instanceof Error ? err.message : "Stream interrupted";
135
+ await s.write(`data: ${JSON.stringify({ error: { message: errMsg, type: "stream_error" } })}\n\n`);
136
+ } catch { /* client already gone */ }
137
+ throw err;
138
+ } finally {
139
+ // P0-2: Kill curl subprocess if still running
140
+ abortController.abort();
141
+ accountPool.release(activeEntryId, usageInfo);
142
  }
143
+ });
144
+ } else {
145
+ // Non-streaming: retry loop for empty responses (switch accounts)
146
+ const MAX_EMPTY_RETRIES = 2;
147
+ let currentEntryId = activeEntryId;
148
+ let currentCodexApi = codexApi;
149
+ let currentRawResponse = rawResponse;
150
+
151
+ for (let attempt = 1; ; attempt++) {
152
  try {
153
+ const result = await fmt.collectTranslator(
154
+ currentCodexApi,
155
+ currentRawResponse,
156
+ req.model,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  );
158
+ accountPool.release(currentEntryId, result.usage);
159
+ return c.json(result.response);
160
+ } catch (collectErr) {
161
+ if (collectErr instanceof EmptyResponseError && attempt <= MAX_EMPTY_RETRIES) {
162
+ const emptyEmail = accountPool.getEntry(currentEntryId)?.email ?? "?";
163
+ console.warn(
164
+ `[${fmt.tag}] Account ${currentEntryId} (${emptyEmail}) | Empty response (attempt ${attempt}/${MAX_EMPTY_RETRIES + 1}), switching account...`,
165
+ );
166
+ accountPool.recordEmptyResponse(currentEntryId);
167
+ accountPool.release(currentEntryId, collectErr.usage);
168
 
169
+ // Acquire a new account (model-aware)
170
+ const newAcquired = accountPool.acquire({ model: req.codexRequest.model });
171
+ if (!newAcquired) {
172
+ console.warn(`[${fmt.tag}] No available account for retry`);
173
+ c.status(502);
174
+ return c.json(fmt.formatError(502, "Codex returned an empty response and no other accounts are available for retry"));
175
+ }
176
 
177
+ currentEntryId = newAcquired.entryId;
178
+ activeEntryId = currentEntryId;
179
+ const retryProxyUrl = proxyPool?.resolveProxyUrl(newAcquired.entryId);
180
+ currentCodexApi = new CodexApi(newAcquired.token, newAcquired.accountId, cookieJar, newAcquired.entryId, retryProxyUrl);
181
+ try {
182
+ currentRawResponse = await withRetry(
183
+ () => currentCodexApi.createResponse(req.codexRequest, abortController.signal),
184
+ { tag: fmt.tag },
185
+ );
186
+ } catch (retryErr) {
187
+ accountPool.release(currentEntryId);
188
+ if (retryErr instanceof CodexApiError) {
189
+ const code = (retryErr.status >= 400 && retryErr.status < 600 ? retryErr.status : 502) as StatusCode;
190
+ c.status(code);
191
+ return c.json(fmt.formatError(code, retryErr.message));
192
+ }
193
+ throw retryErr;
194
  }
195
+ continue;
196
  }
 
 
197
 
198
+ // Not an empty response error, or retries exhausted
199
+ accountPool.release(currentEntryId);
200
+ if (collectErr instanceof EmptyResponseError) {
201
+ const exhaustedEmail = accountPool.getEntry(currentEntryId)?.email ?? "?";
202
+ console.warn(
203
+ `[${fmt.tag}] Account ${currentEntryId} (${exhaustedEmail}) | Empty response (attempt ${attempt}/${MAX_EMPTY_RETRIES + 1}), all retries exhausted`,
204
+ );
205
+ accountPool.recordEmptyResponse(currentEntryId);
206
+ c.status(502);
207
+ return c.json(fmt.formatError(502, "Codex returned empty responses across all available accounts"));
208
+ }
209
+ const msg = collectErr instanceof Error ? collectErr.message : "Unknown error";
210
  c.status(502);
211
+ return c.json(fmt.formatError(502, msg));
212
  }
 
 
 
213
  }
214
  }
215
+ } catch (err) {
216
+ // 5. Error handling with format-specific responses
217
+ if (err instanceof CodexApiError) {
218
+ // Model not supported on this account's plan → try a different account
219
+ if (!modelRetried && isModelNotSupportedError(err)) {
220
+ modelRetried = true;
221
+ const failedEmail = accountPool.getEntry(activeEntryId)?.email ?? "?";
222
+ console.warn(
223
+ `[${fmt.tag}] Account ${activeEntryId} (${failedEmail}) | Model "${req.codexRequest.model}" not supported, trying different account...`,
224
+ );
225
+ accountPool.release(activeEntryId);
226
+
227
+ const retry = accountPool.acquire({
228
+ model: req.codexRequest.model,
229
+ excludeIds: triedEntryIds,
230
+ });
231
+ if (retry) {
232
+ activeEntryId = retry.entryId;
233
+ triedEntryIds.push(retry.entryId);
234
+ const retryProxyUrl = proxyPool?.resolveProxyUrl(retry.entryId);
235
+ codexApi = new CodexApi(retry.token, retry.accountId, cookieJar, retry.entryId, retryProxyUrl);
236
+ console.log(`[${fmt.tag}] Retrying with account ${retry.entryId}`);
237
+ continue; // re-enter model retry loop
238
+ }
239
+ // No other account available — return error (already released above)
240
+ const code = (err.status >= 400 && err.status < 600 ? err.status : 502) as StatusCode;
241
+ c.status(code);
242
+ return c.json(fmt.formatError(code, err.message));
243
+ }
244
+
245
+ console.error(
246
+ `[${fmt.tag}] Account ${activeEntryId} | Codex API error:`,
247
+ err.message,
248
+ );
249
+ if (err.status === 429) {
250
+ // P1-6: Count 429s as requests via encapsulated API (no direct entry mutation)
251
+ accountPool.markRateLimited(activeEntryId, { countRequest: true });
252
+ c.status(429);
253
+ return c.json(fmt.format429(err.message));
254
+ }
255
+ accountPool.release(activeEntryId);
256
+ const code = (
257
+ err.status >= 400 && err.status < 600 ? err.status : 502
258
+ ) as StatusCode;
259
+ c.status(code);
260
+ return c.json(fmt.formatError(code, err.message));
261
  }
262
  accountPool.release(activeEntryId);
263
+ throw err;
 
 
 
 
264
  }
265
+
266
+ break; // normal exit from model retry loop
267
  }
268
+
269
+ // Should never reach here, but TypeScript needs a return
270
+ c.status(500);
271
+ return c.json(fmt.formatError(500, "Unexpected proxy handler exit"));
272
  }