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:
36
.dockerignore
Normal file
36
.dockerignore
Normal 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
9
.env.example
Normal 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
|
||||
39
.gitea/workflows/release.yaml
Normal file
39
.gitea/workflows/release.yaml
Normal 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
59
Dockerfile
Normal 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"]
|
||||
@@ -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 timeout,AI 请求可能耗时很长
|
||||
- **日志清理**:存储在 /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
30
package.json
Normal 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
1340
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user