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

36
.dockerignore Normal file
View File

@@ -0,0 +1,36 @@
# Dependencies
node_modules
# Build output
dist
# Git
.git
.gitignore
# IDE
.idea
.vscode
*.swp
*.swo
# Environment files
.env
.env.*
!.env.example
# Logs
logs
*.log
/tmp
# OS files
.DS_Store
Thumbs.db
# Documentation
*.md
!README.md
# BMAD output
_bmad-output

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Claude API 代理配置
# 上游 API 基础 URL
# Anthropic 官方: https://api.anthropic.com
# 智谱端点: https://open.bigmodel.cn/api/anthropic
ANTHROPIC_BASE_URL=https://api.anthropic.com
# 服务端口(默认 3000
PORT=3000

View File

@@ -0,0 +1,39 @@
name: Docker Build and Push
on:
push:
branches:
- release/* # 匹配所有 release 开头的分支
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: 检出仓库代码
uses: actions/checkout@v4
- name: 设置 Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 从分支名中提取版本号
id: extract_version
run: |
# 从分支名中提取版本号,例如从 release/v1.0.0 中提取 v1.0.0
VERSION=$(echo "${{ gitea.ref }}" | sed 's|refs/heads/release/||')
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "提取的版本号:$VERSION"
- name: 登录 Docker 注册表
uses: docker/login-action@v3
with:
registry: registry.cn-beijing.aliyuncs.com
username: ${{ secrets.ALI_DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.ALI_DOCKER_REGISTRY_PASSWORD }}
- name: 构建并推送
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: |
registry.cn-beijing.aliyuncs.com/d8dcloud/claude-api-proxy:${{ env.VERSION }}

59
Dockerfile Normal file
View File

@@ -0,0 +1,59 @@
# 构建阶段
FROM registry.cn-beijing.aliyuncs.com/d8dcloud/node:20.19.4-bookworm AS builder
# 设置工作目录
WORKDIR /app
# 配置 npm 国内镜像
RUN npm config set registry https://registry.npmmirror.com/
# 安装 pnpm 并配置国内镜像
RUN npm i -g pnpm && \
pnpm config set registry https://registry.npmmirror.com/
# 复制 package.json 和锁定文件
COPY package.json pnpm-lock.yaml* ./
# 安装所有依赖(包括开发依赖)
RUN pnpm install --frozen-lockfile || pnpm install
# 复制源代码
COPY . .
# 构建项目
RUN pnpm build
# 运行时阶段
FROM registry.cn-beijing.aliyuncs.com/d8dcloud/node:20.19.4-bookworm
# 设置工作目录
WORKDIR /app
# 配置 npm 国内镜像
RUN npm config set registry https://registry.npmmirror.com/
# 安装 pnpm
RUN npm i -g pnpm && \
pnpm config set registry https://registry.npmmirror.com/
# 复制 package.json 和锁定文件
COPY package.json pnpm-lock.yaml* ./
# 只安装生产依赖
RUN pnpm install --prod --frozen-lockfile || pnpm install --prod
# 从构建阶段复制编译产物
COPY --from=builder /app/dist ./dist
# 暴露端口
EXPOSE 3000
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
# 使用非 root 用户运行
USER node
# 启动命令
CMD ["node", "dist/index.js"]

View File

@@ -0,0 +1,242 @@
---
title: 'Claude API 透传代理服务'
slug: 'claude-api-proxy'
created: '2026-03-20T04:30:00.000Z'
status: 'completed'
stepsCompleted: [1, 2, 3, 4, 5, 6]
tech_stack: ['Node.js', 'Express', 'TypeScript', 'axios', 'Docker']
files_to_modify:
- 'package.json'
- 'tsconfig.json'
- 'src/index.ts'
- 'src/proxy.ts'
- 'src/health.ts'
- 'src/logger.ts'
- '.env.example'
- 'Dockerfile'
- '.dockerignore'
- '.gitea/workflows/release.yaml'
code_patterns:
- '流式响应: axios stream + pipe'
- '错误处理: axios error + 统一 JSON 格式'
- '日志: JSONL 格式存储到 /tmp/proxy.jsonl'
- 'Docker: 多阶段构建 + 生产依赖分离'
- 'CI/CD: Gitea Actions + 阿里云镜像仓库'
test_patterns: []
---
# Tech-Spec: Claude API 透传代理服务
**Created:** 2026-03-20T04:30:00.000Z
## Overview
### Problem Statement
D8D 项目有一个 API Key 池,目前直接请求 Claude 官方 API。但官方 API 有 IP 并发限制,单个 IP 的并发数有限。为了绕过这个限制,需要启动多个代理容器实例,每个实例有不同的出口 IP从而分散请求。
### Solution
创建一个纯透传的 Claude API 代理服务:
- 使用 Express.js 构建轻量级代理服务
- 接收什么就转发什么,不做任何业务逻辑处理
- 支持流式streaming和非流式两种输出模式
- 构建为 Docker 镜像,部署到阿里云 ECI (t5-lc2m1.nano: 0.5 CPU, 1G 内存)
- 通过 K8S 管理 Pod 和 Ingress 配置
### Scope
**In Scope:**
- Express.js 代理服务(透传 /v1/messages 端点)
- 支持流式和非流式响应
- Dockerfile 构建配置
- Gitea workflow 自动构建镜像
- K8S 部署配置参考
**Out of Scope:**
- 用户认证和授权
- 余额检查和计费
- API Key 池管理(由 D8D 主项目处理)
- 复杂的日志和监控
## Context for Development
### Codebase Patterns
参考项目:`/mnt/code-fast/d8d-worktrees/merge-epics`
**代理转发参考:**
- `src/server/api/ai/claude/v1/messages/post.ts` - Claude API 调用实现
- 使用 axios 进行 HTTP 请求
- 流式响应使用 TransformStream 处理
**K8S 部署参考:**
- `src/server/utils/k8s.ts` - K8S 客户端封装
- 使用阿里云 ECI (virtual-kubelet)
- 创建 Deployment、Service、Ingress
**Docker 构建参考:**
- `Dockerfile` - 多阶段构建
- `.gitea/workflows/release.yaml` - Gitea Actions 构建流程
### Files to Reference
| File | Purpose |
| ---- | ------- |
| `/mnt/code-fast/d8d-worktrees/merge-epics/src/server/api/ai/claude/v1/messages/post.ts` | Claude API 转发逻辑参考 |
| `/mnt/code-fast/d8d-worktrees/merge-epics/src/server/utils/k8s.ts` | K8S 客户端实现参考 |
| `/mnt/code-fast/d8d-worktrees/merge-epics/Dockerfile` | Docker 构建参考 |
| `/mnt/code-fast/d8d-worktrees/merge-epics/.gitea/workflows/release.yaml` | Gitea workflow 参考 |
### Technical Decisions
- **框架选择**: Express.js用户指定轻量简单
- **语言**: TypeScript类型安全
- **HTTP 客户端**: axios支持流式响应
- **容器规格**: 阿里云 ECI t5-lc2m1.nano (0.5 CPU, 1G 内存)
- **镜像仓库**: registry.cn-beijing.aliyuncs.com/d8dcloud/
- **端口**: 3000
- **API URL**: 环境变量 `ANTHROPIC_BASE_URL`,本地读取 `.env`,容器通过 K8S 注入
- **智谱端点**: `https://open.bigmodel.cn/api/anthropic`
- **路径透传**: 完全透传 `/v1/*` 路径到上游 API
- **健康检查**: 添加 `/health` 端点供 K8S 探针使用
- **Headers 透传**: `x-api-key``authorization``anthropic-version``content-type``accept`
- **超时设置**: 不设置 axios timeout让上游 API 自己处理
- **日志格式**: JSONL 格式存储到 `/tmp/proxy.jsonl`,容器重启自动清理
- **日志内容**: 只记录元信息time, method, path, status, duration, stream, error不记录请求/响应 body
- **请求体大小**: Express body-parser 设置 `limit: '50mb'`
- **包管理器**: pnpm与参考项目一致
- **镜像名称**: `registry.cn-beijing.aliyuncs.com/d8dcloud/claude-api-proxy:${VERSION}`
- **HTTPS**: 依赖 K8S Ingress 处理 TLS代理本身不需要 HTTPS
- **流式错误处理**: 流式传输中若上游报错,关闭客户端连接,记录错误日志
- **启动命令**: `node dist/index.js`
## Implementation Plan
### Tasks
**Task 1: 项目初始化**
- 创建 `package.json` 配置 Express、axios、TypeScript 依赖
- 创建 `tsconfig.json` 配置 TypeScript 编译选项
- 创建 `.env.example` 环境变量示例
**Task 2: 核心代理服务**
- 创建 `src/index.ts` Express 入口,监听端口 3000
- 创建 `src/proxy.ts` 代理逻辑:
- 捕获所有 `/v1/*` 路由
- 透传 headers (x-api-key, authorization, anthropic-version, content-type, accept)
- 透传 body 到 `ANTHROPIC_BASE_URL`
- 流式响应:设置 SSE headers使用 axios stream + pipe
- 非流式响应:直接转发 JSON
- **不设置 axios timeout**,让上游 API 自己处理
- 监听 `req.on('close')` 处理客户端断开
- **流式错误处理**:上游流式报错时关闭客户端连接,记录日志
- 设置 Express body-parser `limit: '50mb'`
**Task 3: 日志服务**
- 创建 `src/logger.ts` 日志工具
- JSONL 格式写入 `/tmp/proxy.jsonl`
- 记录字段time, method, path, status, duration, stream?, error?
- **不记录请求/响应 body**(避免敏感数据泄露)
**Task 4: 健康检查**
- 创建 `src/health.ts` 添加 `/health` 端点
- 返回 `{ status: 'ok', timestamp: ISO时间 }`
**Task 5: Docker 构建配置**
- 创建 `Dockerfile` 多阶段构建
- 构建阶段pnpm 安装依赖 + TypeScript 编译
- 运行阶段:只包含生产依赖 + 编译产物
- 暴露端口 3000
- 启动命令:`CMD ["node", "dist/index.js"]`
- 创建 `.dockerignore` 排除 node_modules、.git、.env 等
**Task 6: CI/CD 工作流**
- 创建 `.gitea/workflows/release.yaml`
- 触发条件push 到 `release/*` 分支
- 步骤:提取版本号 → 登录阿里云镜像仓库 → 构建推送镜像
- 镜像名称:`registry.cn-beijing.aliyuncs.com/d8dcloud/claude-api-proxy:${VERSION}`
### Acceptance Criteria
**AC1: 非流式请求转发**
- Given: 客户端发送 POST `/v1/messages` 请求 (stream: false)
- When: 代理收到请求
- Then: 请求被转发到 `ANTHROPIC_BASE_URL/v1/messages`,响应完整返回
**AC2: 流式请求转发**
- Given: 客户端发送 POST `/v1/messages` 请求 (stream: true)
- When: 代理收到请求
- Then: 设置 SSE headers流式转发响应数据
- And: 若流式过程中上游报错,关闭客户端连接并记录日志
**AC3: 健康检查**
- Given: 客户端请求 GET `/health`
- When: 代理收到请求
- Then: 返回 `{ status: 'ok', timestamp: '...' }` 状态码 200
**AC4: Docker 镜像构建**
- Given: 执行 `docker build`
- When: 构建完成
- Then: 镜像大小 < 200MB能正常启动并监听 3000 端口
**AC5: Headers 透传**
- Given: 请求包含 `x-api-key``authorization``anthropic-version` headers
- When: 代理转发请求
- Then: 这些 headers 被完整传递到上游 API
**AC6: 错误透传**
- Given: 上游 API 返回错误响应 400/401/429/500
- When: 代理收到错误响应
- Then: 透传错误状态码和响应 body 给客户端
## Additional Context
### Dependencies
**生产依赖:**
- express - Web 框架
- axios - HTTP 客户端支持流式
- dotenv - 环境变量加载
**开发依赖:**
- typescript - TypeScript 编译器
- @types/express - Express 类型定义
- @types/node - Node.js 类型定义
- tsx - TypeScript 执行器支持热重载
- tsc-alias - TypeScript 路径别名处理
### Testing Strategy
- 手动测试流式和非流式响应
- 使用 curl 测试代理功能
### Notes
- 代理服务需要完全透传包括 headers body
- 流式响应需要正确处理 SSE (Server-Sent Events) 格式
- API Key 通过请求头 x-api-key Authorization 传递
- **超时策略**不设置 axios timeoutAI 请求可能耗时很长
- **日志清理**存储在 /tmp 目录容器重启自动清空不会累积
- **安全考虑**日志不记录请求/响应 body避免敏感数据泄露
- **HTTPS/TLS**代理本身不处理 HTTPS完全依赖 K8S Ingress 处理 TLS
- **流式错误处理**流式传输中上游报错时直接关闭客户端连接
- **镜像命名**`registry.cn-beijing.aliyuncs.com/d8dcloud/claude-api-proxy:${VERSION}`
## Review Notes
- 对抗性审查已完成 (2026-03-20)
- 发现总数: 12 (2 严重, 4 , 4 , 2 )
- 修复数量: 3 (F1, F3, F4)
- 跳过数量: 9 (经专家讨论确认合理)
- 解决方案: 派对模式专家讨论 (Winston, Amelia, Barry, Quinn)
**已修复:**
- F1: Docker 添加 USER node HEALTHCHECK
- F3: 添加 SIGTERM/SIGINT 优雅关闭处理
- F4: 流式错误处理添加 writableEnded 检查
**已跳过 (合理原因):**
- F2: 同步日志对轻量代理可接受
- F5: 透传代理不应验证请求体
- F6-F12: 低影响或过度优化

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "claude-api-proxy",
"version": "1.0.0",
"description": "Claude API 透明代理服务",
"main": "dist/index.js",
"scripts": {
"build": "tsc && tsc-alias",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts"
},
"keywords": [
"claude",
"api",
"proxy"
],
"author": "",
"license": "MIT",
"dependencies": {
"axios": "^1.7.9",
"dotenv": "^16.4.7",
"express": "^4.21.2"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.10.5",
"tsc-alias": "^1.8.10",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

1340
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

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',
},
});
}
}
}

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}