From f6f8af6583def28303ac3b6b437953bceccc15f1 Mon Sep 17 00:00:00 2001 From: D8D Developer Date: Fri, 20 Mar 2026 14:53:45 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20OpenAI=20API=20?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 /openai/v1/* 路由代理 OpenAI API - 添加 OPENAI_BASE_URL 环境变量支持 - 更新日志记录支持 provider 字段 使用方式: - Claude API: /v1/* - OpenAI API: /openai/v1/* Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- src/index.ts | 11 +++- src/logger.ts | 3 +- src/proxy-openai.ts | 131 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 src/proxy-openai.ts diff --git a/src/index.ts b/src/index.ts index 342b07a..c07e067 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import 'dotenv/config'; import express from 'express'; import { proxyHandler } from './proxy.js'; +import { openaiProxyHandler } from './proxy-openai.js'; import { healthHandler } from './health.js'; const app = express(); @@ -12,14 +13,18 @@ app.use(express.json({ limit: '50mb' })); // 健康检查端点 app.get('/health', healthHandler); -// 代理所有 /v1/* 路由 +// Claude API 代理路由: /v1/* app.all('/v1/*', proxyHandler); +// OpenAI API 代理路由: /openai/v1/* +app.all('/openai/v1/*', openaiProxyHandler); + // 启动服务器 const server = app.listen(PORT, () => { - console.log(`Claude API Proxy 服务已启动`); + console.log(`API Proxy 服务已启动`); console.log(`端口: ${PORT}`); - console.log(`上游 API: ${process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com'}`); + console.log(`Claude API: ${process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com'}`); + console.log(`OpenAI API: ${process.env.OPENAI_BASE_URL || 'https://api.openai.com'}`); console.log(`健康检查: http://localhost:${PORT}/health`); }); diff --git a/src/logger.ts b/src/logger.ts index f69764f..37640a5 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -10,6 +10,7 @@ export interface LogEntry { duration: number; stream?: boolean; error?: string; + provider?: string; } export function log(entry: LogEntry): void { @@ -26,7 +27,7 @@ export function createLogEntry( path: string, status: number, duration: number, - options?: { stream?: boolean; error?: string } + options?: { stream?: boolean; error?: string; provider?: string } ): LogEntry { return { time: new Date().toISOString(), diff --git a/src/proxy-openai.ts b/src/proxy-openai.ts new file mode 100644 index 0000000..6640fc5 --- /dev/null +++ b/src/proxy-openai.ts @@ -0,0 +1,131 @@ +import { Request, Response } from 'express'; +import axios from 'axios'; +import { log, createLogEntry } from './logger.js'; + +// OpenAI 需要透传的 headers +const OPENAI_PROXY_HEADERS = [ + 'authorization', + 'content-type', + 'accept', + 'openai-organization', + 'openai-beta', +]; + +// 获取 OpenAI 上游 API 基础 URL +function getOpenAIBaseUrl(): string { + return process.env.OPENAI_BASE_URL || 'https://api.openai.com'; +} + +// 提取需要透传的 headers +function extractProxyHeaders(req: Request): Record { + const headers: Record = {}; + for (const header of OPENAI_PROXY_HEADERS) { + const value = req.headers[header]; + if (value) { + headers[header] = Array.isArray(value) ? value[0] : value; + } + } + return headers; +} + +// OpenAI 代理处理函数 +export async function openaiProxyHandler(req: Request, res: Response): Promise { + const startTime = Date.now(); + const method = req.method; + // 移除 /openai 前缀,保留 /v1/... 路径 + const requestPath = req.path.replace(/^\/openai/, ''); + const baseUrl = getOpenAIBaseUrl(); + const targetUrl = `${baseUrl}${requestPath}`; + + // 判断是否为流式请求 + const isStream = req.body?.stream === true; + + // 提取透传 headers + const headers = extractProxyHeaders(req); + + try { + const response = await axios({ + method: method, + url: targetUrl, + data: req.body, + headers, + responseType: isStream ? 'stream' : 'json', + }); + + if (isStream) { + // 流式响应处理 + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + const stream = response.data; + + // 监听客户端断开 + req.on('close', () => { + stream.destroy(); + }); + + // 监听上游流错误 + stream.on('error', (err: Error) => { + console.error('OpenAI 上游流错误:', err.message); + const duration = Date.now() - startTime; + log(createLogEntry(method, requestPath, 500, duration, { stream: true, provider: 'openai', error: err.message })); + if (!res.writableEnded) { + res.end(); + } + }); + + // 管道传输 + stream.pipe(res); + + // 流结束时记录日志 + stream.on('end', () => { + const duration = Date.now() - startTime; + log(createLogEntry(method, requestPath, 200, duration, { stream: true, provider: 'openai' })); + }); + } else { + // 非流式响应处理 + const duration = Date.now() - startTime; + log(createLogEntry(method, requestPath, response.status, duration, { stream: false, provider: 'openai' })); + res.status(response.status).json(response.data); + } + } catch (error: unknown) { + const duration = Date.now() - startTime; + + if (axios.isAxiosError(error)) { + const status = error.response?.status || 500; + const errorData = error.response?.data; + + log(createLogEntry(method, requestPath, status, duration, { + stream: isStream, + provider: 'openai', + error: errorData?.error?.message || error.message, + })); + + if (isStream && error.response?.data) { + res.status(status).json(errorData); + } else { + res.status(status).json(errorData || { + error: { + message: error.message, + type: 'proxy_error', + }, + }); + } + } else { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + log(createLogEntry(method, requestPath, 500, duration, { + stream: isStream, + provider: 'openai', + error: errorMessage, + })); + + res.status(500).json({ + error: { + message: errorMessage, + type: 'internal_error', + }, + }); + } + } +}