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:
D8D Developer
2026-03-20 06:47:46 +00:00
parent 569a79272a
commit fafef0e034
12 changed files with 2009 additions and 0 deletions

14
src/health.ts Normal file
View 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
View 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
View 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
View 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',
},
});
}
}
}