feat: 实现 Claude API 透传代理服务
实现完整的 Claude API 代理服务,支持: - 透传 /v1/* 路径到上游 API - 流式 (SSE) 和非流式响应 - JSONL 格式请求日志 - /health 健康检查端点 - Docker 多阶段构建 - Gitea Actions CI/CD 工作流 安全加固: - 容器以非 root 用户运行 - 添加 Docker HEALTHCHECK - 实现优雅关闭处理 (SIGTERM/SIGINT) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
14
src/health.ts
Normal file
14
src/health.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
export interface HealthResponse {
|
||||
status: 'ok';
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export function healthHandler(_req: Request, res: Response): void {
|
||||
const response: HealthResponse = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
res.status(200).json(response);
|
||||
}
|
||||
49
src/index.ts
Normal file
49
src/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import { proxyHandler } from './proxy.js';
|
||||
import { healthHandler } from './health.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
|
||||
// 解析 JSON 请求体,设置较大的限制以支持大请求
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
|
||||
// 健康检查端点
|
||||
app.get('/health', healthHandler);
|
||||
|
||||
// 代理所有 /v1/* 路由
|
||||
app.all('/v1/*', proxyHandler);
|
||||
|
||||
// 启动服务器
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`Claude API Proxy 服务已启动`);
|
||||
console.log(`端口: ${PORT}`);
|
||||
console.log(`上游 API: ${process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com'}`);
|
||||
console.log(`健康检查: http://localhost:${PORT}/health`);
|
||||
});
|
||||
|
||||
// 优雅关闭处理
|
||||
let isShuttingDown = false;
|
||||
|
||||
function gracefulShutdown(signal: string) {
|
||||
if (isShuttingDown) return;
|
||||
isShuttingDown = true;
|
||||
|
||||
console.log(`\n收到 ${signal} 信号,开始优雅关闭...`);
|
||||
|
||||
// 停止接受新连接
|
||||
server.close(() => {
|
||||
console.log('服务器已关闭');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// 强制退出超时(K8S 默认 30 秒宽限期)
|
||||
setTimeout(() => {
|
||||
console.log('优雅关闭超时,强制退出');
|
||||
process.exit(1);
|
||||
}, 25000);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
39
src/logger.ts
Normal file
39
src/logger.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import fs from 'node:fs';
|
||||
|
||||
const LOG_FILE = '/tmp/proxy.jsonl';
|
||||
|
||||
export interface LogEntry {
|
||||
time: string;
|
||||
method: string;
|
||||
path: string;
|
||||
status: number;
|
||||
duration: number;
|
||||
stream?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function log(entry: LogEntry): void {
|
||||
try {
|
||||
const logLine = JSON.stringify(entry) + '\n';
|
||||
fs.appendFileSync(LOG_FILE, logLine, 'utf-8');
|
||||
} catch (e) {
|
||||
console.error('写入日志失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function createLogEntry(
|
||||
method: string,
|
||||
path: string,
|
||||
status: number,
|
||||
duration: number,
|
||||
options?: { stream?: boolean; error?: string }
|
||||
): LogEntry {
|
||||
return {
|
||||
time: new Date().toISOString(),
|
||||
method,
|
||||
path,
|
||||
status,
|
||||
duration,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
132
src/proxy.ts
Normal file
132
src/proxy.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Request, Response } from 'express';
|
||||
import axios from 'axios';
|
||||
import { log, createLogEntry } from './logger.js';
|
||||
|
||||
// 需要透传的 headers
|
||||
const PROXY_HEADERS = [
|
||||
'x-api-key',
|
||||
'authorization',
|
||||
'anthropic-version',
|
||||
'content-type',
|
||||
'accept',
|
||||
];
|
||||
|
||||
// 获取上游 API 基础 URL
|
||||
function getBaseUrl(): string {
|
||||
return process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com';
|
||||
}
|
||||
|
||||
// 提取需要透传的 headers
|
||||
function extractProxyHeaders(req: Request): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const header of PROXY_HEADERS) {
|
||||
const value = req.headers[header];
|
||||
if (value) {
|
||||
headers[header] = Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
// 代理处理函数
|
||||
export async function proxyHandler(req: Request, res: Response): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
const method = req.method;
|
||||
const requestPath = req.path;
|
||||
const baseUrl = getBaseUrl();
|
||||
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',
|
||||
// 不设置 timeout,让上游 API 自己处理
|
||||
});
|
||||
|
||||
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('上游流错误:', err.message);
|
||||
const duration = Date.now() - startTime;
|
||||
log(createLogEntry(method, requestPath, 500, duration, { stream: true, 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 }));
|
||||
});
|
||||
} else {
|
||||
// 非流式响应处理
|
||||
const duration = Date.now() - startTime;
|
||||
log(createLogEntry(method, requestPath, response.status, duration, { stream: false }));
|
||||
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,
|
||||
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,
|
||||
error: errorMessage,
|
||||
}));
|
||||
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: 'internal_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user