codex-proxy / src /routes /gemini.ts
icebear
feat: tuple schema (prefixItems) support for structured outputs (#77)
e25a730 unverified
raw
history blame
5.33 kB
/**
* Google Gemini API route handler.
* POST /v1beta/models/{model}:generateContent β€” non-streaming
* POST /v1beta/models/{model}:streamGenerateContent β€” streaming
*/
import { Hono } from "hono";
import type { StatusCode } from "hono/utils/http-status";
import type { GeminiErrorResponse } from "../types/gemini.js";
import { GEMINI_STATUS_MAP } from "../types/gemini.js";
import { GeminiGenerateContentRequestSchema } from "../types/gemini.js";
import type { AccountPool } from "../auth/account-pool.js";
import type { CookieJar } from "../proxy/cookie-jar.js";
import type { ProxyPool } from "../proxy/proxy-pool.js";
import {
translateGeminiToCodexRequest,
} from "../translation/gemini-to-codex.js";
import {
streamCodexToGemini,
collectCodexToGeminiResponse,
} from "../translation/codex-to-gemini.js";
import { getConfig } from "../config.js";
import { getModelCatalog } from "../models/model-store.js";
import {
handleProxyRequest,
type FormatAdapter,
} from "./shared/proxy-handler.js";
function makeError(
code: number,
message: string,
status?: string,
): GeminiErrorResponse {
return {
error: {
code,
message,
status: status ?? GEMINI_STATUS_MAP[code] ?? "INTERNAL",
},
};
}
/**
* Parse model name and action from the URL param.
* e.g. "gemini-2.5-pro:generateContent" β†’ { model: "gemini-2.5-pro", action: "generateContent" }
*/
function parseModelAction(param: string): {
model: string;
action: string;
} | null {
const lastColon = param.lastIndexOf(":");
if (lastColon <= 0) return null;
return {
model: param.slice(0, lastColon),
action: param.slice(lastColon + 1),
};
}
const GEMINI_FORMAT: FormatAdapter = {
tag: "Gemini",
noAccountStatus: 503,
formatNoAccount: () =>
makeError(
503,
"No available accounts. All accounts are expired or rate-limited.",
"UNAVAILABLE",
),
format429: (msg) => makeError(429, msg, "RESOURCE_EXHAUSTED"),
formatError: (status, msg) => makeError(status, msg),
streamTranslator: (api, response, model, onUsage, onResponseId, tupleSchema) =>
streamCodexToGemini(api, response, model, onUsage, onResponseId, tupleSchema),
collectTranslator: (api, response, model, tupleSchema) =>
collectCodexToGeminiResponse(api, response, model, tupleSchema),
};
export function createGeminiRoutes(
accountPool: AccountPool,
cookieJar?: CookieJar,
proxyPool?: ProxyPool,
): Hono {
const app = new Hono();
// Handle both generateContent and streamGenerateContent
app.post("/v1beta/models/:modelAction", async (c) => {
const modelActionParam = c.req.param("modelAction");
const parsed = parseModelAction(modelActionParam);
if (
!parsed ||
(parsed.action !== "generateContent" &&
parsed.action !== "streamGenerateContent")
) {
c.status(400);
return c.json(
makeError(
400,
`Invalid action. Expected :generateContent or :streamGenerateContent, got: ${modelActionParam}`,
),
);
}
const { model: geminiModel, action } = parsed;
const isStreaming =
action === "streamGenerateContent" ||
c.req.query("alt") === "sse";
// Auth check
if (!accountPool.isAuthenticated()) {
c.status(401);
return c.json(
makeError(401, "Not authenticated. Please login first at /"),
);
}
// API key check: query param ?key= or header x-goog-api-key
const config = getConfig();
if (config.server.proxy_api_key) {
const queryKey = c.req.query("key");
const headerKey = c.req.header("x-goog-api-key");
const authHeader = c.req.header("Authorization");
const bearerKey = authHeader?.replace("Bearer ", "");
const providedKey = queryKey ?? headerKey ?? bearerKey;
if (!providedKey || !accountPool.validateProxyApiKey(providedKey)) {
c.status(401);
return c.json(makeError(401, "Invalid API key"));
}
}
// Parse request
let body: unknown;
try {
body = await c.req.json();
} catch {
c.status(400);
return c.json(makeError(400, "Invalid JSON in request body"));
}
const validationResult = GeminiGenerateContentRequestSchema.safeParse(body);
if (!validationResult.success) {
c.status(400);
return c.json(
makeError(400, `Invalid request: ${validationResult.error.message}`),
);
}
const req = validationResult.data;
const { codexRequest, tupleSchema } = translateGeminiToCodexRequest(
req,
geminiModel,
);
console.log(
`[Gemini] Model: ${geminiModel} β†’ ${codexRequest.model}`,
);
return handleProxyRequest(
c,
accountPool,
cookieJar,
{
codexRequest,
model: geminiModel,
isStreaming,
tupleSchema,
},
GEMINI_FORMAT,
proxyPool,
);
});
// List available models (Gemini format)
app.get("/v1beta/models", (c) => {
const catalog = getModelCatalog();
const models = catalog.map((m) => ({
name: `models/${m.id}`,
displayName: m.displayName,
description: m.description,
supportedGenerationMethods: [
"generateContent",
"streamGenerateContent",
],
}));
return c.json({ models });
});
return app;
}