icebear0828 Claude Opus 4.6 commited on
Commit
d0eb8b9
·
1 Parent(s): bd64e44

feat: multi-protocol support — Anthropic Messages API + Google Gemini API

Browse files

Add two new protocol translation layers so the proxy can serve clients
speaking Anthropic (/v1/messages) or Gemini (/v1beta/models/:model:generateContent)
in addition to the existing OpenAI /v1/chat/completions endpoint.
All three protocols translate to the same upstream Codex Responses API.

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

config/models.yaml CHANGED
@@ -74,3 +74,23 @@ aliases:
74
  codex: "gpt-5.3-codex"
75
  codex-max: "gpt-5.1-codex-max"
76
  codex-mini: "gpt-5.1-codex-mini"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  codex: "gpt-5.3-codex"
75
  codex-max: "gpt-5.1-codex-max"
76
  codex-mini: "gpt-5.1-codex-mini"
77
+
78
+ # Anthropic Claude model aliases
79
+ claude-sonnet-4-20250514: "gpt-5.3-codex"
80
+ claude-opus-4-20250514: "gpt-5.3-codex"
81
+ claude-sonnet-4-6: "gpt-5.3-codex"
82
+ claude-opus-4-6: "gpt-5.3-codex"
83
+ claude-haiku-4-5: "gpt-5.1-codex-mini"
84
+ claude-haiku-4-5-20251001: "gpt-5.1-codex-mini"
85
+ claude-3-5-sonnet-20241022: "gpt-5.3-codex"
86
+ claude-3-5-haiku-20241022: "gpt-5.1-codex-mini"
87
+ claude-3-opus-20240229: "gpt-5.3-codex"
88
+ claude-sonnet: "gpt-5.3-codex"
89
+ claude-opus: "gpt-5.3-codex"
90
+ claude-haiku: "gpt-5.1-codex-mini"
91
+
92
+ # Google Gemini model aliases
93
+ gemini-2.5-pro: "gpt-5.3-codex"
94
+ gemini-2.5-pro-preview: "gpt-5.3-codex"
95
+ gemini-2.5-flash: "gpt-5.1-codex-mini"
96
+ gemini-2.0-flash: "gpt-5.1-codex-mini"
src/index.ts CHANGED
@@ -9,6 +9,8 @@ import { errorHandler } from "./middleware/error-handler.js";
9
  import { createAuthRoutes } from "./routes/auth.js";
10
  import { createAccountRoutes } from "./routes/accounts.js";
11
  import { createChatRoutes } from "./routes/chat.js";
 
 
12
  import modelsApp from "./routes/models.js";
13
  import { createWebRoutes } from "./routes/web.js";
14
  import { CookieJar } from "./proxy/cookie-jar.js";
@@ -45,11 +47,15 @@ async function main() {
45
  const authRoutes = createAuthRoutes(accountPool, refreshScheduler);
46
  const accountRoutes = createAccountRoutes(accountPool, refreshScheduler, cookieJar);
47
  const chatRoutes = createChatRoutes(accountPool, sessionManager, cookieJar);
 
 
48
  const webRoutes = createWebRoutes(accountPool);
49
 
50
  app.route("/", authRoutes);
51
  app.route("/", accountRoutes);
52
  app.route("/", chatRoutes);
 
 
53
  app.route("/", modelsApp);
54
  app.route("/", webRoutes);
55
 
 
9
  import { createAuthRoutes } from "./routes/auth.js";
10
  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";
 
47
  const authRoutes = createAuthRoutes(accountPool, refreshScheduler);
48
  const accountRoutes = createAccountRoutes(accountPool, refreshScheduler, cookieJar);
49
  const chatRoutes = createChatRoutes(accountPool, sessionManager, cookieJar);
50
+ const messagesRoutes = createMessagesRoutes(accountPool, sessionManager, cookieJar);
51
+ const geminiRoutes = createGeminiRoutes(accountPool, sessionManager, cookieJar);
52
  const webRoutes = createWebRoutes(accountPool);
53
 
54
  app.route("/", authRoutes);
55
  app.route("/", accountRoutes);
56
  app.route("/", chatRoutes);
57
+ app.route("/", messagesRoutes);
58
+ app.route("/", geminiRoutes);
59
  app.route("/", modelsApp);
60
  app.route("/", webRoutes);
61
 
src/middleware/error-handler.ts CHANGED
@@ -1,7 +1,9 @@
1
  import type { Context, Next } from "hono";
 
2
  import type { OpenAIErrorBody } from "../types/openai.js";
 
3
 
4
- function makeError(
5
  message: string,
6
  type: string,
7
  code: string | null,
@@ -16,6 +18,36 @@ function makeError(
16
  };
17
  }
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  export async function errorHandler(c: Context, next: Next): Promise<void> {
20
  try {
21
  await next();
@@ -24,11 +56,51 @@ export async function errorHandler(c: Context, next: Next): Promise<void> {
24
  console.error("[ErrorHandler]", message);
25
 
26
  const status = (err as { status?: number }).status;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
 
28
  if (status === 401) {
29
  c.status(401);
30
  return c.json(
31
- makeError(
32
  "Invalid or expired ChatGPT token. Please re-authenticate.",
33
  "invalid_request_error",
34
  "invalid_api_key",
@@ -39,7 +111,7 @@ export async function errorHandler(c: Context, next: Next): Promise<void> {
39
  if (status === 429) {
40
  c.status(429);
41
  return c.json(
42
- makeError(
43
  "Rate limit exceeded. Please try again later.",
44
  "rate_limit_error",
45
  "rate_limit_exceeded",
@@ -50,7 +122,7 @@ export async function errorHandler(c: Context, next: Next): Promise<void> {
50
  if (status && status >= 500) {
51
  c.status(502);
52
  return c.json(
53
- makeError(
54
  `Upstream server error: ${message}`,
55
  "server_error",
56
  "server_error",
@@ -60,7 +132,7 @@ export async function errorHandler(c: Context, next: Next): Promise<void> {
60
 
61
  c.status(500);
62
  return c.json(
63
- makeError(message, "server_error", "internal_error"),
64
  ) as never;
65
  }
66
  }
 
1
  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,
8
  type: string,
9
  code: string | null,
 
18
  };
19
  }
20
 
21
+ function makeAnthropicError(
22
+ message: string,
23
+ errorType: AnthropicErrorType,
24
+ ): AnthropicErrorBody {
25
+ return { type: "error", error: { type: errorType, message } };
26
+ }
27
+
28
+ interface GeminiErrorBody {
29
+ error: { code: number; message: string; status: string };
30
+ }
31
+
32
+ function makeGeminiError(
33
+ code: number,
34
+ message: string,
35
+ status: string,
36
+ ): GeminiErrorBody {
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();
 
56
  console.error("[ErrorHandler]", message);
57
 
58
  const status = (err as { status?: number }).status;
59
+ const path = c.req.path;
60
+
61
+ // Anthropic Messages API errors
62
+ if (path.startsWith("/v1/messages")) {
63
+ if (status === 401) {
64
+ c.status(401);
65
+ return c.json(
66
+ makeAnthropicError(
67
+ "Invalid or expired token. Please re-authenticate.",
68
+ "authentication_error",
69
+ ),
70
+ ) as never;
71
+ }
72
+ if (status === 429) {
73
+ c.status(429);
74
+ return c.json(
75
+ makeAnthropicError(
76
+ "Rate limit exceeded. Please try again later.",
77
+ "rate_limit_error",
78
+ ),
79
+ ) as never;
80
+ }
81
+ if (status && status >= 500) {
82
+ c.status(502);
83
+ return c.json(
84
+ makeAnthropicError(`Upstream server error: ${message}`, "api_error"),
85
+ ) as never;
86
+ }
87
+ c.status(500);
88
+ return c.json(makeAnthropicError(message, "api_error")) as never;
89
+ }
90
+
91
+ // Gemini API errors
92
+ if (path.startsWith("/v1beta/")) {
93
+ const code = status ?? 500;
94
+ const geminiStatus = GEMINI_STATUS_MAP[code] ?? "INTERNAL";
95
+ c.status((code >= 400 && code < 600 ? code : 500) as StatusCode);
96
+ return c.json(makeGeminiError(code, message, geminiStatus)) as never;
97
+ }
98
 
99
+ // Default: OpenAI-format errors
100
  if (status === 401) {
101
  c.status(401);
102
  return c.json(
103
+ makeOpenAIError(
104
  "Invalid or expired ChatGPT token. Please re-authenticate.",
105
  "invalid_request_error",
106
  "invalid_api_key",
 
111
  if (status === 429) {
112
  c.status(429);
113
  return c.json(
114
+ makeOpenAIError(
115
  "Rate limit exceeded. Please try again later.",
116
  "rate_limit_error",
117
  "rate_limit_exceeded",
 
122
  if (status && status >= 500) {
123
  c.status(502);
124
  return c.json(
125
+ makeOpenAIError(
126
  `Upstream server error: ${message}`,
127
  "server_error",
128
  "server_error",
 
132
 
133
  c.status(500);
134
  return c.json(
135
+ makeOpenAIError(message, "server_error", "internal_error"),
136
  ) as never;
137
  }
138
  }
src/routes/gemini.ts ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Google Gemini API route handler.
3
+ * POST /v1beta/models/{model}:generateContent — non-streaming
4
+ * POST /v1beta/models/{model}:streamGenerateContent — streaming
5
+ */
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,
18
+ } from "../translation/gemini-to-codex.js";
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,
65
+ message: string,
66
+ status?: string,
67
+ ): GeminiErrorResponse {
68
+ return {
69
+ error: {
70
+ code,
71
+ message,
72
+ status: status ?? GEMINI_STATUS_MAP[code] ?? "INTERNAL",
73
+ },
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Parse model name and action from the URL param.
79
+ * e.g. "gemini-2.5-pro:generateContent" → { model: "gemini-2.5-pro", action: "generateContent" }
80
+ */
81
+ function parseModelAction(param: string): {
82
+ model: string;
83
+ action: string;
84
+ } | null {
85
+ const lastColon = param.lastIndexOf(":");
86
+ if (lastColon <= 0) return null;
87
+ return {
88
+ model: param.slice(0, lastColon),
89
+ action: param.slice(lastColon + 1),
90
+ };
91
+ }
92
+
93
+ export function createGeminiRoutes(
94
+ accountPool: AccountPool,
95
+ sessionManager: SessionManager,
96
+ cookieJar?: CookieJar,
97
+ ): Hono {
98
+ const app = new Hono();
99
+
100
+ // Handle both generateContent and streamGenerateContent
101
+ app.post("/v1beta/models/:modelAction", async (c) => {
102
+ const modelActionParam = c.req.param("modelAction");
103
+ const parsed = parseModelAction(modelActionParam);
104
+
105
+ if (
106
+ !parsed ||
107
+ (parsed.action !== "generateContent" &&
108
+ parsed.action !== "streamGenerateContent")
109
+ ) {
110
+ c.status(400);
111
+ return c.json(
112
+ makeError(
113
+ 400,
114
+ `Invalid action. Expected :generateContent or :streamGenerateContent, got: ${modelActionParam}`,
115
+ ),
116
+ );
117
+ }
118
+
119
+ const { model: geminiModel, action } = parsed;
120
+ const isStreaming =
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(
128
+ makeError(401, "Not authenticated. Please login first at /"),
129
+ );
130
+ }
131
+
132
+ // API key check: query param ?key= or header x-goog-api-key
133
+ const config = getConfig();
134
+ if (config.server.proxy_api_key) {
135
+ const queryKey = c.req.query("key");
136
+ const headerKey = c.req.header("x-goog-api-key");
137
+ const authHeader = c.req.header("Authorization");
138
+ const bearerKey = authHeader?.replace("Bearer ", "");
139
+ const providedKey = queryKey ?? headerKey ?? bearerKey;
140
+
141
+ if (!providedKey || !accountPool.validateProxyApiKey(providedKey)) {
142
+ c.status(401);
143
+ return c.json(makeError(401, "Invalid API key"));
144
+ }
145
+ }
146
+
147
+ // Parse request
148
+ const body = await c.req.json();
149
+ const validationResult = GeminiGenerateContentRequestSchema.safeParse(body);
150
+ if (!validationResult.success) {
151
+ c.status(400);
152
+ return c.json(
153
+ makeError(400, `Invalid request: ${validationResult.error.message}`),
154
+ );
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",
280
+ "gemini-2.5-flash",
281
+ "gemini-2.0-flash",
282
+ ];
283
+
284
+ const models = geminiAliases.map((name) => ({
285
+ name: `models/${name}`,
286
+ displayName: name,
287
+ description: `Proxy alias for ${resolveModelId(name)}`,
288
+ supportedGenerationMethods: [
289
+ "generateContent",
290
+ "streamGenerateContent",
291
+ ],
292
+ }));
293
+
294
+ return c.json({ models });
295
+ });
296
+
297
+ return app;
298
+ }
src/routes/messages.ts ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Anthropic Messages API route handler.
3
+ * POST /v1/messages — compatible with Claude Code CLI and other Anthropic clients.
4
+ */
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,
49
+ message: string,
50
+ ): AnthropicErrorBody {
51
+ return { type: "error", error: { type, message } };
52
+ }
53
+
54
+ /**
55
+ * Extract text from Anthropic message content for session hashing.
56
+ */
57
+ function contentToString(
58
+ content: string | Array<{ type: string; text?: string }>,
59
+ ): string {
60
+ if (typeof content === "string") return content;
61
+ return content
62
+ .filter((b) => b.type === "text" && b.text)
63
+ .map((b) => b.text!)
64
+ .join("\n");
65
+ }
66
+
67
+ export function createMessagesRoutes(
68
+ accountPool: AccountPool,
69
+ sessionManager: SessionManager,
70
+ cookieJar?: CookieJar,
71
+ ): Hono {
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(
79
+ makeError("authentication_error", "Not authenticated. Please login first at /"),
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");
88
+ const authHeader = c.req.header("Authorization");
89
+ const bearerKey = authHeader?.replace("Bearer ", "");
90
+ const providedKey = xApiKey ?? bearerKey;
91
+
92
+ if (!providedKey || !accountPool.validateProxyApiKey(providedKey)) {
93
+ c.status(401);
94
+ return c.json(makeError("authentication_error", "Invalid API key"));
95
+ }
96
+ }
97
+
98
+ // Parse request
99
+ const body = await c.req.json();
100
+ const parsed = AnthropicMessagesRequestSchema.safeParse(body);
101
+ if (!parsed.success) {
102
+ c.status(400);
103
+ return c.json(
104
+ makeError("invalid_request_error", `Invalid request: ${parsed.error.message}`),
105
+ );
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) {
127
+ const sysText =
128
+ typeof req.system === "string"
129
+ ? req.system
130
+ : req.system.map((b) => b.text).join("\n");
131
+ sessionMessages.push({ role: "system", content: sysText });
132
+ }
133
+ for (const msg of req.messages) {
134
+ sessionMessages.push({
135
+ role: msg.role,
136
+ content: contentToString(msg.content),
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;
229
+ }
src/translation/anthropic-to-codex.ts ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Translate Anthropic Messages API request → Codex Responses API request.
3
+ */
4
+
5
+ import { readFileSync } from "fs";
6
+ import { resolve } from "path";
7
+ import type { AnthropicMessagesRequest } from "../types/anthropic.js";
8
+ import type {
9
+ CodexResponsesRequest,
10
+ CodexInputItem,
11
+ } from "../proxy/codex-api.js";
12
+ import { resolveModelId, getModelInfo } from "../routes/models.js";
13
+ import { getConfig } from "../config.js";
14
+
15
+ const DESKTOP_CONTEXT = loadDesktopContext();
16
+
17
+ function loadDesktopContext(): string {
18
+ try {
19
+ return readFileSync(
20
+ resolve(process.cwd(), "config/prompts/desktop-context.md"),
21
+ "utf-8",
22
+ );
23
+ } catch {
24
+ return "";
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Map Anthropic thinking budget_tokens to Codex reasoning effort.
30
+ */
31
+ function mapThinkingToEffort(
32
+ thinking: AnthropicMessagesRequest["thinking"],
33
+ ): string | undefined {
34
+ if (!thinking || thinking.type === "disabled") return undefined;
35
+ const budget = thinking.budget_tokens;
36
+ if (budget < 2000) return "low";
37
+ if (budget < 8000) return "medium";
38
+ if (budget < 20000) return "high";
39
+ return "xhigh";
40
+ }
41
+
42
+ /**
43
+ * Extract text from Anthropic content (string or content block array).
44
+ */
45
+ function flattenContent(
46
+ content: string | Array<{ type: string; text?: string }>,
47
+ ): string {
48
+ if (typeof content === "string") return content;
49
+ return content
50
+ .filter((b) => b.type === "text" && b.text)
51
+ .map((b) => b.text!)
52
+ .join("\n");
53
+ }
54
+
55
+ /**
56
+ * Convert an AnthropicMessagesRequest to a CodexResponsesRequest.
57
+ *
58
+ * Mapping:
59
+ * - system (top-level) → instructions field
60
+ * - messages → input array
61
+ * - model → resolved model ID
62
+ * - thinking → reasoning.effort
63
+ */
64
+ export function translateAnthropicToCodexRequest(
65
+ req: AnthropicMessagesRequest,
66
+ previousResponseId?: string | null,
67
+ ): CodexResponsesRequest {
68
+ // Extract system instructions
69
+ let userInstructions: string;
70
+ if (req.system) {
71
+ if (typeof req.system === "string") {
72
+ userInstructions = req.system;
73
+ } else {
74
+ userInstructions = req.system.map((b) => b.text).join("\n\n");
75
+ }
76
+ } else {
77
+ userInstructions = "You are a helpful assistant.";
78
+ }
79
+ const instructions = DESKTOP_CONTEXT
80
+ ? `${DESKTOP_CONTEXT}\n\n${userInstructions}`
81
+ : userInstructions;
82
+
83
+ // Build input items from messages
84
+ const input: CodexInputItem[] = [];
85
+ for (const msg of req.messages) {
86
+ input.push({
87
+ role: msg.role as "user" | "assistant",
88
+ content: flattenContent(msg.content),
89
+ });
90
+ }
91
+
92
+ // Ensure at least one input message
93
+ if (input.length === 0) {
94
+ input.push({ role: "user", content: "" });
95
+ }
96
+
97
+ // Resolve model
98
+ const modelId = resolveModelId(req.model);
99
+ const modelInfo = getModelInfo(modelId);
100
+ const config = getConfig();
101
+
102
+ // Build request
103
+ const request: CodexResponsesRequest = {
104
+ model: modelId,
105
+ instructions,
106
+ input,
107
+ stream: true,
108
+ store: false,
109
+ tools: [],
110
+ };
111
+
112
+ // Add previous response ID for multi-turn conversations
113
+ if (previousResponseId) {
114
+ request.previous_response_id = previousResponseId;
115
+ }
116
+
117
+ // Add reasoning effort: thinking param → model default → config default
118
+ const thinkingEffort = mapThinkingToEffort(req.thinking);
119
+ const effort =
120
+ thinkingEffort ??
121
+ modelInfo?.defaultReasoningEffort ??
122
+ config.model.default_reasoning_effort;
123
+ if (effort) {
124
+ request.reasoning = { effort };
125
+ }
126
+
127
+ return request;
128
+ }
src/translation/codex-to-anthropic.ts ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Translate Codex Responses API SSE stream → Anthropic Messages API format.
3
+ *
4
+ * Codex SSE events:
5
+ * response.created → extract response ID
6
+ * response.output_text.delta → content_block_delta (text_delta)
7
+ * response.completed → content_block_stop + message_delta + message_stop
8
+ *
9
+ * Non-streaming: collect all text, return Anthropic message response.
10
+ */
11
+
12
+ import { randomUUID } from "crypto";
13
+ import type { CodexApi } from "../proxy/codex-api.js";
14
+ import type {
15
+ AnthropicMessagesResponse,
16
+ AnthropicUsage,
17
+ } from "../types/anthropic.js";
18
+
19
+ export interface AnthropicUsageInfo {
20
+ input_tokens: number;
21
+ output_tokens: number;
22
+ }
23
+
24
+ /** Format an Anthropic SSE event with named event type */
25
+ function formatSSE(eventType: string, data: unknown): string {
26
+ return `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
27
+ }
28
+
29
+ /**
30
+ * Stream Codex Responses API events as Anthropic Messages SSE.
31
+ * Yields string chunks ready to write to the HTTP response.
32
+ */
33
+ export async function* streamCodexToAnthropic(
34
+ codexApi: CodexApi,
35
+ rawResponse: Response,
36
+ model: string,
37
+ onUsage?: (usage: AnthropicUsageInfo) => void,
38
+ onResponseId?: (id: string) => void,
39
+ ): AsyncGenerator<string> {
40
+ const msgId = `msg_${randomUUID().replace(/-/g, "").slice(0, 24)}`;
41
+ let outputTokens = 0;
42
+ let inputTokens = 0;
43
+
44
+ // 1. message_start
45
+ yield formatSSE("message_start", {
46
+ type: "message_start",
47
+ message: {
48
+ id: msgId,
49
+ type: "message",
50
+ role: "assistant",
51
+ content: [],
52
+ model,
53
+ stop_reason: null,
54
+ stop_sequence: null,
55
+ usage: { input_tokens: 0, output_tokens: 0 },
56
+ },
57
+ });
58
+
59
+ // 2. content_block_start for text block at index 0
60
+ yield formatSSE("content_block_start", {
61
+ type: "content_block_start",
62
+ index: 0,
63
+ content_block: { type: "text", text: "" },
64
+ });
65
+
66
+ // 3. Process Codex stream events
67
+ for await (const evt of codexApi.parseStream(rawResponse)) {
68
+ const data = evt.data as Record<string, unknown>;
69
+
70
+ switch (evt.event) {
71
+ case "response.created":
72
+ case "response.in_progress": {
73
+ const resp = data.response as Record<string, unknown> | undefined;
74
+ if (resp?.id) {
75
+ onResponseId?.(resp.id as string);
76
+ }
77
+ break;
78
+ }
79
+
80
+ case "response.output_text.delta": {
81
+ const delta = (data.delta as string) ?? "";
82
+ if (delta) {
83
+ yield formatSSE("content_block_delta", {
84
+ type: "content_block_delta",
85
+ index: 0,
86
+ delta: { type: "text_delta", text: delta },
87
+ });
88
+ }
89
+ break;
90
+ }
91
+
92
+ case "response.completed": {
93
+ const resp = data.response as Record<string, unknown> | undefined;
94
+ if (resp?.usage) {
95
+ const u = resp.usage as Record<string, number>;
96
+ inputTokens = u.input_tokens ?? 0;
97
+ outputTokens = u.output_tokens ?? 0;
98
+ onUsage?.({ input_tokens: inputTokens, output_tokens: outputTokens });
99
+ }
100
+ break;
101
+ }
102
+ }
103
+ }
104
+
105
+ // 4. content_block_stop
106
+ yield formatSSE("content_block_stop", {
107
+ type: "content_block_stop",
108
+ index: 0,
109
+ });
110
+
111
+ // 5. message_delta with stop_reason and usage
112
+ yield formatSSE("message_delta", {
113
+ type: "message_delta",
114
+ delta: { stop_reason: "end_turn" },
115
+ usage: { output_tokens: outputTokens },
116
+ });
117
+
118
+ // 6. message_stop
119
+ yield formatSSE("message_stop", {
120
+ type: "message_stop",
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Consume a Codex Responses SSE stream and build a non-streaming
126
+ * Anthropic Messages response.
127
+ */
128
+ export async function collectCodexToAnthropicResponse(
129
+ codexApi: CodexApi,
130
+ rawResponse: Response,
131
+ model: string,
132
+ ): Promise<{
133
+ response: AnthropicMessagesResponse;
134
+ usage: AnthropicUsageInfo;
135
+ responseId: string | null;
136
+ }> {
137
+ const id = `msg_${randomUUID().replace(/-/g, "").slice(0, 24)}`;
138
+ let fullText = "";
139
+ let inputTokens = 0;
140
+ let outputTokens = 0;
141
+ let responseId: string | null = null;
142
+
143
+ for await (const evt of codexApi.parseStream(rawResponse)) {
144
+ const data = evt.data as Record<string, unknown>;
145
+
146
+ switch (evt.event) {
147
+ case "response.created":
148
+ case "response.in_progress": {
149
+ const resp = data.response as Record<string, unknown> | undefined;
150
+ if (resp?.id) responseId = resp.id as string;
151
+ break;
152
+ }
153
+
154
+ case "response.output_text.delta": {
155
+ fullText += (data.delta as string) ?? "";
156
+ break;
157
+ }
158
+
159
+ case "response.completed": {
160
+ const resp = data.response as Record<string, unknown> | undefined;
161
+ if (resp?.id) responseId = resp.id as string;
162
+ if (resp?.usage) {
163
+ const u = resp.usage as Record<string, number>;
164
+ inputTokens = u.input_tokens ?? 0;
165
+ outputTokens = u.output_tokens ?? 0;
166
+ }
167
+ break;
168
+ }
169
+ }
170
+ }
171
+
172
+ const usage: AnthropicUsage = {
173
+ input_tokens: inputTokens,
174
+ output_tokens: outputTokens,
175
+ };
176
+
177
+ return {
178
+ response: {
179
+ id,
180
+ type: "message",
181
+ role: "assistant",
182
+ content: [{ type: "text", text: fullText }],
183
+ model,
184
+ stop_reason: "end_turn",
185
+ stop_sequence: null,
186
+ usage,
187
+ },
188
+ usage,
189
+ responseId,
190
+ };
191
+ }
src/translation/codex-to-gemini.ts ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Translate Codex Responses API SSE stream → Google Gemini API format.
3
+ *
4
+ * Codex SSE events:
5
+ * response.created → extract response ID
6
+ * response.output_text.delta → streaming candidate with text part
7
+ * response.completed → final candidate with finishReason + usageMetadata
8
+ *
9
+ * Non-streaming: collect all text, return Gemini generateContent response.
10
+ */
11
+
12
+ import type { CodexApi } from "../proxy/codex-api.js";
13
+ import type {
14
+ GeminiGenerateContentResponse,
15
+ GeminiUsageMetadata,
16
+ } from "../types/gemini.js";
17
+
18
+ export interface GeminiUsageInfo {
19
+ input_tokens: number;
20
+ output_tokens: number;
21
+ }
22
+
23
+ /**
24
+ * Stream Codex Responses API events as Gemini SSE.
25
+ * Yields string chunks ready to write to the HTTP response.
26
+ */
27
+ export async function* streamCodexToGemini(
28
+ codexApi: CodexApi,
29
+ rawResponse: Response,
30
+ model: string,
31
+ onUsage?: (usage: GeminiUsageInfo) => void,
32
+ onResponseId?: (id: string) => void,
33
+ ): AsyncGenerator<string> {
34
+ let inputTokens = 0;
35
+ let outputTokens = 0;
36
+
37
+ for await (const evt of codexApi.parseStream(rawResponse)) {
38
+ const data = evt.data as Record<string, unknown>;
39
+
40
+ switch (evt.event) {
41
+ case "response.created":
42
+ case "response.in_progress": {
43
+ const resp = data.response as Record<string, unknown> | undefined;
44
+ if (resp?.id) {
45
+ onResponseId?.(resp.id as string);
46
+ }
47
+ break;
48
+ }
49
+
50
+ case "response.output_text.delta": {
51
+ const delta = (data.delta as string) ?? "";
52
+ if (delta) {
53
+ const chunk: GeminiGenerateContentResponse = {
54
+ candidates: [
55
+ {
56
+ content: {
57
+ parts: [{ text: delta }],
58
+ role: "model",
59
+ },
60
+ index: 0,
61
+ },
62
+ ],
63
+ modelVersion: model,
64
+ };
65
+ yield `data: ${JSON.stringify(chunk)}\r\n\r\n`;
66
+ }
67
+ break;
68
+ }
69
+
70
+ case "response.completed": {
71
+ const resp = data.response as Record<string, unknown> | undefined;
72
+ if (resp?.usage) {
73
+ const u = resp.usage as Record<string, number>;
74
+ inputTokens = u.input_tokens ?? 0;
75
+ outputTokens = u.output_tokens ?? 0;
76
+ onUsage?.({ input_tokens: inputTokens, output_tokens: outputTokens });
77
+ }
78
+
79
+ // Final chunk with finishReason and usage
80
+ const finalChunk: GeminiGenerateContentResponse = {
81
+ candidates: [
82
+ {
83
+ content: {
84
+ parts: [{ text: "" }],
85
+ role: "model",
86
+ },
87
+ finishReason: "STOP",
88
+ index: 0,
89
+ },
90
+ ],
91
+ usageMetadata: {
92
+ promptTokenCount: inputTokens,
93
+ candidatesTokenCount: outputTokens,
94
+ totalTokenCount: inputTokens + outputTokens,
95
+ },
96
+ modelVersion: model,
97
+ };
98
+ yield `data: ${JSON.stringify(finalChunk)}\r\n\r\n`;
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Consume a Codex Responses SSE stream and build a non-streaming
107
+ * Gemini generateContent response.
108
+ */
109
+ export async function collectCodexToGeminiResponse(
110
+ codexApi: CodexApi,
111
+ rawResponse: Response,
112
+ model: string,
113
+ ): Promise<{
114
+ response: GeminiGenerateContentResponse;
115
+ usage: GeminiUsageInfo;
116
+ responseId: string | null;
117
+ }> {
118
+ let fullText = "";
119
+ let inputTokens = 0;
120
+ let outputTokens = 0;
121
+ let responseId: string | null = null;
122
+
123
+ for await (const evt of codexApi.parseStream(rawResponse)) {
124
+ const data = evt.data as Record<string, unknown>;
125
+
126
+ switch (evt.event) {
127
+ case "response.created":
128
+ case "response.in_progress": {
129
+ const resp = data.response as Record<string, unknown> | undefined;
130
+ if (resp?.id) responseId = resp.id as string;
131
+ break;
132
+ }
133
+
134
+ case "response.output_text.delta": {
135
+ fullText += (data.delta as string) ?? "";
136
+ break;
137
+ }
138
+
139
+ case "response.completed": {
140
+ const resp = data.response as Record<string, unknown> | undefined;
141
+ if (resp?.id) responseId = resp.id as string;
142
+ if (resp?.usage) {
143
+ const u = resp.usage as Record<string, number>;
144
+ inputTokens = u.input_tokens ?? 0;
145
+ outputTokens = u.output_tokens ?? 0;
146
+ }
147
+ break;
148
+ }
149
+ }
150
+ }
151
+
152
+ const usage: GeminiUsageInfo = {
153
+ input_tokens: inputTokens,
154
+ output_tokens: outputTokens,
155
+ };
156
+
157
+ const usageMetadata: GeminiUsageMetadata = {
158
+ promptTokenCount: inputTokens,
159
+ candidatesTokenCount: outputTokens,
160
+ totalTokenCount: inputTokens + outputTokens,
161
+ };
162
+
163
+ return {
164
+ response: {
165
+ candidates: [
166
+ {
167
+ content: {
168
+ parts: [{ text: fullText }],
169
+ role: "model",
170
+ },
171
+ finishReason: "STOP",
172
+ index: 0,
173
+ },
174
+ ],
175
+ usageMetadata,
176
+ modelVersion: model,
177
+ },
178
+ usage,
179
+ responseId,
180
+ };
181
+ }
src/translation/gemini-to-codex.ts ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Translate Google Gemini generateContent request → Codex Responses API request.
3
+ */
4
+
5
+ import { readFileSync } from "fs";
6
+ import { resolve } from "path";
7
+ import type {
8
+ GeminiGenerateContentRequest,
9
+ GeminiContent,
10
+ } from "../types/gemini.js";
11
+ import type {
12
+ CodexResponsesRequest,
13
+ CodexInputItem,
14
+ } from "../proxy/codex-api.js";
15
+ import { resolveModelId, getModelInfo } from "../routes/models.js";
16
+ import { getConfig } from "../config.js";
17
+
18
+ const DESKTOP_CONTEXT = loadDesktopContext();
19
+
20
+ function loadDesktopContext(): string {
21
+ try {
22
+ return readFileSync(
23
+ resolve(process.cwd(), "config/prompts/desktop-context.md"),
24
+ "utf-8",
25
+ );
26
+ } catch {
27
+ return "";
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Map Gemini thinkingBudget to Codex reasoning effort.
33
+ */
34
+ function budgetToEffort(budget?: number): string | undefined {
35
+ if (!budget || budget <= 0) return undefined;
36
+ if (budget < 2000) return "low";
37
+ if (budget < 8000) return "medium";
38
+ if (budget < 20000) return "high";
39
+ return "xhigh";
40
+ }
41
+
42
+ /**
43
+ * Extract text from Gemini content parts.
44
+ */
45
+ function flattenParts(
46
+ parts: Array<{ text?: string; thought?: boolean }>,
47
+ ): string {
48
+ return parts
49
+ .filter((p) => p.text && !p.thought)
50
+ .map((p) => p.text!)
51
+ .join("\n");
52
+ }
53
+
54
+ /**
55
+ * Convert Gemini contents to SessionManager-compatible message format.
56
+ */
57
+ export function geminiContentsToMessages(
58
+ contents: GeminiContent[],
59
+ systemInstruction?: GeminiContent,
60
+ ): Array<{ role: string; content: string }> {
61
+ const messages: Array<{ role: string; content: string }> = [];
62
+
63
+ if (systemInstruction) {
64
+ messages.push({
65
+ role: "system",
66
+ content: flattenParts(systemInstruction.parts),
67
+ });
68
+ }
69
+
70
+ for (const c of contents) {
71
+ const role = c.role === "model" ? "assistant" : c.role ?? "user";
72
+ messages.push({ role, content: flattenParts(c.parts) });
73
+ }
74
+
75
+ return messages;
76
+ }
77
+
78
+ /**
79
+ * Convert a GeminiGenerateContentRequest to a CodexResponsesRequest.
80
+ *
81
+ * Mapping:
82
+ * - systemInstruction → instructions field
83
+ * - contents → input array (role: "model" → "assistant")
84
+ * - model (from URL) → resolved model ID
85
+ * - thinkingConfig → reasoning.effort
86
+ */
87
+ export function translateGeminiToCodexRequest(
88
+ req: GeminiGenerateContentRequest,
89
+ geminiModel: string,
90
+ previousResponseId?: string | null,
91
+ ): CodexResponsesRequest {
92
+ // Extract system instructions
93
+ let userInstructions: string;
94
+ if (req.systemInstruction) {
95
+ userInstructions = flattenParts(req.systemInstruction.parts);
96
+ } else {
97
+ userInstructions = "You are a helpful assistant.";
98
+ }
99
+ const instructions = DESKTOP_CONTEXT
100
+ ? `${DESKTOP_CONTEXT}\n\n${userInstructions}`
101
+ : userInstructions;
102
+
103
+ // Build input items from contents
104
+ const input: CodexInputItem[] = [];
105
+ for (const content of req.contents) {
106
+ const role = content.role === "model" ? "assistant" : "user";
107
+ input.push({
108
+ role: role as "user" | "assistant",
109
+ content: flattenParts(content.parts),
110
+ });
111
+ }
112
+
113
+ // Ensure at least one input message
114
+ if (input.length === 0) {
115
+ input.push({ role: "user", content: "" });
116
+ }
117
+
118
+ // Resolve model
119
+ const modelId = resolveModelId(geminiModel);
120
+ const modelInfo = getModelInfo(modelId);
121
+ const config = getConfig();
122
+
123
+ // Build request
124
+ const request: CodexResponsesRequest = {
125
+ model: modelId,
126
+ instructions,
127
+ input,
128
+ stream: true,
129
+ store: false,
130
+ tools: [],
131
+ };
132
+
133
+ // Add previous response ID for multi-turn conversations
134
+ if (previousResponseId) {
135
+ request.previous_response_id = previousResponseId;
136
+ }
137
+
138
+ // Add reasoning effort: thinkingBudget → model default → config default
139
+ const thinkingEffort = budgetToEffort(
140
+ req.generationConfig?.thinkingConfig?.thinkingBudget,
141
+ );
142
+ const effort =
143
+ thinkingEffort ??
144
+ modelInfo?.defaultReasoningEffort ??
145
+ config.model.default_reasoning_effort;
146
+ if (effort) {
147
+ request.reasoning = { effort };
148
+ }
149
+
150
+ return request;
151
+ }
src/types/anthropic.ts ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Anthropic Messages API types for /v1/messages compatibility
3
+ */
4
+ import { z } from "zod";
5
+
6
+ // --- Request ---
7
+
8
+ const AnthropicTextContentSchema = z.object({
9
+ type: z.literal("text"),
10
+ text: z.string(),
11
+ });
12
+
13
+ const AnthropicImageContentSchema = z.object({
14
+ type: z.literal("image"),
15
+ source: z.object({
16
+ type: z.literal("base64"),
17
+ media_type: z.string(),
18
+ data: z.string(),
19
+ }),
20
+ });
21
+
22
+ const AnthropicContentBlockSchema = z.discriminatedUnion("type", [
23
+ AnthropicTextContentSchema,
24
+ AnthropicImageContentSchema,
25
+ ]);
26
+
27
+ const AnthropicContentSchema = z.union([
28
+ z.string(),
29
+ z.array(AnthropicContentBlockSchema),
30
+ ]);
31
+
32
+ const AnthropicMessageSchema = z.object({
33
+ role: z.enum(["user", "assistant"]),
34
+ content: AnthropicContentSchema,
35
+ });
36
+
37
+ const AnthropicThinkingEnabledSchema = z.object({
38
+ type: z.literal("enabled"),
39
+ budget_tokens: z.number().int().positive(),
40
+ });
41
+
42
+ const AnthropicThinkingDisabledSchema = z.object({
43
+ type: z.literal("disabled"),
44
+ });
45
+
46
+ export const AnthropicMessagesRequestSchema = z.object({
47
+ model: z.string(),
48
+ max_tokens: z.number().int().positive(),
49
+ messages: z.array(AnthropicMessageSchema).min(1),
50
+ system: z
51
+ .union([z.string(), z.array(AnthropicTextContentSchema)])
52
+ .optional(),
53
+ stream: z.boolean().optional().default(false),
54
+ temperature: z.number().optional(),
55
+ top_p: z.number().optional(),
56
+ top_k: z.number().optional(),
57
+ stop_sequences: z.array(z.string()).optional(),
58
+ metadata: z
59
+ .object({
60
+ user_id: z.string().optional(),
61
+ })
62
+ .optional(),
63
+ thinking: z
64
+ .union([AnthropicThinkingEnabledSchema, AnthropicThinkingDisabledSchema])
65
+ .optional(),
66
+ });
67
+
68
+ export type AnthropicMessagesRequest = z.infer<
69
+ typeof AnthropicMessagesRequestSchema
70
+ >;
71
+
72
+ // --- Response ---
73
+
74
+ export interface AnthropicContentBlock {
75
+ type: "text" | "thinking";
76
+ text?: string;
77
+ thinking?: string;
78
+ }
79
+
80
+ export interface AnthropicUsage {
81
+ input_tokens: number;
82
+ output_tokens: number;
83
+ }
84
+
85
+ export interface AnthropicMessagesResponse {
86
+ id: string;
87
+ type: "message";
88
+ role: "assistant";
89
+ content: AnthropicContentBlock[];
90
+ model: string;
91
+ stop_reason: "end_turn" | "max_tokens" | "stop_sequence" | null;
92
+ stop_sequence: string | null;
93
+ usage: AnthropicUsage;
94
+ }
95
+
96
+ // --- Error ---
97
+
98
+ export type AnthropicErrorType =
99
+ | "invalid_request_error"
100
+ | "authentication_error"
101
+ | "permission_error"
102
+ | "not_found_error"
103
+ | "rate_limit_error"
104
+ | "api_error"
105
+ | "overloaded_error";
106
+
107
+ export interface AnthropicErrorBody {
108
+ type: "error";
109
+ error: {
110
+ type: AnthropicErrorType;
111
+ message: string;
112
+ };
113
+ }
src/types/gemini.ts ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Google Gemini API types for generateContent / streamGenerateContent compatibility
3
+ */
4
+ import { z } from "zod";
5
+
6
+ // --- Request ---
7
+
8
+ const GeminiPartSchema = z.object({
9
+ text: z.string().optional(),
10
+ thought: z.boolean().optional(),
11
+ });
12
+
13
+ const GeminiContentSchema = z.object({
14
+ role: z.enum(["user", "model"]).optional(),
15
+ parts: z.array(GeminiPartSchema).min(1),
16
+ });
17
+
18
+ const GeminiThinkingConfigSchema = z.object({
19
+ thinkingBudget: z.number().optional(),
20
+ });
21
+
22
+ const GeminiGenerationConfigSchema = z.object({
23
+ temperature: z.number().optional(),
24
+ topP: z.number().optional(),
25
+ topK: z.number().optional(),
26
+ maxOutputTokens: z.number().optional(),
27
+ stopSequences: z.array(z.string()).optional(),
28
+ thinkingConfig: GeminiThinkingConfigSchema.optional(),
29
+ });
30
+
31
+ export const GeminiGenerateContentRequestSchema = z.object({
32
+ contents: z.array(GeminiContentSchema).min(1),
33
+ systemInstruction: GeminiContentSchema.optional(),
34
+ generationConfig: GeminiGenerationConfigSchema.optional(),
35
+ });
36
+
37
+ export type GeminiGenerateContentRequest = z.infer<
38
+ typeof GeminiGenerateContentRequestSchema
39
+ >;
40
+ export type GeminiContent = z.infer<typeof GeminiContentSchema>;
41
+
42
+ // --- Response ---
43
+
44
+ export interface GeminiCandidate {
45
+ content: {
46
+ parts: Array<{ text: string; thought?: boolean }>;
47
+ role: "model";
48
+ };
49
+ finishReason?: "STOP" | "MAX_TOKENS" | "SAFETY" | "OTHER";
50
+ index: number;
51
+ }
52
+
53
+ export interface GeminiUsageMetadata {
54
+ promptTokenCount: number;
55
+ candidatesTokenCount: number;
56
+ totalTokenCount: number;
57
+ }
58
+
59
+ export interface GeminiGenerateContentResponse {
60
+ candidates: GeminiCandidate[];
61
+ usageMetadata?: GeminiUsageMetadata;
62
+ modelVersion?: string;
63
+ }
64
+
65
+ // --- Error ---
66
+
67
+ export interface GeminiErrorResponse {
68
+ error: {
69
+ code: number;
70
+ message: string;
71
+ status: string;
72
+ };
73
+ }