| |
| |
| |
| |
| |
| |
| |
| |
| |
| import { createServer, type Server } from "node:http"; |
| import { Readable } from "node:stream"; |
| import { timingSafeEqual } from "node:crypto"; |
| import webuiHtml from "./webui.txt" with { type: "text" }; |
| import type { ProxyConfig } from "../config/types.js"; |
| import type { AuthManager } from "../auth/manager.js"; |
| import { handleChatCompletions, handleListModels } from "./routes-openai.js"; |
| import { handleMessages } from "./routes-anthropic.js"; |
| import { handleResponsesRoute } from "./routes-responses.js"; |
| import { handleAsyncMessagesRoute, handleAsyncChatRoute, handleAsyncHealthRoute } from "./routes-async.js"; |
| import { handleQuota } from "./routes-quota.js"; |
| import { errorResponse } from "../proxy/handler.js"; |
| import type { ResponseStore } from "../responses/store.js"; |
|
|
| interface ServerOptions { |
| config: ProxyConfig; |
| auth: AuthManager; |
| |
| fetchImpl?: typeof fetch; |
| |
| debug?: boolean; |
| |
| responseStore?: ResponseStore; |
| } |
|
|
| |
| export interface ProxyServer { |
| hostname: string; |
| port: number; |
| |
| stop(exit?: boolean): void; |
| |
| close(): Promise<void>; |
| } |
|
|
| |
| export function createFetchHandler(opts: ServerOptions): (req: Request) => Promise<Response> { |
| const { config, auth } = opts; |
| const proxyOpts = { config, auth, fetchImpl: opts.fetchImpl, debug: opts.debug === true }; |
| const responsesOpts = { |
| config, |
| auth, |
| fetchImpl: opts.fetchImpl, |
| debug: opts.debug === true, |
| ...(opts.responseStore ? { responseStore: opts.responseStore } : {}), |
| }; |
| const asyncOpts = { |
| config, |
| auth, |
| fetchImpl: opts.fetchImpl, |
| debug: opts.debug === true, |
| }; |
|
|
| return async (req: Request): Promise<Response> => { |
| const url = new URL(req.url); |
| const path = url.pathname; |
| const method = req.method; |
|
|
| |
| if (method === "OPTIONS") { |
| return corsResponse(); |
| } |
|
|
| if (method === "GET" && (path === "/webui" || path.startsWith("/webui/"))) { |
| return new Response(webuiHtml, { |
| status: 200, |
| headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache" }, |
| }); |
| } |
|
|
| if (config.auth.proxyApiKey) { |
| const authHeader = req.headers.get("authorization") ?? req.headers.get("x-api-key"); |
| if (!authHeader || !checkProxyKey(authHeader, config.auth.proxyApiKey)) { |
| return errorResponse(401, "authentication_error", "Invalid or missing proxy API key"); |
| } |
| } |
|
|
| |
|
|
| if (path === "/v1/chat/completions" && method === "POST") { |
| return handleChatCompletions(req, proxyOpts); |
| } |
| if (config.responses.enabled && path === "/v1/responses" && method === "POST") { |
| return handleResponsesRoute(req, responsesOpts); |
| } |
| if (path === "/v1/models" && method === "GET") { |
| return handleListModels(req); |
| } |
|
|
| if (path === "/quota" && method === "GET") { |
| return handleQuota(config, opts.fetchImpl); |
| } |
|
|
| if (path === "/v1/messages" && method === "POST") { |
| return handleMessages(req, proxyOpts); |
| } |
|
|
| if (config.async.enabled) { |
| |
| |
| const isAsyncRoute = |
| (path === "/async/v1/messages" && method === "POST") || |
| (path === "/async/v1/chat/completions" && method === "POST") || |
| (path === "/async/v1/health" && method === "GET"); |
| if (isAsyncRoute && config.plan !== "coding-plan") { |
| return errorResponse( |
| 400, |
| "async_plan_unsupported", |
| `async (off-peak) endpoints are only available with plan "coding-plan" (current plan: ${config.plan})`, |
| ); |
| } |
| if (path === "/async/v1/messages" && method === "POST") { |
| return handleAsyncMessagesRoute(req, asyncOpts); |
| } |
| if (path === "/async/v1/chat/completions" && method === "POST") { |
| return handleAsyncChatRoute(req, asyncOpts); |
| } |
| if (path === "/async/v1/health" && method === "GET") { |
| return handleAsyncHealthRoute(req, asyncOpts); |
| } |
| } |
|
|
| if (path === "/health" || path === "/") { |
| return new Response(JSON.stringify({ status: "ok", provider: config.provider }), { |
| status: 200, |
| headers: { "content-type": "application/json" }, |
| }); |
| } |
|
|
| return errorResponse(404, "not_found_error", `No route for ${method} ${path}`); |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function startServer(opts: ServerOptions): Promise<ProxyServer> { |
| const handler = createFetchHandler(opts); |
| const { port: requestedPort, host } = opts.config.server; |
|
|
| const server: Server = createServer(async (req, res) => { |
| const abortController = new AbortController(); |
| const onClientClose = (): void => { |
| if (!res.writableEnded) abortController.abort(); |
| }; |
| res.on("close", onClientClose); |
|
|
| |
| |
| |
| |
| |
| if ((req.url ?? "").startsWith("/async/")) { |
| req.setTimeout(24 * 60 * 60 * 1000); |
| } |
|
|
| try { |
| const webReq = nodeReqToWebRequest(req, abortController.signal); |
| const resp = await handler(webReq).then((r) => addCorsHeaders(r)); |
| await writeWebResponseToNodeResp(resp, res, abortController.signal); |
| } catch (err) { |
| if (abortController.signal.aborted) return; |
| if (!res.headersSent) { |
| res.writeHead(500, { "content-type": "application/json" }); |
| res.end(JSON.stringify({ error: { type: "internal_error", message: (err as Error).message } })); |
| } else { |
| try { res.end(); } catch {} |
| } |
| } |
| }); |
|
|
| |
| |
| |
| server.requestTimeout = 600_000; |
| server.keepAliveTimeout = 120_000; |
| server.headersTimeout = 600_000; |
|
|
| return new Promise<ProxyServer>((resolve, reject) => { |
| server.on("error", reject); |
| server.listen(requestedPort, host, () => { |
| const addr = server.address(); |
| const actualPort = typeof addr === "object" && addr ? addr.port : requestedPort; |
| resolve({ |
| hostname: host, |
| port: actualPort, |
| stop: (exit) => { |
| server.close(); |
| if (exit) process.exit(0); |
| }, |
| close: () => new Promise<void>((r) => server.close(() => r())), |
| }); |
| }); |
| }); |
| } |
|
|
| |
| function nodeReqToWebRequest(req: import("node:http").IncomingMessage, signal?: AbortSignal): Request { |
| const headers = new Headers(); |
| for (const [key, val] of Object.entries(req.headers)) { |
| if (val == null) continue; |
| if (Array.isArray(val)) { |
| for (const v of val) headers.append(key, v); |
| } else { |
| headers.set(key, val); |
| } |
| } |
| const host = headers.get("host") ?? "localhost"; |
| const url = `http://${host}${req.url ?? "/"}`; |
| const method = req.method ?? "GET"; |
|
|
| if (method === "GET" || method === "HEAD") { |
| return new Request(url, { method, headers, signal }); |
| } |
|
|
| |
| const bodyStream = Readable.toWeb(req) as unknown as ReadableStream<Uint8Array>; |
| const init: RequestInit & { duplex?: "half" } = { |
| method, |
| headers, |
| body: bodyStream, |
| duplex: "half", |
| signal, |
| }; |
| return new Request(url, init); |
| } |
|
|
| |
| async function writeWebResponseToNodeResp(resp: Response, res: import("node:http").ServerResponse, abortSignal?: AbortSignal): Promise<void> { |
| const headers: Record<string, string | string[]> = {}; |
| resp.headers.forEach((value, key) => { |
| const existing = headers[key]; |
| if (existing === undefined) { |
| headers[key] = value; |
| } else if (typeof existing === "string") { |
| headers[key] = [existing, value]; |
| } else { |
| existing.push(value); |
| } |
| }); |
|
|
| res.writeHead(resp.status, resp.statusText, headers); |
|
|
| if (resp.body == null) { |
| res.end(); |
| return; |
| } |
|
|
| const reader = resp.body.getReader(); |
| const onAbort = (): void => { reader.cancel().catch(() => {}); }; |
| abortSignal?.addEventListener("abort", onAbort); |
| try { |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| if (!res.write(Buffer.from(value))) { |
| await new Promise<void>((resolve) => res.once("drain", () => resolve())); |
| } |
| } |
| res.end(); |
| } catch (err) { |
| if (abortSignal?.aborted) { |
| try { res.end(); } catch {} |
| } else { |
| try { res.destroy(err as Error); } catch {} |
| } |
| } finally { |
| abortSignal?.removeEventListener("abort", onAbort); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function checkProxyKey(authHeader: string, expected: string): boolean { |
| const trimmed = authHeader.trim(); |
| const presented = trimmed.startsWith("Bearer ") ? trimmed.slice(7).trim() : trimmed; |
| const a = Buffer.from(presented, "utf-8"); |
| const b = Buffer.from(expected, "utf-8"); |
| if (a.length !== b.length) return false; |
| return timingSafeEqual(a, b); |
| } |
|
|
| |
| function corsResponse(): Response { |
| return new Response(null, { |
| status: 204, |
| headers: corsHeaders(), |
| }); |
| } |
|
|
| |
| function addCorsHeaders(resp: Response): Response { |
| const headers = new Headers(resp.headers); |
| for (const [k, v] of Object.entries(corsHeaders())) { |
| headers.set(k, v); |
| } |
| return new Response(resp.body, { |
| status: resp.status, |
| statusText: resp.statusText, |
| headers, |
| }); |
| } |
|
|
| function corsHeaders(): Record<string, string> { |
| return { |
| "access-control-allow-origin": "*", |
| "access-control-allow-methods": "GET, POST, OPTIONS", |
| "access-control-allow-headers": "Content-Type, Authorization, x-api-key, anthropic-version, anthropic-beta", |
| "access-control-max-age": "86400", |
| }; |
| } |
|
|