icebear0828 Claude Opus 4.6 commited on
Commit
8b777a2
·
1 Parent(s): a5368fd

refactor: extract shared proxy handler, eliminate route duplication

Browse files

- Extract withRetry() to src/utils/retry.ts (was duplicated 3x)
- Create shared proxy-handler.ts encapsulating acquire/stream/release lifecycle
- Refactor chat.ts, messages.ts, gemini.ts to use shared handler (-264 lines net)
- Convert models.ts from default export to named createModelRoutes()
- Extract GEMINI_STATUS_MAP to types/gemini.ts (was duplicated in error-handler)
- Add SessionManager.destroy() with interval cleanup + unref
- Remove dead captureCookies() method from codex-api.ts
- Add stack trace logging to error handler
- Fix hardcoded localhost in startup message
- Use atomic write-to-temp-then-rename for cookie-jar and account-pool
- Add warning logs for previously silent catch blocks

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

src/auth/account-pool.ts CHANGED
@@ -402,7 +402,9 @@ export class AccountPool {
402
  const dir = dirname(ACCOUNTS_FILE);
403
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
404
  const data: AccountsFile = { accounts: [...this.accounts.values()] };
405
- writeFileSync(ACCOUNTS_FILE, JSON.stringify(data, null, 2), "utf-8");
 
 
406
  } catch (err) {
407
  console.error("[AccountPool] Failed to persist accounts:", err instanceof Error ? err.message : err);
408
  }
@@ -420,8 +422,8 @@ export class AccountPool {
420
  }
421
  }
422
  }
423
- } catch {
424
- // corrupt file, start fresh
425
  }
426
  }
427
 
 
402
  const dir = dirname(ACCOUNTS_FILE);
403
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
404
  const data: AccountsFile = { accounts: [...this.accounts.values()] };
405
+ const tmpFile = ACCOUNTS_FILE + ".tmp";
406
+ writeFileSync(tmpFile, JSON.stringify(data, null, 2), "utf-8");
407
+ renameSync(tmpFile, ACCOUNTS_FILE);
408
  } catch (err) {
409
  console.error("[AccountPool] Failed to persist accounts:", err instanceof Error ? err.message : err);
410
  }
 
422
  }
423
  }
424
  }
425
+ } catch (err) {
426
+ console.warn("[AccountPool] Failed to load accounts:", err instanceof Error ? err.message : err);
427
  }
428
  }
429
 
src/index.ts CHANGED
@@ -11,7 +11,7 @@ import { createAccountRoutes } from "./routes/accounts.js";
11
  import { createChatRoutes } from "./routes/chat.js";
12
  import { createMessagesRoutes } from "./routes/messages.js";
13
  import { createGeminiRoutes } from "./routes/gemini.js";
14
- import modelsApp from "./routes/models.js";
15
  import { createWebRoutes } from "./routes/web.js";
16
  import { CookieJar } from "./proxy/cookie-jar.js";
17
  import { startUpdateChecker, stopUpdateChecker } from "./update-checker.js";
@@ -60,7 +60,7 @@ async function main() {
60
  app.route("/", chatRoutes);
61
  app.route("/", messagesRoutes);
62
  app.route("/", geminiRoutes);
63
- app.route("/", modelsApp);
64
  app.route("/", webRoutes);
65
 
66
  // Start server
@@ -86,7 +86,8 @@ async function main() {
86
  console.log(` Key: ${accountPool.getProxyApiKey()}`);
87
  console.log(` Pool: ${poolSummary.active} active / ${poolSummary.total} total accounts`);
88
  } else {
89
- console.log(` Open http://localhost:${port} to login`);
 
90
  }
91
  console.log();
92
 
@@ -103,6 +104,7 @@ async function main() {
103
  const shutdown = () => {
104
  console.log("\n[Shutdown] Cleaning up...");
105
  stopUpdateChecker();
 
106
  cookieJar.destroy();
107
  refreshScheduler.destroy();
108
  accountPool.destroy();
 
11
  import { createChatRoutes } from "./routes/chat.js";
12
  import { createMessagesRoutes } from "./routes/messages.js";
13
  import { createGeminiRoutes } from "./routes/gemini.js";
14
+ import { createModelRoutes } from "./routes/models.js";
15
  import { createWebRoutes } from "./routes/web.js";
16
  import { CookieJar } from "./proxy/cookie-jar.js";
17
  import { startUpdateChecker, stopUpdateChecker } from "./update-checker.js";
 
60
  app.route("/", chatRoutes);
61
  app.route("/", messagesRoutes);
62
  app.route("/", geminiRoutes);
63
+ app.route("/", createModelRoutes());
64
  app.route("/", webRoutes);
65
 
66
  // Start server
 
86
  console.log(` Key: ${accountPool.getProxyApiKey()}`);
87
  console.log(` Pool: ${poolSummary.active} active / ${poolSummary.total} total accounts`);
88
  } else {
89
+ const displayHost = host === "0.0.0.0" ? "localhost" : host;
90
+ console.log(` Open http://${displayHost}:${port} to login`);
91
  }
92
  console.log();
93
 
 
104
  const shutdown = () => {
105
  console.log("\n[Shutdown] Cleaning up...");
106
  stopUpdateChecker();
107
+ sessionManager.destroy();
108
  cookieJar.destroy();
109
  refreshScheduler.destroy();
110
  accountPool.destroy();
src/middleware/error-handler.ts CHANGED
@@ -2,6 +2,7 @@ import type { Context, Next } from "hono";
2
  import type { StatusCode } from "hono/utils/http-status";
3
  import type { OpenAIErrorBody } from "../types/openai.js";
4
  import type { AnthropicErrorBody, AnthropicErrorType } from "../types/anthropic.js";
 
5
 
6
  function makeOpenAIError(
7
  message: string,
@@ -37,23 +38,12 @@ function makeGeminiError(
37
  return { error: { code, message, status } };
38
  }
39
 
40
- const GEMINI_STATUS_MAP: Record<number, string> = {
41
- 400: "INVALID_ARGUMENT",
42
- 401: "UNAUTHENTICATED",
43
- 403: "PERMISSION_DENIED",
44
- 404: "NOT_FOUND",
45
- 429: "RESOURCE_EXHAUSTED",
46
- 500: "INTERNAL",
47
- 502: "INTERNAL",
48
- 503: "UNAVAILABLE",
49
- };
50
-
51
  export async function errorHandler(c: Context, next: Next): Promise<void> {
52
  try {
53
  await next();
54
  } catch (err: unknown) {
55
  const message = err instanceof Error ? err.message : "Internal server error";
56
- console.error("[ErrorHandler]", message);
57
 
58
  const status = (err as { status?: number }).status;
59
  const path = c.req.path;
 
2
  import type { StatusCode } from "hono/utils/http-status";
3
  import type { OpenAIErrorBody } from "../types/openai.js";
4
  import type { AnthropicErrorBody, AnthropicErrorType } from "../types/anthropic.js";
5
+ import { GEMINI_STATUS_MAP } from "../types/gemini.js";
6
 
7
  function makeOpenAIError(
8
  message: string,
 
38
  return { error: { code, message, status } };
39
  }
40
 
 
 
 
 
 
 
 
 
 
 
 
41
  export async function errorHandler(c: Context, next: Next): Promise<void> {
42
  try {
43
  await next();
44
  } catch (err: unknown) {
45
  const message = err instanceof Error ? err.message : "Internal server error";
46
+ console.error("[ErrorHandler]", err instanceof Error ? (err.stack ?? message) : message);
47
 
48
  const status = (err as { status?: number }).status;
49
  const path = c.req.path;
src/proxy/codex-api.ts CHANGED
@@ -88,13 +88,6 @@ export class CodexApi {
88
  }
89
  }
90
 
91
- /** Capture Set-Cookie headers from a fetch Response into the jar. */
92
- private captureCookies(response: Response): void {
93
- if (this.cookieJar && this.entryId) {
94
- this.cookieJar.capture(this.entryId, response);
95
- }
96
- }
97
-
98
  /**
99
  * Execute a POST request via curl subprocess.
100
  * Returns headers + streaming body as a CurlResponse.
 
88
  }
89
  }
90
 
 
 
 
 
 
 
 
91
  /**
92
  * Execute a POST request via curl subprocess.
93
  * Returns headers + streaming body as a CurlResponse.
src/proxy/cookie-jar.ts CHANGED
@@ -11,6 +11,7 @@
11
  import {
12
  readFileSync,
13
  writeFileSync,
 
14
  existsSync,
15
  mkdirSync,
16
  } from "fs";
@@ -159,9 +160,11 @@ export class CookieJar {
159
  const dir = dirname(COOKIE_FILE);
160
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
161
  const data = Object.fromEntries(this.cookies);
162
- writeFileSync(COOKIE_FILE, JSON.stringify(data, null, 2), "utf-8");
163
- } catch {
164
- // best-effort
 
 
165
  }
166
  }
167
 
@@ -175,8 +178,8 @@ export class CookieJar {
175
  this.cookies.set(key, val);
176
  }
177
  }
178
- } catch {
179
- // corrupt file, start fresh
180
  }
181
  }
182
 
 
11
  import {
12
  readFileSync,
13
  writeFileSync,
14
+ renameSync,
15
  existsSync,
16
  mkdirSync,
17
  } from "fs";
 
160
  const dir = dirname(COOKIE_FILE);
161
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
162
  const data = Object.fromEntries(this.cookies);
163
+ const tmpFile = COOKIE_FILE + ".tmp";
164
+ writeFileSync(tmpFile, JSON.stringify(data, null, 2), "utf-8");
165
+ renameSync(tmpFile, COOKIE_FILE);
166
+ } catch (err) {
167
+ console.warn("[CookieJar] Failed to persist:", err instanceof Error ? err.message : err);
168
  }
169
  }
170
 
 
178
  this.cookies.set(key, val);
179
  }
180
  }
181
+ } catch (err) {
182
+ console.warn("[CookieJar] Failed to load cookies:", err instanceof Error ? err.message : err);
183
  }
184
  }
185
 
src/routes/chat.ts CHANGED
@@ -1,42 +1,50 @@
1
  import { Hono } from "hono";
2
- import type { StatusCode } from "hono/utils/http-status";
3
- import { stream } from "hono/streaming";
4
  import { ChatCompletionRequestSchema } from "../types/openai.js";
5
  import type { AccountPool } from "../auth/account-pool.js";
6
- import { CodexApi, CodexApiError } from "../proxy/codex-api.js";
7
- import { SessionManager } from "../session/manager.js";
8
  import { translateToCodexRequest } from "../translation/openai-to-codex.js";
9
  import {
10
  streamCodexToOpenAI,
11
  collectCodexResponse,
12
- type UsageInfo,
13
  } from "../translation/codex-to-openai.js";
14
  import { getConfig } from "../config.js";
15
- import type { CookieJar } from "../proxy/cookie-jar.js";
 
 
 
16
 
17
- /** Retry a function on 5xx errors with exponential backoff. */
18
- async function withRetry<T>(
19
- fn: () => Promise<T>,
20
- { maxRetries = 2, baseDelayMs = 1000 }: { maxRetries?: number; baseDelayMs?: number } = {},
21
- ): Promise<T> {
22
- let lastError: unknown;
23
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
24
- try {
25
- return await fn();
26
- } catch (err) {
27
- lastError = err;
28
- const isRetryable =
29
- err instanceof CodexApiError && err.status >= 500 && err.status < 600;
30
- if (!isRetryable || attempt === maxRetries) throw err;
31
- const delay = baseDelayMs * Math.pow(2, attempt);
32
- console.warn(
33
- `[Chat] Retrying after ${err instanceof CodexApiError ? err.status : "error"} (attempt ${attempt + 1}/${maxRetries}, delay ${delay}ms)`,
34
- );
35
- await new Promise((r) => setTimeout(r, delay));
36
- }
37
- }
38
- throw lastError;
39
- }
 
 
 
 
 
 
 
 
40
 
41
  export function createChatRoutes(
42
  accountPool: AccountPool,
@@ -46,7 +54,7 @@ export function createChatRoutes(
46
  const app = new Hono();
47
 
48
  app.post("/v1/chat/completions", async (c) => {
49
- // Validate auth — at least one active account
50
  if (!accountPool.isAuthenticated()) {
51
  c.status(401);
52
  return c.json({
@@ -96,106 +104,21 @@ export function createChatRoutes(
96
  }
97
  const req = parsed.data;
98
 
99
- // Acquire an account from the pool
100
- const acquired = accountPool.acquire();
101
- if (!acquired) {
102
- c.status(503);
103
- return c.json({
104
- error: {
105
- message: "No available accounts. All accounts are expired or rate-limited.",
106
- type: "server_error",
107
- param: null,
108
- code: "no_available_accounts",
109
- },
110
- });
111
- }
112
-
113
- const { entryId, token, accountId } = acquired;
114
- const codexApi = new CodexApi(token, accountId, cookieJar, entryId);
115
 
116
- // Find existing session for multi-turn previous_response_id
117
- const existingSession = sessionManager.findSession(req.messages);
118
- const previousResponseId = existingSession?.responseId ?? null;
119
- const codexRequest = translateToCodexRequest(req, previousResponseId);
120
- if (previousResponseId) {
121
- console.log(`[Chat] Account ${entryId} | Multi-turn: previous_response_id=${previousResponseId}`);
122
- }
123
- console.log(
124
- `[Chat] Account ${entryId} | Codex request:`,
125
- JSON.stringify(codexRequest).slice(0, 300),
 
 
126
  );
127
-
128
- let usageInfo: UsageInfo | undefined;
129
-
130
- try {
131
- const rawResponse = await withRetry(() => codexApi.createResponse(codexRequest));
132
-
133
- if (req.stream) {
134
- c.header("Content-Type", "text/event-stream");
135
- c.header("Cache-Control", "no-cache");
136
- c.header("Connection", "keep-alive");
137
-
138
- return stream(c, async (s) => {
139
- let sessionTaskId: string | null = null;
140
- try {
141
- for await (const chunk of streamCodexToOpenAI(
142
- codexApi,
143
- rawResponse,
144
- codexRequest.model,
145
- (u) => { usageInfo = u; },
146
- (respId) => {
147
- if (!sessionTaskId) {
148
- // First call: create session
149
- sessionTaskId = `task-${Date.now()}`;
150
- sessionManager.storeSession(sessionTaskId, "turn-1", req.messages);
151
- }
152
- sessionManager.updateResponseId(sessionTaskId, respId);
153
- },
154
- )) {
155
- await s.write(chunk);
156
- }
157
- } finally {
158
- accountPool.release(entryId, usageInfo);
159
- }
160
- });
161
- } else {
162
- const result = await collectCodexResponse(
163
- codexApi,
164
- rawResponse,
165
- codexRequest.model,
166
- );
167
- // Store session with responseId for multi-turn
168
- if (result.responseId) {
169
- const taskId = `task-${Date.now()}`;
170
- sessionManager.storeSession(taskId, "turn-1", req.messages);
171
- sessionManager.updateResponseId(taskId, result.responseId);
172
- }
173
- accountPool.release(entryId, result.usage);
174
- return c.json(result.response);
175
- }
176
- } catch (err) {
177
- if (err instanceof CodexApiError) {
178
- console.error(`[Chat] Account ${entryId} | Codex API error:`, err.message);
179
- if (err.status === 429) {
180
- // Parse Retry-After if present
181
- accountPool.markRateLimited(entryId);
182
- } else {
183
- accountPool.release(entryId);
184
- }
185
- const code = (err.status >= 400 && err.status < 600 ? err.status : 502) as StatusCode;
186
- c.status(code);
187
- return c.json({
188
- error: {
189
- message: err.message,
190
- type: "server_error",
191
- param: null,
192
- code: "codex_api_error",
193
- },
194
- });
195
- }
196
- accountPool.release(entryId);
197
- throw err;
198
- }
199
  });
200
 
201
  return app;
 
1
  import { Hono } from "hono";
 
 
2
  import { ChatCompletionRequestSchema } from "../types/openai.js";
3
  import type { AccountPool } from "../auth/account-pool.js";
4
+ import type { SessionManager } from "../session/manager.js";
5
+ import type { CookieJar } from "../proxy/cookie-jar.js";
6
  import { translateToCodexRequest } from "../translation/openai-to-codex.js";
7
  import {
8
  streamCodexToOpenAI,
9
  collectCodexResponse,
 
10
  } from "../translation/codex-to-openai.js";
11
  import { getConfig } from "../config.js";
12
+ import {
13
+ handleProxyRequest,
14
+ type FormatAdapter,
15
+ } from "./shared/proxy-handler.js";
16
 
17
+ const OPENAI_FORMAT: FormatAdapter = {
18
+ tag: "Chat",
19
+ noAccountStatus: 503,
20
+ formatNoAccount: () => ({
21
+ error: {
22
+ message:
23
+ "No available accounts. All accounts are expired or rate-limited.",
24
+ type: "server_error",
25
+ param: null,
26
+ code: "no_available_accounts",
27
+ },
28
+ }),
29
+ format429: (msg) => ({
30
+ error: {
31
+ message: msg,
32
+ type: "rate_limit_error",
33
+ param: null,
34
+ code: "rate_limit_exceeded",
35
+ },
36
+ }),
37
+ formatError: (_status, msg) => ({
38
+ error: {
39
+ message: msg,
40
+ type: "server_error",
41
+ param: null,
42
+ code: "codex_api_error",
43
+ },
44
+ }),
45
+ streamTranslator: streamCodexToOpenAI,
46
+ collectTranslator: collectCodexResponse,
47
+ };
48
 
49
  export function createChatRoutes(
50
  accountPool: AccountPool,
 
54
  const app = new Hono();
55
 
56
  app.post("/v1/chat/completions", async (c) => {
57
+ // Auth check
58
  if (!accountPool.isAuthenticated()) {
59
  c.status(401);
60
  return c.json({
 
104
  }
105
  const req = parsed.data;
106
 
107
+ const codexRequest = translateToCodexRequest(req);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
+ return handleProxyRequest(
110
+ c,
111
+ accountPool,
112
+ sessionManager,
113
+ cookieJar,
114
+ {
115
+ codexRequest,
116
+ sessionMessages: req.messages,
117
+ model: codexRequest.model,
118
+ isStreaming: req.stream,
119
+ },
120
+ OPENAI_FORMAT,
121
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  });
123
 
124
  return app;
src/routes/gemini.ts CHANGED
@@ -6,12 +6,12 @@
6
 
7
  import { Hono } from "hono";
8
  import type { StatusCode } from "hono/utils/http-status";
9
- import { stream } from "hono/streaming";
10
- import { GeminiGenerateContentRequestSchema } from "../types/gemini.js";
11
  import type { GeminiErrorResponse } from "../types/gemini.js";
 
 
12
  import type { AccountPool } from "../auth/account-pool.js";
13
- import { CodexApi, CodexApiError } from "../proxy/codex-api.js";
14
- import { SessionManager } from "../session/manager.js";
15
  import {
16
  translateGeminiToCodexRequest,
17
  geminiContentsToMessages,
@@ -19,46 +19,13 @@ import {
19
  import {
20
  streamCodexToGemini,
21
  collectCodexToGeminiResponse,
22
- type GeminiUsageInfo,
23
  } from "../translation/codex-to-gemini.js";
24
  import { getConfig } from "../config.js";
25
- import type { CookieJar } from "../proxy/cookie-jar.js";
26
  import { resolveModelId } from "./models.js";
27
-
28
- /** Retry a function on 5xx errors with exponential backoff. */
29
- async function withRetry<T>(
30
- fn: () => Promise<T>,
31
- { maxRetries = 2, baseDelayMs = 1000 }: { maxRetries?: number; baseDelayMs?: number } = {},
32
- ): Promise<T> {
33
- let lastError: unknown;
34
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
35
- try {
36
- return await fn();
37
- } catch (err) {
38
- lastError = err;
39
- const isRetryable =
40
- err instanceof CodexApiError && err.status >= 500 && err.status < 600;
41
- if (!isRetryable || attempt === maxRetries) throw err;
42
- const delay = baseDelayMs * Math.pow(2, attempt);
43
- console.warn(
44
- `[Gemini] Retrying after ${err instanceof CodexApiError ? err.status : "error"} (attempt ${attempt + 1}/${maxRetries}, delay ${delay}ms)`,
45
- );
46
- await new Promise((r) => setTimeout(r, delay));
47
- }
48
- }
49
- throw lastError;
50
- }
51
-
52
- const GEMINI_STATUS_MAP: Record<number, string> = {
53
- 400: "INVALID_ARGUMENT",
54
- 401: "UNAUTHENTICATED",
55
- 403: "PERMISSION_DENIED",
56
- 404: "NOT_FOUND",
57
- 429: "RESOURCE_EXHAUSTED",
58
- 500: "INTERNAL",
59
- 502: "INTERNAL",
60
- 503: "UNAVAILABLE",
61
- };
62
 
63
  function makeError(
64
  code: number,
@@ -90,6 +57,21 @@ function parseModelAction(param: string): {
90
  };
91
  }
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  export function createGeminiRoutes(
94
  accountPool: AccountPool,
95
  sessionManager: SessionManager,
@@ -121,7 +103,7 @@ export function createGeminiRoutes(
121
  action === "streamGenerateContent" ||
122
  c.req.query("alt") === "sse";
123
 
124
- // Validate auth — at least one active account
125
  if (!accountPool.isAuthenticated()) {
126
  c.status(401);
127
  return c.json(
@@ -155,125 +137,38 @@ export function createGeminiRoutes(
155
  }
156
  const req = validationResult.data;
157
 
158
- // Acquire an account from the pool
159
- const acquired = accountPool.acquire();
160
- if (!acquired) {
161
- c.status(503);
162
- return c.json(
163
- makeError(
164
- 503,
165
- "No available accounts. All accounts are expired or rate-limited.",
166
- "UNAVAILABLE",
167
- ),
168
- );
169
- }
170
-
171
- const { entryId, token, accountId } = acquired;
172
- const codexApi = new CodexApi(token, accountId, cookieJar, entryId);
173
-
174
  // Session lookup for multi-turn
175
  const sessionMessages = geminiContentsToMessages(
176
  req.contents,
177
  req.systemInstruction,
178
  );
179
- const existingSession = sessionManager.findSession(sessionMessages);
180
- const previousResponseId = existingSession?.responseId ?? null;
181
 
182
  const codexRequest = translateGeminiToCodexRequest(
183
  req,
184
  geminiModel,
185
- previousResponseId,
186
  );
187
- if (previousResponseId) {
188
- console.log(
189
- `[Gemini] Account ${entryId} | Multi-turn: previous_response_id=${previousResponseId}`,
190
- );
191
- }
192
  console.log(
193
- `[Gemini] Account ${entryId} | Model: ${geminiModel} → ${codexRequest.model} | Codex request:`,
194
- JSON.stringify(codexRequest).slice(0, 300),
195
  );
196
 
197
- let usageInfo: GeminiUsageInfo | undefined;
198
-
199
- try {
200
- const rawResponse = await withRetry(() =>
201
- codexApi.createResponse(codexRequest),
202
- );
203
-
204
- if (isStreaming) {
205
- c.header("Content-Type", "text/event-stream");
206
- c.header("Cache-Control", "no-cache");
207
- c.header("Connection", "keep-alive");
208
-
209
- return stream(c, async (s) => {
210
- let sessionTaskId: string | null = null;
211
- try {
212
- for await (const chunk of streamCodexToGemini(
213
- codexApi,
214
- rawResponse,
215
- geminiModel,
216
- (u) => {
217
- usageInfo = u;
218
- },
219
- (respId) => {
220
- if (!sessionTaskId) {
221
- sessionTaskId = `task-${Date.now()}`;
222
- sessionManager.storeSession(
223
- sessionTaskId,
224
- "turn-1",
225
- sessionMessages,
226
- );
227
- }
228
- sessionManager.updateResponseId(sessionTaskId, respId);
229
- },
230
- )) {
231
- await s.write(chunk);
232
- }
233
- } finally {
234
- accountPool.release(entryId, usageInfo);
235
- }
236
- });
237
- } else {
238
- const result = await collectCodexToGeminiResponse(
239
- codexApi,
240
- rawResponse,
241
- geminiModel,
242
- );
243
- if (result.responseId) {
244
- const taskId = `task-${Date.now()}`;
245
- sessionManager.storeSession(taskId, "turn-1", sessionMessages);
246
- sessionManager.updateResponseId(taskId, result.responseId);
247
- }
248
- accountPool.release(entryId, result.usage);
249
- return c.json(result.response);
250
- }
251
- } catch (err) {
252
- if (err instanceof CodexApiError) {
253
- console.error(
254
- `[Gemini] Account ${entryId} | Codex API error:`,
255
- err.message,
256
- );
257
- if (err.status === 429) {
258
- accountPool.markRateLimited(entryId);
259
- c.status(429);
260
- return c.json(makeError(429, err.message, "RESOURCE_EXHAUSTED"));
261
- }
262
- accountPool.release(entryId);
263
- const code = (
264
- err.status >= 400 && err.status < 600 ? err.status : 502
265
- ) as StatusCode;
266
- c.status(code);
267
- return c.json(makeError(code, err.message));
268
- }
269
- accountPool.release(entryId);
270
- throw err;
271
- }
272
  });
273
 
274
  // List available Gemini models
275
  app.get("/v1beta/models", (c) => {
276
- // Import aliases from models.yaml and filter Gemini ones
277
  const geminiAliases = [
278
  "gemini-2.5-pro",
279
  "gemini-2.5-pro-preview",
 
6
 
7
  import { Hono } from "hono";
8
  import type { StatusCode } from "hono/utils/http-status";
 
 
9
  import type { GeminiErrorResponse } from "../types/gemini.js";
10
+ import { GEMINI_STATUS_MAP } from "../types/gemini.js";
11
+ import { GeminiGenerateContentRequestSchema } from "../types/gemini.js";
12
  import type { AccountPool } from "../auth/account-pool.js";
13
+ import type { SessionManager } from "../session/manager.js";
14
+ import type { CookieJar } from "../proxy/cookie-jar.js";
15
  import {
16
  translateGeminiToCodexRequest,
17
  geminiContentsToMessages,
 
19
  import {
20
  streamCodexToGemini,
21
  collectCodexToGeminiResponse,
 
22
  } from "../translation/codex-to-gemini.js";
23
  import { getConfig } from "../config.js";
 
24
  import { resolveModelId } from "./models.js";
25
+ import {
26
+ handleProxyRequest,
27
+ type FormatAdapter,
28
+ } from "./shared/proxy-handler.js";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  function makeError(
31
  code: number,
 
57
  };
58
  }
59
 
60
+ const GEMINI_FORMAT: FormatAdapter = {
61
+ tag: "Gemini",
62
+ noAccountStatus: 503,
63
+ formatNoAccount: () =>
64
+ makeError(
65
+ 503,
66
+ "No available accounts. All accounts are expired or rate-limited.",
67
+ "UNAVAILABLE",
68
+ ),
69
+ format429: (msg) => makeError(429, msg, "RESOURCE_EXHAUSTED"),
70
+ formatError: (status, msg) => makeError(status, msg),
71
+ streamTranslator: streamCodexToGemini,
72
+ collectTranslator: collectCodexToGeminiResponse,
73
+ };
74
+
75
  export function createGeminiRoutes(
76
  accountPool: AccountPool,
77
  sessionManager: SessionManager,
 
103
  action === "streamGenerateContent" ||
104
  c.req.query("alt") === "sse";
105
 
106
+ // Auth check
107
  if (!accountPool.isAuthenticated()) {
108
  c.status(401);
109
  return c.json(
 
137
  }
138
  const req = validationResult.data;
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  // Session lookup for multi-turn
141
  const sessionMessages = geminiContentsToMessages(
142
  req.contents,
143
  req.systemInstruction,
144
  );
 
 
145
 
146
  const codexRequest = translateGeminiToCodexRequest(
147
  req,
148
  geminiModel,
 
149
  );
150
+
 
 
 
 
151
  console.log(
152
+ `[Gemini] Model: ${geminiModel} → ${codexRequest.model}`,
 
153
  );
154
 
155
+ return handleProxyRequest(
156
+ c,
157
+ accountPool,
158
+ sessionManager,
159
+ cookieJar,
160
+ {
161
+ codexRequest,
162
+ sessionMessages,
163
+ model: geminiModel,
164
+ isStreaming,
165
+ },
166
+ GEMINI_FORMAT,
167
+ );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  });
169
 
170
  // List available Gemini models
171
  app.get("/v1beta/models", (c) => {
 
172
  const geminiAliases = [
173
  "gemini-2.5-pro",
174
  "gemini-2.5-pro-preview",
src/routes/messages.ts CHANGED
@@ -5,44 +5,21 @@
5
 
6
  import { Hono } from "hono";
7
  import type { StatusCode } from "hono/utils/http-status";
8
- import { stream } from "hono/streaming";
9
  import { AnthropicMessagesRequestSchema } from "../types/anthropic.js";
10
  import type { AnthropicErrorBody, AnthropicErrorType } from "../types/anthropic.js";
11
  import type { AccountPool } from "../auth/account-pool.js";
12
- import { CodexApi, CodexApiError } from "../proxy/codex-api.js";
13
- import { SessionManager } from "../session/manager.js";
14
  import { translateAnthropicToCodexRequest } from "../translation/anthropic-to-codex.js";
15
  import {
16
  streamCodexToAnthropic,
17
  collectCodexToAnthropicResponse,
18
- type AnthropicUsageInfo,
19
  } from "../translation/codex-to-anthropic.js";
20
  import { getConfig } from "../config.js";
21
- import type { CookieJar } from "../proxy/cookie-jar.js";
22
-
23
- /** Retry a function on 5xx errors with exponential backoff. */
24
- async function withRetry<T>(
25
- fn: () => Promise<T>,
26
- { maxRetries = 2, baseDelayMs = 1000 }: { maxRetries?: number; baseDelayMs?: number } = {},
27
- ): Promise<T> {
28
- let lastError: unknown;
29
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
30
- try {
31
- return await fn();
32
- } catch (err) {
33
- lastError = err;
34
- const isRetryable =
35
- err instanceof CodexApiError && err.status >= 500 && err.status < 600;
36
- if (!isRetryable || attempt === maxRetries) throw err;
37
- const delay = baseDelayMs * Math.pow(2, attempt);
38
- console.warn(
39
- `[Messages] Retrying after ${err instanceof CodexApiError ? err.status : "error"} (attempt ${attempt + 1}/${maxRetries}, delay ${delay}ms)`,
40
- );
41
- await new Promise((r) => setTimeout(r, delay));
42
- }
43
- }
44
- throw lastError;
45
- }
46
 
47
  function makeError(
48
  type: AnthropicErrorType,
@@ -64,6 +41,20 @@ function contentToString(
64
  .join("\n");
65
  }
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  export function createMessagesRoutes(
68
  accountPool: AccountPool,
69
  sessionManager: SessionManager,
@@ -72,7 +63,7 @@ export function createMessagesRoutes(
72
  const app = new Hono();
73
 
74
  app.post("/v1/messages", async (c) => {
75
- // Validate auth — at least one active account
76
  if (!accountPool.isAuthenticated()) {
77
  c.status(401);
78
  return c.json(
@@ -80,8 +71,7 @@ export function createMessagesRoutes(
80
  );
81
  }
82
 
83
- // Optional proxy API key check
84
- // Anthropic clients use x-api-key header; also accept Bearer token
85
  const config = getConfig();
86
  if (config.server.proxy_api_key) {
87
  const xApiKey = c.req.header("x-api-key");
@@ -106,21 +96,6 @@ export function createMessagesRoutes(
106
  }
107
  const req = parsed.data;
108
 
109
- // Acquire an account from the pool
110
- const acquired = accountPool.acquire();
111
- if (!acquired) {
112
- c.status(529 as StatusCode);
113
- return c.json(
114
- makeError(
115
- "overloaded_error",
116
- "No available accounts. All accounts are expired or rate-limited.",
117
- ),
118
- );
119
- }
120
-
121
- const { entryId, token, accountId } = acquired;
122
- const codexApi = new CodexApi(token, accountId, cookieJar, entryId);
123
-
124
  // Build session-compatible messages for multi-turn lookup
125
  const sessionMessages: Array<{ role: string; content: string }> = [];
126
  if (req.system) {
@@ -137,92 +112,21 @@ export function createMessagesRoutes(
137
  });
138
  }
139
 
140
- const existingSession = sessionManager.findSession(sessionMessages);
141
- const previousResponseId = existingSession?.responseId ?? null;
142
- const codexRequest = translateAnthropicToCodexRequest(req, previousResponseId);
143
- if (previousResponseId) {
144
- console.log(
145
- `[Messages] Account ${entryId} | Multi-turn: previous_response_id=${previousResponseId}`,
146
- );
147
- }
148
- console.log(
149
- `[Messages] Account ${entryId} | Codex request:`,
150
- JSON.stringify(codexRequest).slice(0, 300),
 
 
 
151
  );
152
-
153
- let usageInfo: AnthropicUsageInfo | undefined;
154
-
155
- try {
156
- const rawResponse = await withRetry(() => codexApi.createResponse(codexRequest));
157
-
158
- if (req.stream) {
159
- c.header("Content-Type", "text/event-stream");
160
- c.header("Cache-Control", "no-cache");
161
- c.header("Connection", "keep-alive");
162
-
163
- return stream(c, async (s) => {
164
- let sessionTaskId: string | null = null;
165
- try {
166
- for await (const chunk of streamCodexToAnthropic(
167
- codexApi,
168
- rawResponse,
169
- req.model, // Echo back the model name the client sent
170
- (u) => {
171
- usageInfo = u;
172
- },
173
- (respId) => {
174
- if (!sessionTaskId) {
175
- sessionTaskId = `task-${Date.now()}`;
176
- sessionManager.storeSession(
177
- sessionTaskId,
178
- "turn-1",
179
- sessionMessages,
180
- );
181
- }
182
- sessionManager.updateResponseId(sessionTaskId, respId);
183
- },
184
- )) {
185
- await s.write(chunk);
186
- }
187
- } finally {
188
- accountPool.release(entryId, usageInfo);
189
- }
190
- });
191
- } else {
192
- const result = await collectCodexToAnthropicResponse(
193
- codexApi,
194
- rawResponse,
195
- req.model,
196
- );
197
- if (result.responseId) {
198
- const taskId = `task-${Date.now()}`;
199
- sessionManager.storeSession(taskId, "turn-1", sessionMessages);
200
- sessionManager.updateResponseId(taskId, result.responseId);
201
- }
202
- accountPool.release(entryId, result.usage);
203
- return c.json(result.response);
204
- }
205
- } catch (err) {
206
- if (err instanceof CodexApiError) {
207
- console.error(
208
- `[Messages] Account ${entryId} | Codex API error:`,
209
- err.message,
210
- );
211
- if (err.status === 429) {
212
- accountPool.markRateLimited(entryId);
213
- c.status(429);
214
- return c.json(makeError("rate_limit_error", err.message));
215
- }
216
- accountPool.release(entryId);
217
- const code = (
218
- err.status >= 400 && err.status < 600 ? err.status : 502
219
- ) as StatusCode;
220
- c.status(code);
221
- return c.json(makeError("api_error", err.message));
222
- }
223
- accountPool.release(entryId);
224
- throw err;
225
- }
226
  });
227
 
228
  return app;
 
5
 
6
  import { Hono } from "hono";
7
  import type { StatusCode } from "hono/utils/http-status";
 
8
  import { AnthropicMessagesRequestSchema } from "../types/anthropic.js";
9
  import type { AnthropicErrorBody, AnthropicErrorType } from "../types/anthropic.js";
10
  import type { AccountPool } from "../auth/account-pool.js";
11
+ import type { SessionManager } from "../session/manager.js";
12
+ import type { CookieJar } from "../proxy/cookie-jar.js";
13
  import { translateAnthropicToCodexRequest } from "../translation/anthropic-to-codex.js";
14
  import {
15
  streamCodexToAnthropic,
16
  collectCodexToAnthropicResponse,
 
17
  } from "../translation/codex-to-anthropic.js";
18
  import { getConfig } from "../config.js";
19
+ import {
20
+ handleProxyRequest,
21
+ type FormatAdapter,
22
+ } from "./shared/proxy-handler.js";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  function makeError(
25
  type: AnthropicErrorType,
 
41
  .join("\n");
42
  }
43
 
44
+ const ANTHROPIC_FORMAT: FormatAdapter = {
45
+ tag: "Messages",
46
+ noAccountStatus: 529 as StatusCode,
47
+ formatNoAccount: () =>
48
+ makeError(
49
+ "overloaded_error",
50
+ "No available accounts. All accounts are expired or rate-limited.",
51
+ ),
52
+ format429: (msg) => makeError("rate_limit_error", msg),
53
+ formatError: (_status, msg) => makeError("api_error", msg),
54
+ streamTranslator: streamCodexToAnthropic,
55
+ collectTranslator: collectCodexToAnthropicResponse,
56
+ };
57
+
58
  export function createMessagesRoutes(
59
  accountPool: AccountPool,
60
  sessionManager: SessionManager,
 
63
  const app = new Hono();
64
 
65
  app.post("/v1/messages", async (c) => {
66
+ // Auth check
67
  if (!accountPool.isAuthenticated()) {
68
  c.status(401);
69
  return c.json(
 
71
  );
72
  }
73
 
74
+ // Optional proxy API key check (x-api-key or Bearer token)
 
75
  const config = getConfig();
76
  if (config.server.proxy_api_key) {
77
  const xApiKey = c.req.header("x-api-key");
 
96
  }
97
  const req = parsed.data;
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  // Build session-compatible messages for multi-turn lookup
100
  const sessionMessages: Array<{ role: string; content: string }> = [];
101
  if (req.system) {
 
112
  });
113
  }
114
 
115
+ const codexRequest = translateAnthropicToCodexRequest(req);
116
+
117
+ return handleProxyRequest(
118
+ c,
119
+ accountPool,
120
+ sessionManager,
121
+ cookieJar,
122
+ {
123
+ codexRequest,
124
+ sessionMessages,
125
+ model: req.model,
126
+ isStreaming: req.stream,
127
+ },
128
+ ANTHROPIC_FORMAT,
129
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  });
131
 
132
  return app;
src/routes/models.ts CHANGED
@@ -5,8 +5,6 @@ import yaml from "js-yaml";
5
  import { getConfig } from "../config.js";
6
  import type { OpenAIModel, OpenAIModelList } from "../types/openai.js";
7
 
8
- const app = new Hono();
9
-
10
  /**
11
  * Full model catalog from Codex CLI `model/list`.
12
  * Each model has reasoning effort levels, description, and capabilities.
@@ -78,60 +76,64 @@ function toOpenAIModel(info: CodexModelInfo): OpenAIModel {
78
  };
79
  }
80
 
81
- app.get("/v1/models", (c) => {
82
- // Include catalog models + aliases as separate entries
83
- const models: OpenAIModel[] = MODEL_CATALOG.map(toOpenAIModel);
84
- for (const [alias] of Object.entries(MODEL_ALIASES)) {
85
- models.push({
86
- id: alias,
87
- object: "model",
88
- created: MODEL_CREATED_TIMESTAMP,
89
- owned_by: "openai",
90
- });
91
- }
92
- const response: OpenAIModelList = { object: "list", data: models };
93
- return c.json(response);
94
- });
 
 
 
95
 
96
- app.get("/v1/models/:modelId", (c) => {
97
- const modelId = c.req.param("modelId");
98
 
99
- // Try direct match
100
- const info = MODEL_CATALOG.find((m) => m.id === modelId);
101
- if (info) return c.json(toOpenAIModel(info));
102
 
103
- // Try alias
104
- const resolved = MODEL_ALIASES[modelId];
105
- if (resolved) {
 
 
 
 
 
 
 
 
 
106
  return c.json({
107
- id: modelId,
108
- object: "model",
109
- created: MODEL_CREATED_TIMESTAMP,
110
- owned_by: "openai",
 
 
111
  });
112
- }
113
-
114
- c.status(404);
115
- return c.json({
116
- error: {
117
- message: `Model '${modelId}' not found`,
118
- type: "invalid_request_error",
119
- param: "model",
120
- code: "model_not_found",
121
- },
122
  });
123
- });
124
-
125
- // Extended endpoint: model details with reasoning efforts
126
- app.get("/v1/models/:modelId/info", (c) => {
127
- const modelId = c.req.param("modelId");
128
- const resolved = MODEL_ALIASES[modelId] ?? modelId;
129
- const info = MODEL_CATALOG.find((m) => m.id === resolved);
130
- if (!info) {
131
- c.status(404);
132
- return c.json({ error: `Model '${modelId}' not found` });
133
- }
134
- return c.json(info);
135
- });
136
 
137
- export default app;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  import { getConfig } from "../config.js";
6
  import type { OpenAIModel, OpenAIModelList } from "../types/openai.js";
7
 
 
 
8
  /**
9
  * Full model catalog from Codex CLI `model/list`.
10
  * Each model has reasoning effort levels, description, and capabilities.
 
76
  };
77
  }
78
 
79
+ export function createModelRoutes(): Hono {
80
+ const app = new Hono();
81
+
82
+ app.get("/v1/models", (c) => {
83
+ // Include catalog models + aliases as separate entries
84
+ const models: OpenAIModel[] = MODEL_CATALOG.map(toOpenAIModel);
85
+ for (const [alias] of Object.entries(MODEL_ALIASES)) {
86
+ models.push({
87
+ id: alias,
88
+ object: "model",
89
+ created: MODEL_CREATED_TIMESTAMP,
90
+ owned_by: "openai",
91
+ });
92
+ }
93
+ const response: OpenAIModelList = { object: "list", data: models };
94
+ return c.json(response);
95
+ });
96
 
97
+ app.get("/v1/models/:modelId", (c) => {
98
+ const modelId = c.req.param("modelId");
99
 
100
+ // Try direct match
101
+ const info = MODEL_CATALOG.find((m) => m.id === modelId);
102
+ if (info) return c.json(toOpenAIModel(info));
103
 
104
+ // Try alias
105
+ const resolved = MODEL_ALIASES[modelId];
106
+ if (resolved) {
107
+ return c.json({
108
+ id: modelId,
109
+ object: "model",
110
+ created: MODEL_CREATED_TIMESTAMP,
111
+ owned_by: "openai",
112
+ });
113
+ }
114
+
115
+ c.status(404);
116
  return c.json({
117
+ error: {
118
+ message: `Model '${modelId}' not found`,
119
+ type: "invalid_request_error",
120
+ param: "model",
121
+ code: "model_not_found",
122
+ },
123
  });
 
 
 
 
 
 
 
 
 
 
124
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
+ // Extended endpoint: model details with reasoning efforts
127
+ app.get("/v1/models/:modelId/info", (c) => {
128
+ const modelId = c.req.param("modelId");
129
+ const resolved = MODEL_ALIASES[modelId] ?? modelId;
130
+ const info = MODEL_CATALOG.find((m) => m.id === resolved);
131
+ if (!info) {
132
+ c.status(404);
133
+ return c.json({ error: `Model '${modelId}' not found` });
134
+ }
135
+ return c.json(info);
136
+ });
137
+
138
+ return app;
139
+ }
src/routes/shared/proxy-handler.ts ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Shared proxy handler — encapsulates the account acquire → retry → stream/collect → release
3
+ * lifecycle that is common to all API format routes (OpenAI, Anthropic, Gemini).
4
+ *
5
+ * Each route provides its own schema parsing, auth checking, and format adapter.
6
+ * This handler takes over once a CodexResponsesRequest is prepared.
7
+ */
8
+
9
+ import type { Context } from "hono";
10
+ import type { StatusCode } from "hono/utils/http-status";
11
+ import { stream } from "hono/streaming";
12
+ import { randomUUID } from "crypto";
13
+ import { CodexApi, CodexApiError } from "../../proxy/codex-api.js";
14
+ import type { CodexResponsesRequest } from "../../proxy/codex-api.js";
15
+ import type { AccountPool } from "../../auth/account-pool.js";
16
+ import type { SessionManager } from "../../session/manager.js";
17
+ import type { CookieJar } from "../../proxy/cookie-jar.js";
18
+ import { withRetry } from "../../utils/retry.js";
19
+
20
+ /** Data prepared by each route after parsing and translating the request. */
21
+ export interface ProxyRequest {
22
+ codexRequest: CodexResponsesRequest;
23
+ sessionMessages: Array<{ role: string; content: string }>;
24
+ model: string;
25
+ isStreaming: boolean;
26
+ }
27
+
28
+ /** Format-specific adapter provided by each route. */
29
+ export interface FormatAdapter {
30
+ tag: string;
31
+ noAccountStatus: StatusCode;
32
+ formatNoAccount: () => unknown;
33
+ format429: (message: string) => unknown;
34
+ formatError: (status: number, message: string) => unknown;
35
+ streamTranslator: (
36
+ api: CodexApi,
37
+ response: Response,
38
+ model: string,
39
+ onUsage: (u: { input_tokens: number; output_tokens: number }) => void,
40
+ onResponseId: (id: string) => void,
41
+ ) => AsyncGenerator<string>;
42
+ collectTranslator: (
43
+ api: CodexApi,
44
+ response: Response,
45
+ model: string,
46
+ ) => Promise<{
47
+ response: unknown;
48
+ usage: { input_tokens: number; output_tokens: number };
49
+ responseId: string | null;
50
+ }>;
51
+ }
52
+
53
+ /**
54
+ * Core shared handler — from account acquire to release.
55
+ *
56
+ * Handles: acquire, session lookup, retry, stream/collect, release, error formatting.
57
+ */
58
+ export async function handleProxyRequest(
59
+ c: Context,
60
+ accountPool: AccountPool,
61
+ sessionManager: SessionManager,
62
+ cookieJar: CookieJar | undefined,
63
+ req: ProxyRequest,
64
+ fmt: FormatAdapter,
65
+ ): Promise<Response> {
66
+ // 1. Acquire account
67
+ const acquired = accountPool.acquire();
68
+ if (!acquired) {
69
+ c.status(fmt.noAccountStatus);
70
+ return c.json(fmt.formatNoAccount());
71
+ }
72
+
73
+ const { entryId, token, accountId } = acquired;
74
+ const codexApi = new CodexApi(token, accountId, cookieJar, entryId);
75
+
76
+ // 2. Session lookup for multi-turn
77
+ const existingSession = sessionManager.findSession(req.sessionMessages);
78
+ const previousResponseId = existingSession?.responseId ?? null;
79
+ if (previousResponseId) {
80
+ req.codexRequest.previous_response_id = previousResponseId;
81
+ console.log(
82
+ `[${fmt.tag}] Account ${entryId} | Multi-turn: previous_response_id=${previousResponseId}`,
83
+ );
84
+ }
85
+ console.log(
86
+ `[${fmt.tag}] Account ${entryId} | Codex request:`,
87
+ JSON.stringify(req.codexRequest).slice(0, 300),
88
+ );
89
+
90
+ let usageInfo: { input_tokens: number; output_tokens: number } | undefined;
91
+
92
+ try {
93
+ // 3. Retry + send to Codex
94
+ const rawResponse = await withRetry(
95
+ () => codexApi.createResponse(req.codexRequest),
96
+ { tag: fmt.tag },
97
+ );
98
+
99
+ // 4. Stream or collect
100
+ if (req.isStreaming) {
101
+ c.header("Content-Type", "text/event-stream");
102
+ c.header("Cache-Control", "no-cache");
103
+ c.header("Connection", "keep-alive");
104
+
105
+ return stream(c, async (s) => {
106
+ let sessionTaskId: string | null = null;
107
+ try {
108
+ for await (const chunk of fmt.streamTranslator(
109
+ codexApi,
110
+ rawResponse,
111
+ req.model,
112
+ (u) => {
113
+ usageInfo = u;
114
+ },
115
+ (respId) => {
116
+ if (!sessionTaskId) {
117
+ sessionTaskId = `task-${randomUUID()}`;
118
+ sessionManager.storeSession(
119
+ sessionTaskId,
120
+ "turn-1",
121
+ req.sessionMessages,
122
+ );
123
+ }
124
+ sessionManager.updateResponseId(sessionTaskId, respId);
125
+ },
126
+ )) {
127
+ await s.write(chunk);
128
+ }
129
+ } finally {
130
+ accountPool.release(entryId, usageInfo);
131
+ }
132
+ });
133
+ } else {
134
+ const result = await fmt.collectTranslator(
135
+ codexApi,
136
+ rawResponse,
137
+ req.model,
138
+ );
139
+ if (result.responseId) {
140
+ const taskId = `task-${randomUUID()}`;
141
+ sessionManager.storeSession(
142
+ taskId,
143
+ "turn-1",
144
+ req.sessionMessages,
145
+ );
146
+ sessionManager.updateResponseId(taskId, result.responseId);
147
+ }
148
+ accountPool.release(entryId, result.usage);
149
+ return c.json(result.response);
150
+ }
151
+ } catch (err) {
152
+ // 5. Error handling with format-specific responses
153
+ if (err instanceof CodexApiError) {
154
+ console.error(
155
+ `[${fmt.tag}] Account ${entryId} | Codex API error:`,
156
+ err.message,
157
+ );
158
+ if (err.status === 429) {
159
+ accountPool.markRateLimited(entryId);
160
+ c.status(429);
161
+ return c.json(fmt.format429(err.message));
162
+ }
163
+ accountPool.release(entryId);
164
+ const code = (
165
+ err.status >= 400 && err.status < 600 ? err.status : 502
166
+ ) as StatusCode;
167
+ c.status(code);
168
+ return c.json(fmt.formatError(code, err.message));
169
+ }
170
+ accountPool.release(entryId);
171
+ throw err;
172
+ }
173
+ }
src/session/manager.ts CHANGED
@@ -12,11 +12,20 @@ interface Session {
12
  export class SessionManager {
13
  private sessions = new Map<string, Session>();
14
  private ttlMs: number;
 
15
 
16
  constructor() {
17
  const { ttl_minutes, cleanup_interval_minutes } = getConfig().session;
18
  this.ttlMs = ttl_minutes * 60 * 1000;
19
- setInterval(() => this.cleanup(), cleanup_interval_minutes * 60 * 1000);
 
 
 
 
 
 
 
 
20
  }
21
 
22
  /**
 
12
  export class SessionManager {
13
  private sessions = new Map<string, Session>();
14
  private ttlMs: number;
15
+ private cleanupTimer: ReturnType<typeof setInterval>;
16
 
17
  constructor() {
18
  const { ttl_minutes, cleanup_interval_minutes } = getConfig().session;
19
  this.ttlMs = ttl_minutes * 60 * 1000;
20
+ this.cleanupTimer = setInterval(
21
+ () => this.cleanup(),
22
+ cleanup_interval_minutes * 60 * 1000,
23
+ );
24
+ if (this.cleanupTimer.unref) this.cleanupTimer.unref();
25
+ }
26
+
27
+ destroy(): void {
28
+ clearInterval(this.cleanupTimer);
29
  }
30
 
31
  /**
src/types/gemini.ts CHANGED
@@ -62,6 +62,19 @@ export interface GeminiGenerateContentResponse {
62
  modelVersion?: string;
63
  }
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  // --- Error ---
66
 
67
  export interface GeminiErrorResponse {
 
62
  modelVersion?: string;
63
  }
64
 
65
+ // --- Status map (shared by error-handler and gemini route) ---
66
+
67
+ export const GEMINI_STATUS_MAP: Record<number, string> = {
68
+ 400: "INVALID_ARGUMENT",
69
+ 401: "UNAUTHENTICATED",
70
+ 403: "PERMISSION_DENIED",
71
+ 404: "NOT_FOUND",
72
+ 429: "RESOURCE_EXHAUSTED",
73
+ 500: "INTERNAL",
74
+ 502: "INTERNAL",
75
+ 503: "UNAVAILABLE",
76
+ };
77
+
78
  // --- Error ---
79
 
80
  export interface GeminiErrorResponse {
src/utils/retry.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CodexApiError } from "../proxy/codex-api.js";
2
+
3
+ /** Retry a function on 5xx errors with exponential backoff. */
4
+ export async function withRetry<T>(
5
+ fn: () => Promise<T>,
6
+ {
7
+ maxRetries = 2,
8
+ baseDelayMs = 1000,
9
+ tag = "Proxy",
10
+ }: { maxRetries?: number; baseDelayMs?: number; tag?: string } = {},
11
+ ): Promise<T> {
12
+ let lastError: unknown;
13
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
14
+ try {
15
+ return await fn();
16
+ } catch (err) {
17
+ lastError = err;
18
+ const isRetryable =
19
+ err instanceof CodexApiError && err.status >= 500 && err.status < 600;
20
+ if (!isRetryable || attempt === maxRetries) throw err;
21
+ const delay = baseDelayMs * Math.pow(2, attempt);
22
+ console.warn(
23
+ `[${tag}] Retrying after ${err instanceof CodexApiError ? err.status : "error"} (attempt ${attempt + 1}/${maxRetries}, delay ${delay}ms)`,
24
+ );
25
+ await new Promise((r) => setTimeout(r, delay));
26
+ }
27
+ }
28
+ throw lastError;
29
+ }