Spaces:
Paused
Paused
icebear0828 Claude Opus 4.6 commited on
Commit ·
d0eb8b9
1
Parent(s): bd64e44
feat: multi-protocol support — Anthropic Messages API + Google Gemini API
Browse filesAdd 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 +20 -0
- src/index.ts +6 -0
- src/middleware/error-handler.ts +77 -5
- src/routes/gemini.ts +298 -0
- src/routes/messages.ts +229 -0
- src/translation/anthropic-to-codex.ts +128 -0
- src/translation/codex-to-anthropic.ts +191 -0
- src/translation/codex-to-gemini.ts +181 -0
- src/translation/gemini-to-codex.ts +151 -0
- src/types/anthropic.ts +113 -0
- src/types/gemini.ts +73 -0
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
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
+
}
|