Repository: BenedictKing/claude-proxy Branch: main Commit: 330011372f6c Files: 152 Total size: 1.2 MB Directory structure: gitextract_1jsidgpo/ ├── .claudeignore ├── .dockerignore ├── .gitattributes ├── .github/ │ └── workflows/ │ ├── docker-build.yml │ ├── release-linux.yml │ ├── release-macos.yml │ └── release-windows.yml ├── .gitignore ├── .prettierrc ├── AGENTS.md ├── ARCHITECTURE.md ├── CHANGELOG.md ├── CLAUDE.md ├── CONTRIBUTING.md ├── DEVELOPMENT.md ├── Dockerfile ├── Dockerfile_China ├── ENVIRONMENT.md ├── LICENSE ├── Makefile ├── README.md ├── RELEASE.md ├── VERSION ├── backend-go/ │ ├── .air.toml │ ├── .env.example │ ├── .gitignore │ ├── CLAUDE.md │ ├── DEV_GUIDE.md │ ├── Makefile │ ├── README.md │ ├── build.sh │ ├── docs/ │ │ └── MALFORMED_TOOLCALL_MEMO.md │ ├── go.mod │ ├── go.sum │ ├── internal/ │ │ ├── config/ │ │ │ ├── config.go │ │ │ ├── config_baseurl_test.go │ │ │ ├── config_gemini.go │ │ │ ├── config_loader.go │ │ │ ├── config_messages.go │ │ │ ├── config_responses.go │ │ │ ├── config_utils.go │ │ │ └── env.go │ │ ├── converters/ │ │ │ ├── chat_to_responses.go │ │ │ ├── chat_to_responses_test.go │ │ │ ├── claude_converter.go │ │ │ ├── converter.go │ │ │ ├── converter_test.go │ │ │ ├── factory.go │ │ │ ├── gemini_converter.go │ │ │ ├── gemini_converter_test.go │ │ │ ├── openai_converter.go │ │ │ ├── responses_converter.go │ │ │ ├── responses_passthrough.go │ │ │ └── responses_to_chat.go │ │ ├── handlers/ │ │ │ ├── channel_metrics_handler.go │ │ │ ├── common/ │ │ │ │ ├── client_error_test.go │ │ │ │ ├── failover.go │ │ │ │ ├── failover_test.go │ │ │ │ ├── multi_channel_failover.go │ │ │ │ ├── request.go │ │ │ │ ├── stream.go │ │ │ │ ├── stream_test.go │ │ │ │ └── upstream_failover.go │ │ │ ├── frontend.go │ │ │ ├── gemini/ │ │ │ │ ├── channels.go │ │ │ │ ├── dashboard.go │ │ │ │ ├── dashboard_test.go │ │ │ │ ├── handler.go │ │ │ │ ├── handler_test.go │ │ │ │ └── stream.go │ │ │ ├── global_stats_handler.go │ │ │ ├── health.go │ │ │ ├── messages/ │ │ │ │ ├── channels.go │ │ │ │ ├── handler.go │ │ │ │ └── models.go │ │ │ ├── responses/ │ │ │ │ ├── channels.go │ │ │ │ ├── compact.go │ │ │ │ └── handler.go │ │ │ └── settings.go │ │ ├── httpclient/ │ │ │ └── client.go │ │ ├── logger/ │ │ │ └── logger.go │ │ ├── metrics/ │ │ │ ├── channel_metrics.go │ │ │ ├── channel_metrics_activity_test.go │ │ │ ├── channel_metrics_cache_stats_test.go │ │ │ ├── persistence.go │ │ │ └── sqlite_store.go │ │ ├── middleware/ │ │ │ ├── auth.go │ │ │ ├── auth_test.go │ │ │ ├── cors.go │ │ │ └── logger.go │ │ ├── providers/ │ │ │ ├── claude.go │ │ │ ├── gemini.go │ │ │ ├── openai.go │ │ │ ├── provider.go │ │ │ ├── request_context_test.go │ │ │ ├── responses.go │ │ │ └── url_builder_test.go │ │ ├── scheduler/ │ │ │ ├── channel_scheduler.go │ │ │ └── channel_scheduler_test.go │ │ ├── session/ │ │ │ ├── manager.go │ │ │ └── trace_affinity.go │ │ ├── types/ │ │ │ ├── gemini.go │ │ │ ├── gemini_test.go │ │ │ ├── responses.go │ │ │ └── types.go │ │ ├── utils/ │ │ │ ├── compression.go │ │ │ ├── headers.go │ │ │ ├── headers_test.go │ │ │ ├── json.go │ │ │ ├── json_compact_test.go │ │ │ ├── json_test.go │ │ │ ├── stream_synthesizer.go │ │ │ ├── token_counter.go │ │ │ └── token_counter_test.go │ │ └── warmup/ │ │ └── url_manager.go │ ├── main.go │ └── version.go ├── docker-compose.yml └── frontend/ ├── .env.example ├── CLAUDE.md ├── ESLINT.md ├── eslint.config.js ├── index.html ├── package.json ├── src/ │ ├── App.vue │ ├── assets/ │ │ └── style.css │ ├── components/ │ │ ├── AddChannelModal.vue │ │ ├── ChannelCard.vue │ │ ├── ChannelMetricsChart.vue │ │ ├── ChannelOrchestration.vue │ │ ├── ChannelStatusBadge.vue │ │ ├── GlobalStatsChart.vue │ │ └── KeyTrendChart.vue │ ├── composables/ │ │ └── useTheme.ts │ ├── env.d.ts │ ├── main.ts │ ├── plugins/ │ │ └── vuetify.ts │ ├── router/ │ │ └── index.ts │ ├── services/ │ │ ├── api.ts │ │ └── version.ts │ ├── stores/ │ │ ├── auth.ts │ │ ├── channel.ts │ │ ├── dialog.ts │ │ ├── index.ts │ │ ├── preferences.ts │ │ └── system.ts │ ├── styles/ │ │ └── settings.scss │ ├── utils/ │ │ ├── quickInputParser.test.ts │ │ └── quickInputParser.ts │ └── views/ │ └── ChannelsView.vue ├── tsconfig.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .claudeignore ================================================ # 忽略常规依赖 **/node_modules **/frontend/node_modules **/dist **/.git **/.next **/.vercel # 忽略构建产物和二进制 **/target **/bin **/obj *.mmdb *.pack *.bin *.rlib *.exe # 忽略日志 **/logs *.log *.log.gz # 忽略外部引用 refs/ ================================================ FILE: .dockerignore ================================================ # Git 相关 .git .gitignore .github # Node 模块 node_modules frontend/node_modules backend/node_modules # 构建产物(会在 Docker 中重新构建) frontend/dist backend/dist backend-go/dist backend-go/frontend/dist dist # 日志和缓存 *.log logs .cache .vite frontend/node_modules/.vite # 开发工具 .vscode .idea *.swp *.swo *~ # 环境配置 .env .env.local .env.*.local backend/.env backend-go/.env # 配置文件(运行时生成) .config backend-go/.config # 测试和覆盖率 coverage *.coverprofile coverage.out coverage.html # 临时文件 tmp *.tmp .DS_Store Thumbs.db # Aider 历史文件 .aider* # Lock 文件保留 !bun.lock !package-lock.json !go.sum ================================================ FILE: .gitattributes ================================================ # Bun lockfile - 配置 Git 以显示可读的差异 # bun.lockb (二进制格式) - 需要 textconv 转换 *.lockb binary diff=lockb # bun.lock (文本格式) - 直接作为文本处理 bun.lock text diff # 确保跨平台行尾符一致性 * text=auto eol=lf # 明确标记文本文件 *.go text diff=golang *.js text *.ts text *.vue text *.json text *.md text *.yaml text *.yml text *.toml text *.sh text eol=lf # 明确标记二进制文件 *.png binary *.jpg binary *.jpeg binary *.gif binary *.ico binary *.woff binary *.woff2 binary *.ttf binary *.eot binary ================================================ FILE: .github/workflows/docker-build.yml ================================================ name: Build Docker Image on: push: tags: - 'v*' env: REGISTRY: crpi-i19l8zl0ugidq97v.cn-hangzhou.personal.cr.aliyuncs.com IMAGE_NAME: bene/claude-proxy jobs: build_docker_image: name: Build Docker Image runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Aliyun Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ secrets.ALIYUN_CR_USERNAME }} password: ${{ secrets.ALIYUN_CR_PASSWORD }} - name: Extract metadata id: meta uses: docker/metadata-action@v5 with: images: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} flavor: | latest=false tags: | type=ref,event=tag type=raw,value=latest,enable=${{ !contains(github.ref, '-') }} - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: . file: Dockerfile platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | VERSION=${{ github.ref_name }} ================================================ FILE: .github/workflows/release-linux.yml ================================================ name: Release Linux Build permissions: contents: write on: push: tags: - "v*" concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false jobs: release: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "20.x" - name: Setup Bun uses: oven-sh/setup-bun@v2 - name: Build Frontend run: bun install && bun run build working-directory: ./frontend - name: Copy Frontend to Backend run: | rm -rf backend-go/frontend/dist mkdir -p backend-go/frontend/dist cp -r frontend/dist/* backend-go/frontend/dist/ - name: Setup Go uses: actions/setup-go@v5 with: go-version: "1.22.x" - name: Build Backend for amd64 run: | go mod tidy go mod download CGO_ENABLED=0 go build -ldflags "-s -w -X main.Version=${{ github.ref_name }} -X main.BuildTime=$(date -u '+%Y-%m-%d_%H:%M:%S') -X main.GitCommit=${{ github.sha }}" -o claude-proxy-linux-amd64 working-directory: ./backend-go - name: Build Backend for arm64 run: | CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X main.Version=${{ github.ref_name }} -X main.BuildTime=$(date -u '+%Y-%m-%d_%H:%M:%S') -X main.GitCommit=${{ github.sha }}" -o claude-proxy-linux-arm64 working-directory: ./backend-go - name: Release uses: softprops/action-gh-release@v2.0.5 if: startsWith(github.ref, 'refs/tags/') with: files: | backend-go/claude-proxy-linux-amd64 backend-go/claude-proxy-linux-arm64 draft: true generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/release-macos.yml ================================================ name: Release MacOS Build permissions: contents: write on: push: tags: - "v*" concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false jobs: release: runs-on: macos-latest steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "20.x" - name: Setup Bun uses: oven-sh/setup-bun@v2 - name: Build Frontend run: bun install && bun run build working-directory: ./frontend - name: Copy Frontend to Backend run: | rm -rf backend-go/frontend/dist mkdir -p backend-go/frontend/dist cp -r frontend/dist/* backend-go/frontend/dist/ - name: Setup Go uses: actions/setup-go@v5 with: go-version: "1.22.x" - name: Build Backend arm64 run: | go mod tidy go mod download CGO_ENABLED=0 go build -ldflags "-s -w -X main.Version=${{ github.ref_name }} -X main.BuildTime=$(date -u '+%Y-%m-%d_%H:%M:%S') -X main.GitCommit=${{ github.sha }}" -o claude-proxy-darwin-arm64 working-directory: ./backend-go - name: Build Backend amd64 run: | CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w -X main.Version=${{ github.ref_name }} -X main.BuildTime=$(date -u '+%Y-%m-%d_%H:%M:%S') -X main.GitCommit=${{ github.sha }}" -o claude-proxy-darwin-amd64 working-directory: ./backend-go - name: Release uses: softprops/action-gh-release@v2.0.5 if: startsWith(github.ref, 'refs/tags/') with: files: | backend-go/claude-proxy-darwin-arm64 backend-go/claude-proxy-darwin-amd64 draft: true generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/release-windows.yml ================================================ name: Release Windows Build permissions: contents: write on: push: tags: - "v*" concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false jobs: release: runs-on: windows-latest defaults: run: shell: bash steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "20.x" - name: Setup Bun uses: oven-sh/setup-bun@v2 - name: Build Frontend run: bun install && bun run build working-directory: ./frontend - name: Copy Frontend to Backend run: | rm -rf backend-go/frontend/dist mkdir -p backend-go/frontend/dist cp -r frontend/dist/* backend-go/frontend/dist/ - name: Setup Go uses: actions/setup-go@v5 with: go-version: "1.22.x" - name: Build Backend amd64 run: | go mod tidy go mod download CGO_ENABLED=0 go build -ldflags "-s -w -X main.Version=${{ github.ref_name }} -X main.BuildTime=$(date -u '+%Y-%m-%d_%H:%M:%S') -X main.GitCommit=${{ github.sha }}" -o claude-proxy-windows-amd64.exe working-directory: ./backend-go - name: Build Backend arm64 run: | CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "-s -w -X main.Version=${{ github.ref_name }} -X main.BuildTime=$(date -u '+%Y-%m-%d_%H:%M:%S') -X main.GitCommit=${{ github.sha }}" -o claude-proxy-windows-arm64.exe working-directory: ./backend-go - name: Release uses: softprops/action-gh-release@v2.0.5 if: startsWith(github.ref, 'refs/tags/') with: files: | backend-go/claude-proxy-windows-amd64.exe backend-go/claude-proxy-windows-arm64.exe draft: true generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* .pnpm-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional stylelint cache .stylelintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variable files .env .env.development.local .env.test.local .env.production.local .env.local # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache # Next.js build output .next out # Nuxt.js build / generate output .nuxt dist # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and not Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # vuepress v2.x temp and cache directory .temp .cache # Docusaurus cache and generated files .docusaurus # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # yarn v2 .yarn/cache .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz .pnp.* # Cursor rules .cursorrules # VSCode .vscode/ node_modules # Output .output .vercel .netlify .svelte-kit build # OS .DS_Store Thumbs.db # Env .env .env.* !.env.example !.env.test # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* # Custom .claude/* !.claude/skills/ .claude/skills/* !.claude/skills/version-bump/ !.claude/skills/codex-review/ !.claude/skills/github-release/ .aider* *.log # 不要忽略包管理器的 lock 文件(package-lock.json, bun.lock, yarn.lock, pnpm-lock.yaml 等) # 这些文件对于确保依赖版本一致性至关重要 # *.lock # *-lock.json # *-lock.yaml *.patch *.tsbuildinfo **/.config/ # 测试和脚本文件 test-*.sh test-*.js test-*.ts .gocache/ .gomodcache/ .snow/ refs/ # CCB/Codex 会话配置(包含本机路径等敏感信息) .ccb_config/ # 临时调查文档 backend-go/cmd/ !backend-go/cmd/stream_verify/ docs/claude-code-investigation-*.md ================================================ FILE: .prettierrc ================================================ { "semi": false, "singleQuote": true, "trailingComma": "none", "printWidth": 120, "tabWidth": 2, "arrowParens": "avoid", "useTabs": false, "bracketSpacing": true, "endOfLine": "lf" } ================================================ FILE: AGENTS.md ================================================ # 仓库协作指南 ## 重要约定 - **始终使用简体中文回复**。 - 遵循 SOLID / KISS / DRY / YAGNI;优先修复根因,避免无关重构。 ## 项目结构与模块 - `backend-go/`:主 Go 服务(Gin),构建后内嵌前端静态资源;Go 代码位于 `backend-go/internal/`。 - `frontend/`:Vue 3 + Vite + Vuetify 管理界面;构建产物复制到 `backend-go/frontend/dist/` 并由后端 embed。 - `dist/`:发布构建产物(Go 二进制/打包后的 UI),不要手动编辑。 - `.config/`:运行时配置目录(`config.json` 及 `backups/`),随容器/本地持久化。 - `refs/`:外部参考项目存档,仅供对照,默认只读。 - 文档入口:`README.md`、`ARCHITECTURE.md`、`DEVELOPMENT.md`、`ENVIRONMENT.md`、`RELEASE.md`。 ## 构建/测试/开发命令 - 全栈开发(推荐):根目录 `make dev`(前端 `bun run dev` + 后端 `air` 热重载)。 - 仅后端:`cd backend-go && make dev`。 - 构建运行: - 根目录 `make run` / `make build`(先构建前端再编译后端)。 - 后端本地构建:`cd backend-go && make build-local`。 - 测试:`cd backend-go && make test`(或 `make test-cover` 生成覆盖率)。 - 前端:`cd frontend && bun install` 后 `bun run dev|build|type-check`。 - Docker:`docker-compose up -d` 默认拉取镜像;本地构建请按 `docker-compose.yml` 注释说明启用 `build`(可选 `Dockerfile_China`)。 ## 代码风格 - Go:保持包职责单一、接口清晰;修改后运行 `go fmt ./...`。 - 前端:遵循现有 Vuetify/Tailwind/Prettier 风格;TypeScript 保持 strict。 - 配置/密钥:`.env`/`.json` 只提交示例文件(`*.example`),禁止提交真实密钥。 ## 测试规范 - 新增/修改后端逻辑尽量补 `_test.go`,优先表驱动 + `httptest`。 - 前端目前无测试框架;如增加复杂逻辑再引入轻量单测。 ## 安全与配置提示 - 部署前必须设置强 `PROXY_ACCESS_KEY`;生产环境建议关闭详细请求/响应日志。 - 代理端点统一鉴权(Header `x-api-key` / `Authorization: Bearer`);生产环境不建议使用 query `key`。 ================================================ FILE: ARCHITECTURE.md ================================================ # 项目架构与设计 本文档详细介绍 Claude / Codex / Gemini API Proxy 的架构设计、技术选型和实现细节。 ## 项目结构 项目采用一体化架构,Go 后端嵌入前端构建产物,实现单二进制部署: ``` claude-proxy/ ├── backend-go/ # Go 后端服务(主程序) │ ├── main.go # 程序入口 │ ├── internal/ # 内部实现 │ │ ├── config/ # 配置管理 │ │ ├── handlers/ # HTTP 处理器 │ │ ├── middleware/ # 中间件 │ │ ├── providers/ # 上游服务适配器 │ │ ├── converters/ # Responses API 协议转换器 │ │ ├── scheduler/ # 多渠道调度器 │ │ ├── session/ # 会话管理 │ │ └── metrics/ # 渠道指标监控 │ ├── .config/ # 运行时配置 │ │ ├── config.json # 主配置文件 │ │ └── backups/ # 配置备份 (保留最近10个) │ └── .env # 环境变量 ├── frontend/ # Vue 3 + Vuetify 前端 │ ├── src/ │ │ ├── components/ # Vue 组件 │ │ ├── services/ # API 服务 │ │ └── styles/ # 样式文件 │ ├── public/ # 静态资源 │ └── dist/ # 构建产物(嵌入到 Go 二进制) ├── Makefile # 构建和开发命令 ├── docker-compose.yml # Docker 部署配置 └── Dockerfile # 容器镜像定义 ``` ## 核心技术栈 ### 后端 (backend-go/) - **运行时**: Go 1.22+ - **框架**: Gin Web Framework - **配置管理**: fsnotify (热重载) + godotenv - **前端嵌入**: Go `embed.FS` - **并发模型**: 原生 Goroutine - **性能优势**: - 启动时间: < 100ms (vs Node.js 2-3s) - 内存占用: ~20MB (vs Node.js 50-100MB) - 部署包大小: ~15MB (vs Node.js 200MB+) ### 前端 (frontend/) - **框架**: Vue 3 (Composition API) - **UI 组件库**: Vuetify 3 - **UI 主题**: 复古像素 (Neo-Brutalism) - **构建工具**: Vite - **状态管理**: Vue Composition API - **HTTP 客户端**: Fetch API ### 构建系统 - **包管理器**: Bun (推荐) / npm / pnpm - **构建工具**: Makefile + Shell Scripts - **跨平台编译**: 支持 Linux/macOS/Windows, amd64/arm64 ## 模块索引 | 模块 | 路径 | 职责 | | -------------- | --------------------------------- | --------------------------- | | **后端核心** | `backend-go/` | API 代理、协议转换、配置管理 | | **前端界面** | `frontend/` | Web 管理界面、渠道配置 | | **提供商适配** | `backend-go/internal/providers/` | 上游服务协议转换 | | **配置系统** | `backend-go/internal/config/` | 配置文件管理和热重载 | | **HTTP 处理** | `backend-go/internal/handlers/` | REST API 路由和业务逻辑 | | **中间件** | `backend-go/internal/middleware/` | 认证、日志、CORS | | **会话管理** | `backend-go/internal/session/` | Responses API 会话跟踪 | | **调度器** | `backend-go/internal/scheduler/` | 多渠道智能调度 | | **指标管理** | `backend-go/internal/metrics/` | 渠道健康度和性能指标 | ## 设计模式 ### 1. 提供商模式 (Provider Pattern) 所有上游 AI 服务都实现统一的 `Provider` 接口,实现协议转换: ```go type Provider interface { // 将 Claude 格式请求转换为上游格式 ConvertRequest(claudeRequest *ClaudeRequest) (*UpstreamRequest, error) // 将上游响应转换为 Claude 格式 ConvertResponse(upstreamResponse *UpstreamResponse) (*ClaudeResponse, error) // 处理流式响应 StreamResponse(upstream io.Reader, downstream io.Writer) error } ``` **已实现的提供商**: - `OpenAI`: 支持 OpenAI API 和兼容 API - `Gemini`: Google Gemini API - `Claude`: Anthropic Claude API (直接透传) - `Responses`: Codex Responses API (支持会话管理) - `OpenAI Old`: 旧版 OpenAI API 兼容 ### 2. 配置管理器模式 `ConfigManager` 负责配置的生命周期管理: ```go type ConfigManager struct { config *Config configPath string watcher *fsnotify.Watcher mu sync.RWMutex } // 核心功能 func (cm *ConfigManager) Load() error func (cm *ConfigManager) Save() error func (cm *ConfigManager) Watch() error func (cm *ConfigManager) GetNextAPIKey(channelID string) (string, error) ``` **特性**: - 配置文件热重载 (无需重启服务) - 自动备份机制 (保留最近 10 个版本) - 线程安全的读写操作 - API 密钥轮询策略 ### 3. 会话管理模式 (Session Manager) 为 Responses API 提供有状态的多轮对话支持: ```go type SessionManager struct { sessions map[string]*Session responseMap map[string]string // responseID -> sessionID mu sync.RWMutex expiration time.Duration maxMessages int maxTokens int } // 核心功能 func (sm *SessionManager) GetOrCreateSession(previousResponseID string) (*Session, error) func (sm *SessionManager) AppendMessage(sessionID string, item ResponsesItem, tokens int) func (sm *SessionManager) UpdateLastResponseID(sessionID, responseID string) func (sm *SessionManager) RecordResponseMapping(responseID, sessionID string) ``` **特性**: - 自动会话创建和关联 - 基于 `previous_response_id` 的会话追踪 - 限制消息数量(默认 100 条) - 限制 Token 总数(默认 100k) - 自动过期清理(默认 24 小时) - 线程安全的并发访问 **会话流程**: 1. 首次请求:创建新会话,返回 `response_id` 2. 后续请求:通过 `previous_response_id` 查找会话 3. 自动追加用户输入和模型输出 4. 响应中包含 `previous_id` 链接历史 ### 4. 转换器模式 (Converter Pattern) 🆕 **v2.0.5 新增**:为 Responses API 提供统一的协议转换架构。 #### 转换器接口 ```go type ResponsesConverter interface { // 将 Responses 请求转换为上游服务格式 ToProviderRequest(sess *session.Session, req *types.ResponsesRequest) (interface{}, error) // 将上游响应转换为 Responses 格式 FromProviderResponse(resp map[string]interface{}, sessionID string) (*types.ResponsesResponse, error) // 获取上游服务名称 GetProviderName() string } ``` #### 已实现的转换器 | 转换器 | 文件 | 转换方向 | |--------|------|----------| | `OpenAIChatConverter` | `openai_converter.go` | Responses ↔ OpenAI Chat Completions | | `OpenAICompletionsConverter` | `openai_converter.go` | Responses ↔ OpenAI Completions | | `ClaudeConverter` | `claude_converter.go` | Responses ↔ Claude Messages API | | `ResponsesPassthroughConverter` | `responses_passthrough.go` | Responses ↔ Responses (透传) | #### 工厂模式 ```go func NewConverter(serviceType string) ResponsesConverter { switch serviceType { case "openai": return &OpenAIChatConverter{} case "claude": return &ClaudeConverter{} case "responses": return &ResponsesPassthroughConverter{} default: return &OpenAIChatConverter{} } } ``` #### 核心转换逻辑 **1. Instructions 字段处理** ```go // OpenAI: instructions → messages[0] (role: system) if req.Instructions != "" { messages = append(messages, map[string]interface{}{ "role": "system", "content": req.Instructions, }) } // Claude: instructions → system 参数(独立字段) if req.Instructions != "" { claudeReq["system"] = req.Instructions } ``` **2. 嵌套 Content 数组提取** ```go func extractTextFromContent(content interface{}) string { // 1. 如果是 string,直接返回 if str, ok := content.(string); ok { return str } // 2. 如果是 []ContentBlock,提取 input_text/output_text if arr, ok := content.([]interface{}); ok { texts := []string{} for _, c := range arr { if block["type"] == "input_text" || block["type"] == "output_text" { texts = append(texts, block["text"]) } } return strings.Join(texts, "\n") } return "" } ``` **3. Message Type 区分** ```go switch item.Type { case "message": // 新格式:嵌套结构(type=message, role=user/assistant, content=[]ContentBlock) role := item.Role // 直接从 item.role 获取 contentText := extractTextFromContent(item.Content) case "text": // 旧格式:简单 string(向后兼容) contentStr := extractTextFromContent(item.Content) role := item.Role // 使用 role 字段,不再依赖 [ASSISTANT] 前缀 } ``` #### 架构优势 - **易于扩展** - 新增上游只需实现 `ResponsesConverter` 接口 - **职责清晰** - 转换逻辑与 Provider 解耦 - **可测试性** - 每个转换器可独立测试 - **代码复用** - 公共逻辑提取到 `extractTextFromContent` 等基础函数 - **统一流程** - 所有上游使用相同的转换流程 #### 使用示例 ```go // 在 ResponsesProvider 中使用 converter := converters.NewConverter(upstream.ServiceType) providerReq, err := converter.ToProviderRequest(sess, &responsesReq) ``` #### 支持的 Responses API 格式 ```json { "model": "gpt-4", "instructions": "You are a helpful assistant.", // ✅ 新增 "input": [ { "type": "message", // ✅ 新增 "role": "user", // ✅ 新增 "content": [ { "type": "input_text", // ✅ 新增 "text": "Hello!" } ] } ], "previous_response_id": "resp_xxxxx", "max_tokens": 1000 } ``` **对比旧格式**: ```json { "model": "gpt-4", "input": [ { "type": "text", "content": "Hello!" // 简单 string }, { "type": "text", "content": "[ASSISTANT]Hi there!" // ❌ 使用前缀 hack } ] } ``` ### 5. 多渠道调度模式 (Channel Scheduler) 🆕 **v2.0.11 新增**:智能多渠道调度系统,支持优先级排序、健康检查和故障转移。 #### 核心组件 ```go // ChannelScheduler 多渠道调度器 type ChannelScheduler struct { configManager *config.ConfigManager messagesMetricsManager *metrics.MetricsManager responsesMetricsManager *metrics.MetricsManager traceAffinity *session.TraceAffinityManager } // SelectChannel 选择最佳渠道 func (s *ChannelScheduler) SelectChannel( ctx context.Context, userID string, failedChannels map[int]bool, isResponses bool, ) (*SelectionResult, error) ``` #### 调度优先级 调度器按以下优先级选择渠道: ``` 1. Trace 亲和性检查 └─ 同一用户优先使用之前成功的渠道 ↓ (无亲和记录或渠道不健康) 2. 健康检查 └─ 跳过失败率 >= 50% 的渠道 ↓ (渠道健康) 3. 优先级排序 └─ 按 priority 字段排序(数字越小优先级越高) ↓ (所有健康渠道都失败) 4. 降级选择 └─ 选择失败率最低的渠道 ``` #### 渠道状态 ```go type UpstreamConfig struct { // ... 其他字段 Priority int `json:"priority"` // 优先级(数字越小越高) Status string `json:"status"` // active | suspended | disabled } ``` | 状态 | 参与调度 | 故障转移 | 说明 | |------|----------|----------|------| | `active` | ✅ | ✅ | 正常运行 | | `suspended` | ✅ | ✅ | 暂停但保留在序列中(被健康检查跳过) | | `disabled` | ❌ | ❌ | 备用池,完全不参与 | > ⚠️ **状态 vs 熔断的区别**: > - `status: suspended` 是**配置层面**的状态,需要手动改为 `active` 才能恢复 > - 运行时熔断(`CircuitBrokenAt`)是**指标层面**的临时保护,15 分钟后自动恢复 > - 启动时就是 `suspended` 的渠道不会触发自动恢复逻辑 #### 指标管理 ```go // MetricsManager 渠道指标管理 type MetricsManager struct { metrics map[int]*ChannelMetrics windowSize int // 滑动窗口大小(默认 10) failureThreshold float64 // 失败率阈值(默认 0.5) circuitRecoveryTime time.Duration // 熔断恢复时间(默认 15 分钟) } // 核心方法 func (m *MetricsManager) RecordSuccess(channelIndex int) func (m *MetricsManager) RecordFailure(channelIndex int) func (m *MetricsManager) CalculateFailureRate(channelIndex int) float64 func (m *MetricsManager) IsChannelHealthy(channelIndex int) bool ``` **滑动窗口算法**: - 基于最近 N 次请求(默认 10 次)计算失败率 - 失败率 >= 50% 触发熔断,记录 `CircuitBrokenAt` 时间戳 - 熔断恢复方式: - **自动恢复**:15 分钟后后台任务自动重置滑动窗口 - **成功请求恢复**:任意成功请求立即清除熔断状态 - **手动恢复**:通过 API 调用 `Reset()` 重置 #### Trace 亲和性 ```go // TraceAffinityManager 用户会话亲和性 type TraceAffinityManager struct { affinity map[string]*TraceAffinity // key: user_id ttl time.Duration // 默认 30 分钟 } // 核心方法 func (m *TraceAffinityManager) GetPreferredChannel(userID string) (int, bool) func (m *TraceAffinityManager) SetPreferredChannel(userID string, channelIndex int) ``` **工作原理**: 1. 用户首次请求 → 调度器选择渠道 → 记录亲和关系 2. 用户后续请求 → 优先使用之前成功的渠道 3. 渠道不健康或 30 分钟无活动 → 清除亲和记录 #### 调度流程图 ```mermaid graph TD A[请求到达] --> B{检查 Trace 亲和} B -->|有亲和| C{渠道健康?} C -->|是| D[使用亲和渠道] C -->|否| E[遍历活跃渠道] B -->|无亲和| E E --> F{渠道健康?} F -->|是| G[选择该渠道] F -->|否| H[跳过,继续遍历] H --> I{还有渠道?} I -->|是| E I -->|否| J[降级选择最佳渠道] D --> K[执行请求] G --> K J --> K K --> L{请求成功?} L -->|是| M[记录成功 + 更新亲和] L -->|否| N[记录失败 + 尝试下一渠道] ``` ### 6. 中间件模式 Express/Gin 使用中间件架构处理横切关注点: ```go // 认证中间件 func AuthMiddleware() gin.HandlerFunc // 日志记录中间件 func LoggerMiddleware() gin.HandlerFunc // 错误处理中间件 func ErrorHandler() gin.HandlerFunc // CORS 中间件 func CORSMiddleware() gin.HandlerFunc ``` ## 数据流图 ```mermaid graph TD A[Client Request] --> B[Gin Router] B --> C[Auth Middleware] C --> D[Logger Middleware] D --> E[Route Handler] E --> F[Channel Scheduler] F --> G[Trace Affinity Check] G --> H[Health Check] H --> I[Provider Factory] I --> J[Request Converter] J --> K[Upstream API] K --> L[Response Converter] L --> M[Metrics Recorder] M --> N[Client Response] O[Config Manager] --> F P[Metrics Manager] --> H Q[File Watcher] --> O ``` **Messages API 流程说明**: 1. 客户端请求到达 Gin 路由器 2. 通过认证和日志中间件 3. 路由处理器调用 Channel Scheduler 4. 调度器检查 Trace 亲和性(优先使用历史成功渠道) 5. 健康检查过滤不健康渠道 6. Provider 工厂创建对应的协议转换器 7. 转换请求格式并发送到上游 API 8. 接收上游响应并转换回 Claude 格式 9. 记录指标(成功/失败)并返回给客户端 **Responses API 特殊流程**: ```mermaid graph TD A[Client Request] --> B[Responses Handler] B --> C[Session Manager] C --> D{检查 previous_response_id} D -->|存在| E[获取现有会话] D -->|不存在| F[创建新会话] E --> G[Responses Provider] F --> G G --> H[上游 API] H --> I[响应转换] I --> J[更新会话历史] J --> K[记录 Response Mapping] K --> L[返回带 response_id 的响应] ``` **Responses API 会话管理**: 1. 检查请求中的 `previous_response_id` 2. 如存在,通过 `responseMap` 查找对应的会话 3. 如不存在,创建新的会话 ID 4. 将用户输入追加到会话历史 5. 发送请求到上游 Responses API 6. 将模型输出追加到会话历史 7. 更新会话的 `last_response_id` 8. 记录 `response_id` → `session_id` 映射 9. 返回响应,包含 `id` (当前) 和 `previous_id` (上一轮) ## 技术选型决策 ### 前端资源嵌入方案 #### 实现对比 **当前方案**: ```go //go:embed frontend/dist/* var frontendFS embed.FS func ServeStaticFiles(r *gin.Engine) { // API 路由优先处理 r.NoRoute(func(c *gin.Context) { path := c.Request.URL.Path // 检测 API 路径 if isAPIPath(path) { c.JSON(404, gin.H{"error": "API endpoint not found"}) return } // 尝试读取静态文件 fileContent, err := fs.ReadFile(distFS, path[1:]) if err == nil { contentType := getContentType(path) c.Data(200, contentType, fileContent) return } // SPA 回退到 index.html indexContent, _ := fs.ReadFile(distFS, "index.html") c.Data(200, "text/html; charset=utf-8", indexContent) }) } ``` **关键优势**: 1. ✅ **单次嵌入**: 只嵌入一次整个目录,避免重复 2. ✅ **智能文件检测**: 先尝试读取实际文件 3. ✅ **动态 Content-Type**: 根据扩展名返回正确类型 4. ✅ **API 路由优先**: API 404 返回 JSON 而非 HTML 5. ✅ **简洁代码**: 无需自定义 FileSystem 适配器 #### 缓存策略 **已实施**: - API 路由返回 JSON 格式 404 错误 - 静态文件正确的 MIME 类型检测 **待优化**: - HTML 文件: `Cache-Control: no-cache, no-store, must-revalidate` - 静态资源 (.css, .js, 字体): `Cache-Control: public, max-age=31536000, immutable` ### Go vs TypeScript 重写 v2.0.0 将后端完全重写为 Go 语言: | 指标 | TypeScript/Bun | Go | 提升 | | --------------- | -------------- | ---------- | --------- | | **启动时间** | 2-3s | < 100ms | **20x** | | **内存占用** | 50-100MB | ~20MB | **70%↓** | | **部署包大小** | 200MB+ | ~15MB | **90%↓** | | **并发处理** | 事件循环 | Goroutine | 原生并发 | | **部署依赖** | Node.js 运行时 | 单二进制 | 零依赖 | **选择 Go 的原因**: - 高性能和低资源占用 - 单二进制部署,无需运行时 - 原生并发支持,适合高并发场景 - 强类型系统和出色的工具链 ## 性能优化 ### 智能构建缓存 Makefile 实现了智能缓存机制: ```makefile .build-marker: $(shell find frontend/src -type f) @echo "检测到前端文件变更,重新构建..." cd frontend && npm run build @touch .build-marker ensure-frontend-built: .build-marker ``` **性能对比**: | 场景 | 之前 | 现在 | 提升 | | ------------------ | ------ | --------- | ---------- | | 首次构建 | ~10秒 | ~10秒 | 无变化 | | **无变更重启** | ~10秒 | **0.07秒** | **142x** 🚀 | | 有变更重新构建 | ~10秒 | ~8.5秒 | 15%提升 | ### 请求头优化 针对不同上游使用不同的请求头策略: - **Claude 渠道**: 保留原始请求头 (支持 `anthropic-version` 等) - **OpenAI/Gemini 渠道**: 最小化请求头 (仅 `Host` 和 `Content-Type`) 这避免了转发无关头部导致上游 API 拒绝请求的问题。 ## 安全设计 ### 统一认证架构 所有访问入口受 `PROXY_ACCESS_KEY` 保护: ```go func AuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { // 健康检查无需认证 if c.Request.URL.Path == "/health" { c.Next() return } // 验证访问密钥 apiKey := c.GetHeader("x-api-key") if apiKey != expectedKey { c.JSON(401, gin.H{"error": "Unauthorized"}) c.Abort() return } c.Next() } } ``` **受保护的入口**: 1. 前端管理界面 (`/`) 2. 管理 API (`/api/*`) 3. Messages API (`/v1/messages`) 4. Responses API (`/v1/responses`) **公开入口**: - 健康检查 (`/health`) ### 敏感信息保护 - API 密钥掩码显示 (仅显示前 8 位和后 4 位) - 日志中自动隐藏 `Authorization` 头 - 配置文件权限控制 (`.config/` 目录) ## 扩展性 ### 添加新的上游服务 1. 在 `internal/providers/` 创建新的 provider 文件 2. 实现 `Provider` 接口 3. 在 `ProviderFactory` 注册新 provider 4. 更新配置文件模式 示例: ```go // internal/providers/myapi.go type MyAPIProvider struct{} func (p *MyAPIProvider) ConvertRequest(req *ClaudeRequest) (*UpstreamRequest, error) { // 实现协议转换逻辑 } // 在 factory 中注册 func GetProvider(providerType string) Provider { switch providerType { case "myapi": return &MyAPIProvider{} // ... } } ``` ## 文档资源 - **快速开始**: 参见 [README.md](README.md) - **环境配置**: 参见 [ENVIRONMENT.md](ENVIRONMENT.md) - **贡献指南**: 参见 [CONTRIBUTING.md](CONTRIBUTING.md) - **版本历史**: 参见 [CHANGELOG.md](CHANGELOG.md) ================================================ FILE: CHANGELOG.md ================================================ # 版本历史 > **注意**: v2.0.0 开始为 Go 语言重写版本,v1.x 为 TypeScript 版本 --- ## [v2.5.13] - 2026-01-31 ### 修复 - **Gemini functionDeclaration parameters 类型修复** - 修复 Gemini API 返回 400 错误的问题 - 问题:当 Claude 工具的 `InputSchema` 为 nil、缺少 `type` 字段或缺少 `properties` 字段时,Gemini API 拒绝请求 - 新增 `normalizeGeminiParameters()` 辅助函数,确保 parameters schema 符合 Gemini 要求: - `parameters` 必须有 `type: "object"` 字段 - `parameters` 必须有 `properties` 字段(即使为空对象) - 涉及文件:`backend-go/internal/providers/gemini.go` --- ## [v2.5.12] - 2026-01-30 ### 新增 - **渠道置顶/置底功能** - 在渠道编排菜单中新增一键调整渠道位置的操作 - 在渠道右侧弹出菜单中添加"置顶"和"置底"选项 - 第一个渠道不显示"置顶",最后一个渠道不显示"置底" - 操作后立即保存到后端,复用现有 `saveOrder()` 函数 - 解决渠道数量较多时拖拽排序不便的问题 - 涉及文件: - `frontend/src/components/ChannelOrchestration.vue` - 添加菜单项和处理函数 - `frontend/src/plugins/vuetify.ts` - 添加 `arrow-collapse-up/down` 图标 - **隐式缓存读取推断** - 当上游未明确返回 `cache_read_input_tokens` 但存在显著 token 差异时,自动推断缓存命中 - 检测 `message_start` 与 `message_delta` 事件中 `input_tokens` 的差异 - 触发条件:差额 > 10% 或差额 > 10000 tokens - 将差额自动填充到 `CacheReadInputTokens` 字段,使 token 统计更准确 - **下游转发支持**:推断的 `cache_read_input_tokens` 会写入 `message_delta` 事件并转发给下游客户端 - 新增 `StreamContext.MessageStartInputTokens` 字段记录初始 token 数 - 新增 `inferImplicitCacheRead()` 函数在流结束时执行推断 - 新增 `PatchTokensInEventWithCache()` 函数在修补 token 的同时写入推断的缓存值 - **关键修复**: - `message_start` 的 `input_tokens` 不再累积到 `CollectedUsage.InputTokens`,确保差额计算正确 - 使用 `originalUsageData` 传递给 `PatchMessageStartInputTokensIfNeeded`,避免误判 - Token 修补逻辑增加隐式缓存信号检测,避免覆盖缓存命中场景下的正确低值 - 隐式缓存推断在转发前执行,确保下游客户端能收到推断值 - 仅当上游事件中不存在 `cache_read_input_tokens` 字段时才写入推断值,避免覆盖上游显式返回的 0 值 - 涉及文件: - `backend-go/internal/handlers/common/stream.go` - 核心逻辑实现 - `backend-go/internal/handlers/common/stream_test.go` - 单元测试(15 个边界场景) --- ## [v2.5.10] - 2026-01-26 ### 新增 - **删除渠道时自动清理指标数据** - 修复删除渠道后内存和 SQLite 指标数据残留问题 - 扩展 `PersistenceStore` 接口,新增按 `metrics_key` 和 `api_type` 批量删除记录的方法 - 新增 `MetricsManager.DeleteChannelMetrics()` 方法,支持同时清理内存和持久化数据 - 新增 `ChannelScheduler.DeleteChannelMetrics()` 统一删除入口 - 修改 `DeleteUpstream` Handler(Messages/Responses/Gemini),删除后自动调用指标清理 - SQLite 清理不依赖内存状态,确保即使内存中无数据也能正确清理持久化记录 - 删除渠道时同时清理历史 Key 的指标数据 - **按 `api_type` 过滤删除**:避免误删其他接口类型(messages/responses/gemini)的指标数据 - **分批删除**:每批 500 条,避免触发 SQLite 变量上限(999)导致删除失败 - **并发安全**:`flushMu` 互斥锁串行化 flush 与 delete;`asyncFlushWg` 确保 Close 前所有异步 flush 完成 - 涉及文件: - `backend-go/internal/metrics/persistence.go` - 接口扩展(新增 apiType 参数) - `backend-go/internal/metrics/sqlite_store.go` - 实现 SQLite 删除逻辑(分批 + api_type 过滤) - `backend-go/internal/metrics/channel_metrics.go` - 新增删除方法,导出 `GenerateMetricsKey()` - `backend-go/internal/scheduler/channel_scheduler.go` - 新增统一删除入口 - `backend-go/internal/handlers/*/channels.go` - 删除 Handler 改造 - `backend-go/main.go` - 路由注册更新 - **换 Key 后历史数据累计统计** - 修复更换 API Key 后旧 Key 的历史统计数据丢失问题 - 新增 `UpstreamConfig.HistoricalAPIKeys` 字段,存储历史 API Key 列表 - 更新渠道时自动维护历史 Key 列表:被移除的 Key 进入历史列表,恢复的 Key 从历史列表移除 - `Add*APIKey` / `Remove*APIKey` 接口同样维护历史 Key 列表 - `ToResponseMultiURL()` 支持聚合历史 Key 指标(只计入总数,不影响实时失败率和熔断判断) - 前端查看渠道统计时,总数包含历史 Key 数据,Key 详情列表只显示当前活跃 Key - 涉及文件: - `backend-go/internal/config/config.go` - 新增 `HistoricalAPIKeys` 字段 - `backend-go/internal/config/config_utils.go` - `Clone()` 方法深拷贝历史 Key - `backend-go/internal/config/config_*.go` - 更新渠道时维护历史 Key 列表 - `backend-go/internal/metrics/channel_metrics.go` - 聚合逻辑支持历史 Key - `backend-go/internal/handlers/channel_metrics_handler.go` - 传入历史 Key 参数 - `backend-go/internal/handlers/gemini/dashboard.go` - 传入历史 Key 参数 --- ## [v2.5.9] - 2026-01-24 ### 新增 - **前端模型映射智能选择功能** - 优化模型重定向配置体验,支持自动获取上游模型列表 - 前端直连上游 `/v1/models` 接口,无需后端代理 - 目标模型输入框改为 `v-combobox`,点击时自动获取模型列表 - 为每个 API Key 并行检测 models 接口状态,提高效率 - 在 API 密钥列表中实时显示状态标签: - 成功:绿色标签显示 `models 200 (N 个)` - 失败:红色标签显示 `models 错误码`,鼠标悬停显示详细错误消息 - 加载中:蓝色标签显示 `检测中...` - 智能错误解析,支持上游标准错误格式 `{ "error": { "message": "...", "code": "..." } }` - 合并所有成功的模型列表并去重,提供完整的模型选项 - 涉及文件: - `frontend/src/services/api.ts` - 新增 `fetchUpstreamModels` 函数和 `buildModelsURL` 工具函数 - `frontend/src/components/AddChannelModal.vue` - 优化交互体验和状态管理 --- ## [v2.5.8] - 2026-01-21 ### 修复 - **客户端取消请求误计入失败** - 修复用户主动取消请求被错误计入渠道失败指标的问题 - 新增 `isClientSideError` 函数,使用 `errors.Is` 正确识别被包装的 `context.Canceled` 错误 - 仅识别明确的客户端取消(`context.Canceled`),连接故障(`broken pipe`、`connection reset`)继续 failover - 统一口径:`SendRequest` 和 `handleSuccess` 路径均应用客户端取消判断 - 新增 `RecordRequestFinalizeClientCancel` 方法,客户端取消时仅计入总请求数,不计入失败数和失败率 - 客户端取消不重置 `ConsecutiveFailures`,保留真实的连续失败计数 - 涉及文件: - `backend-go/internal/handlers/common/upstream_failover.go` - 错误类型判断与分流 - `backend-go/internal/metrics/channel_metrics.go` - 新增客户端取消记录方法 - `backend-go/internal/handlers/common/client_error_test.go` - 单元测试 - **指标二次计数 Bug** - 修复 `RecordRequestFinalize*` fallback 路径导致的请求计数重复问题 - 将 `RequestCount++` 从 `RecordRequestConnected` 移至 `RecordRequestFinalize*` 阶段 - 采用延迟计数策略:连接时预写历史记录,完成时统一计数 - 确保 fallback 路径(requestID 丢失/索引越界)不会触发二次计数 - 涉及文件:`backend-go/internal/metrics/channel_metrics.go` ### 重构 - **指标记录架构优化** - 将指标记录职责从 handler 层下沉到 failover 层,实现"连接即计数"的实时统计 - 新增 `RecordRequestConnected` / `RecordRequestFinalizeSuccess` / `RecordRequestFinalizeFailure` 三阶段记录机制 - TCP 建连时即计入活跃请求数,响应完成后回写成功/失败与 token 数据 - 移除 handler 层的 `RecordSuccessWithUsage` / `RecordFailure` 调用,统一由 `upstream_failover.go` 管理 - 修改 `HandleSuccessFunc` 签名:返回 `(*types.Usage, error)` 而非 `*types.Usage`,支持流式响应错误处理 - 修改 `ProcessStreamEvents` / `HandleStreamResponse` 返回 usage,避免在 stream 层直接记录指标 - 新增 `pendingHistoryIdx` 映射表,支持请求 ID 到历史记录索引的快速查找 - 新增 `cleanupHistoryLocked` 函数,清理过期历史记录时同步修正索引 - 涉及文件: - `backend-go/internal/handlers/common/stream.go` - 移除指标记录,返回 usage - `backend-go/internal/handlers/common/upstream_failover.go` - 三阶段指标记录 - `backend-go/internal/handlers/messages/handler.go` - 移除指标记录调用 - `backend-go/internal/handlers/responses/handler.go` - 移除指标记录调用 - `backend-go/internal/handlers/gemini/handler.go` - 移除指标记录调用 - `backend-go/internal/metrics/channel_metrics.go` - 新增三阶段记录 API ## [v2.5.6] - 2026-01-20 ### 修复 - **Gemini CLI 工具调用签名兼容** - 修复多轮工具调用中签名字段位置/命名不一致导致上游返回 400 的问题(启用 `injectDummyThoughtSignature` 时会为缺失签名的 `functionCall` 注入 dummy)。 - **Gemini CLI tools schema 兼容** - 支持 `parametersJsonSchema` 并在转发前清洗不兼容字段(`$schema` / `additionalProperties` / `const`),避免上游 400。 - **Gemini Dashboard stripThoughtSignature 字段缺失** - Dashboard API 补齐 `stripThoughtSignature` 字段,避免配置在刷新后丢失。 - **Gemini 渠道 stripThoughtSignature 字段无法保存** - 修复前端无法正确显示和保存"移除 Thought Signature"配置的问题 - 修复 `GetUpstreams` 函数返回数据中缺失 `stripThoughtSignature` 字段 - 修复前端图标显示问题(将 `mdi-signature-freehand` 改为 `mdi-close-circle`) - 统一图标和开关颜色为 `error` 红色,与"移除"操作语义一致 - 涉及文件: - `backend-go/internal/handlers/gemini/channels.go` - 添加缺失字段 - `frontend/src/components/AddChannelModal.vue` - 修复图标和颜色 ### 新增 - **Gemini API thought_signature 兼容性方案** - 新增 `stripThoughtSignature` 配置项,支持兼容旧版 Gemini API - 新增 `StripThoughtSignature` 配置字段(布尔值),用于移除 `thought_signature` 字段 - 实现 `stripThoughtSignatures()` 函数,移除所有 functionCall 的 thought_signature 字段 - 配置优先级:`StripThoughtSignature` > `InjectDummyThoughtSignature` - 保持深拷贝机制,避免多渠道 failover 时污染后续请求 - 前端添加"移除 Thought Signature"开关(仅 Gemini 渠道显示) - 涉及文件: - `backend-go/internal/config/config.go` - 配置结构定义 - `backend-go/internal/config/config_gemini.go` - 配置更新逻辑 - `backend-go/internal/handlers/gemini/handler.go` - 请求处理逻辑 - `backend-go/internal/handlers/gemini/handler_test.go` - 单元测试 - `frontend/src/components/AddChannelModal.vue` - 前端开关 - `frontend/src/services/api.ts` - 类型定义 ## [v2.5.5] - 2026-01-19 ## [v2.5.4] - 2026-01-19 ### 重构 - **Failover 逻辑模块化** - 将多渠道和单上游 failover 逻辑提取到公共模块,大幅减少代码重复 - 新增 `backend-go/internal/handlers/common/multi_channel_failover.go` - 多渠道 failover 外壳逻辑 - 新增 `backend-go/internal/handlers/common/upstream_failover.go` - 单上游 Key/BaseURL 轮转逻辑 - 重构 Messages、Responses、Gemini 三个 handler,使用统一的 failover 函数 - 代码行数减少:-1253 行,+475 行(净减少 778 行) - 涉及文件: - `backend-go/internal/handlers/messages/handler.go` - `backend-go/internal/handlers/responses/handler.go` - `backend-go/internal/handlers/gemini/handler.go` - `backend-go/internal/scheduler/channel_scheduler.go` ## [v2.5.3] - 2026-01-19 ### 修复 - **Models API 日志标签修正** - 修正 Models API 相关日志标签,确保正确区分 Messages 和 Responses 渠道 - 修正 `models.go` 中 `tryModelsRequest` 和 `fetchModelsFromChannel` 函数的日志标签 - 使用动态 `channelType` 变量替代硬编码的 `"Messages"` 字符串 - 日志标签格式统一为 `[Messages-Models]` 或 `[Responses-Models]` - 涉及文件:`backend-go/internal/handlers/messages/models.go` - **多渠道 failover 客户端取消检测** - 在 failover 循环中添加客户端断开检测,避免客户端已取消请求后继续尝试其他渠道 - 在每次渠道选择前检查 `c.Request.Context().Done()` - 客户端断开时立即返回,不再进行无效的渠道 failover - 涉及文件: - `backend-go/internal/handlers/gemini/handler.go` - Gemini API 处理器 - `backend-go/internal/handlers/messages/handler.go` - Messages API 处理器 - `backend-go/internal/handlers/responses/handler.go` - Responses API 处理器 ### 新增 - **响应 model 字段改写可配置化** - 新增环境变量 `REWRITE_RESPONSE_MODEL` 控制是否改写响应中的 model 字段 - 默认值:`false`(保持上游返回的原始 model) - 启用后:当上游返回的 model 与请求的 model 不一致时,自动改写为请求的 model - 适用范围:仅影响 Messages API 的流式响应,不影响 Responses API 和 Gemini API - 涉及文件: - `backend-go/.env.example` - 添加配置说明和默认值 - `backend-go/internal/config/env.go` - 添加 `RewriteResponseModel` 配置字段 - `backend-go/internal/handlers/common/stream.go` - 修改 `PatchMessageStartEvent` 函数,仅在配置启用时改写 model 字段 ## [v2.5.2] - 2026-01-19 ### 新增 - **Gemini thought_signature 可配置化** - 新增渠道级配置开关 `injectDummyThoughtSignature` - 新增 `ensureThoughtSignatures` 函数:为所有缺失 `thought_signature` 的 `functionCall` 注入 dummy 值 - 使用官方推荐的 `skip_thought_signature_validator` 跳过验证 - **默认关闭**:保持原样,符合官方 Gemini API 标准 - **用户可开启**:为需要该字段的第三方 API 注入 dummy signature - 前端 UI:在 Gemini 渠道编辑界面添加"注入 Dummy Thought Signature"开关 - 涉及文件: - `backend-go/internal/config/config.go` - 添加 `InjectDummyThoughtSignature` 配置字段 - `backend-go/internal/config/config_gemini.go` - 更新方法支持新字段 - `backend-go/internal/config/config_messages.go` - 更新方法支持新字段 - `backend-go/internal/handlers/gemini/handler.go` - 根据配置决定是否调用 `ensureThoughtSignatures` - `backend-go/internal/types/gemini.go` - 新增共享常量 `DummyThoughtSignature` - `backend-go/internal/converters/gemini_converter.go` - 使用共享常量 - `frontend/src/services/api.ts` - 添加类型定义 - `frontend/src/components/AddChannelModal.vue` - 添加配置开关 UI - `frontend/src/plugins/vuetify.ts` - 添加 `mdi-signature` 图标映射 - 配置优化:将 `.ccb_config/` 目录加入 `.gitignore`,避免泄露本机路径等敏感信息 - **codex-review 技能 v2.1.0** - 新增自动暂存新增文件功能,避免 codex 审核时报 P1 错误 - 新增步骤 2:在审核前自动暂存所有新增文件 - 使用安全的 `git ls-files -z | while read` 命令,正确处理特殊文件名(空格、换行、以 `-` 开头) - 修复空列表问题:当没有新增文件时安全跳过,不会报错 - 优化元数据:添加 `user-invocable: true` 和 `context: fork` 字段 - 优化描述:添加触发关键词,移除 `(user)` 后缀 - 更新完整审核协议:增加 `[PREPARE] Stage Untracked Files` 步骤 - 创建 Plugin Marketplace 配置:`.claude-plugin/marketplace.json` - 创建详细文档:`.claude/skills/codex-review/README.md` - 涉及文件:`.claude/skills/codex-review/SKILL.md`, `.claude-plugin/marketplace.json`, `.claude/skills/codex-review/README.md` ### 优化 - **渠道活跃度图表颜色优化** - 状态条柱状图颜色改为显示每个 6 秒段的独立成功率 - 修改 SVG 渐变定义:为每个柱子单独定义渐变色(`gradient-${channelIndex}-${i}`) - 重构 `getActivityBars` 函数:为每个 6 秒时间段计算独立的成功率并分配颜色 - 颜色规则(7 档分级): - 深红色(0-5%):极端故障 - 红色(5-20%):严重失败 - 深橙色(20-40%):高失败率 - 橙色(40-60%):中等失败率 - 黄色(60-80%):轻微失败 - 黄绿色(80-95%):良好 - 绿色(95-100%):优秀 - 效果:用户可以更清晰地看到每个时间段的健康状况,颜色变化更细腻 - 性能优化:新增 `activityBarsCache` 计算属性缓存柱状图数据,避免重复计算 - 代码清理:删除未使用的 `activityColorCache` 和 `getActivityColor` 函数 - 涉及文件:`frontend/src/components/ChannelOrchestration.vue` - **修复 Dashboard 切换 Tab 时数据闪烁问题** - 将 Dashboard 数据改为按 API 类型独立缓存 - 重构 `channelStore`:将单一全局 `dashboardMetrics`/`dashboardStats`/`dashboardRecentActivity` 改为按 Tab(messages/responses/gemini)独立缓存的 `dashboardCache` 结构 - 新增 `currentDashboardMetrics`、`currentDashboardStats`、`currentDashboardRecentActivity` 计算属性,根据当前 Tab 返回对应缓存数据 - 切换 Tab 时直接显示该 Tab 的缓存数据,避免显示其他 Tab 的旧数据导致闪烁 - 涉及文件:`frontend/src/stores/channel.ts`、`frontend/src/views/ChannelsView.vue` ### 重构 - **前端系统状态管理重构** - 将 App.vue 中的系统级状态迁移到 SystemStore - 新增 `src/stores/system.ts` 系统状态 Store,统一管理系统运行状态、版本信息、Fuzzy 模式加载状态 - 重构 `src/App.vue`,移除本地系统状态变量(systemStatus、versionInfo、isCheckingVersion、fuzzyModeLoading、fuzzyModeLoadError),改用 SystemStore 统一管理 - 更新 `src/stores/index.ts`,导出 SystemStore - 新增 2 个计算属性:systemStatusText、systemStatusDesc - 新增 8 个状态管理方法:setSystemStatus、setVersionInfo、setCurrentVersion、setCheckingVersion、setFuzzyModeLoading、setFuzzyModeLoadError、resetSystemState - 优势: - 状态集中:所有系统级状态统一管理,避免分散在组件中 - 代码简化:App.vue 系统状态逻辑更清晰,减少本地状态管理 - 可复用性:其他组件可直接使用 SystemStore 的系统状态 - 易维护:系统状态变更集中在 Store 中,便于调试和扩展 - 涉及文件:`frontend/src/stores/system.ts`、`frontend/src/stores/index.ts`、`frontend/src/App.vue` - **前端对话框状态管理重构** - 将 App.vue 中的对话框状态迁移到 DialogStore - 新增 `src/stores/dialog.ts` 对话框状态 Store,统一管理添加/编辑渠道对话框和添加 API 密钥对话框 - 重构 `src/App.vue`,移除本地对话框状态变量(showAddChannelModal、showAddKeyModalRef、editingChannel、selectedChannelForKey、newApiKey),改用 DialogStore 统一管理 - 更新 `src/stores/index.ts`,导出 DialogStore - 新增 6 个状态管理方法:openAddChannelModal、openEditChannelModal、closeAddChannelModal、openAddKeyModal、closeAddKeyModal、resetDialogState - 优势: - 状态集中:所有对话框相关状态统一管理,避免分散在组件中 - 代码简化:App.vue 对话框逻辑更清晰,减少本地状态管理 - 可复用性:其他组件可直接使用 DialogStore 的对话框状态 - 易维护:对话框状态变更集中在 Store 中,便于调试和扩展 - 涉及文件:`frontend/src/stores/dialog.ts`、`frontend/src/stores/index.ts`、`frontend/src/App.vue` - **前端偏好设置管理重构** - 将 App.vue 中的用户偏好设置迁移到 PreferencesStore - 新增 `src/stores/preferences.ts` 偏好设置 Store,统一管理暗色模式、Fuzzy 模式、全局统计面板状态 - 重构 `src/App.vue`,移除本地偏好设置变量(darkModePreference、fuzzyModeEnabled、showGlobalStats),改用 PreferencesStore 统一管理 - 更新 `src/stores/index.ts`,导出 PreferencesStore - 支持自动持久化到 localStorage(使用 pinia-plugin-persistedstate) - 优势: - 状态集中:所有用户偏好设置统一管理,避免分散在组件中 - 自动持久化:用户设置自动保存到本地存储,刷新页面后保持 - 代码简化:App.vue 偏好设置逻辑更清晰,减少本地状态管理 - 可复用性:其他组件可直接使用 PreferencesStore 的偏好设置 - 涉及文件:`frontend/src/stores/preferences.ts`、`frontend/src/stores/index.ts`、`frontend/src/App.vue` - **前端认证状态管理重构** - 将 App.vue 中的认证相关状态迁移到 AuthStore - 扩展 `src/stores/auth.ts`,新增认证 UI 状态管理(authError、authAttempts、authLockoutTime、isAutoAuthenticating、isInitialized、authLoading、authKeyInput) - 重构 `src/App.vue`,移除本地认证状态变量,改用 AuthStore 统一管理 - 新增 `isAuthLocked` 计算属性,自动判断认证锁定状态 - 新增 8 个状态管理方法:setAuthError、incrementAuthAttempts、resetAuthAttempts、setAuthLockout、setAutoAuthenticating、setInitialized、setAuthLoading、setAuthKeyInput - 优势: - 状态集中:所有认证相关状态统一管理,避免分散在组件中 - 代码简化:App.vue 认证逻辑更清晰,减少本地状态管理 - 可复用性:其他组件可直接使用 AuthStore 的认证状态 - 安全性增强:认证失败次数和锁定时间集中管理,便于扩展 - 涉及文件:`frontend/src/stores/auth.ts`、`frontend/src/App.vue` - **前端渠道管理逻辑重构** - 将 App.vue 中的渠道管理逻辑提取到 Pinia Store - 新增 `src/stores/channel.ts` 渠道状态 Store,统一管理三种 API 类型(Messages/Responses/Gemini)的渠道数据 - 重构 `src/App.vue`,移除 300+ 行本地状态和业务逻辑,改用 ChannelStore 统一管理 - 更新 `src/stores/index.ts`,导出 ChannelStore - 优势: - 代码解耦:App.vue 从 1000+ 行减少到 700+ 行,职责更清晰 - 状态集中:渠道数据、指标、自动刷新定时器统一管理 - 可复用性:其他组件可直接使用 ChannelStore,无需通过 props 传递 - 可测试性:业务逻辑独立于组件,便于单元测试 - 涉及文件:`frontend/src/stores/channel.ts`、`frontend/src/stores/index.ts`、`frontend/src/App.vue` - **前端状态管理架构升级** - 引入 Pinia 状态管理库,替代原有的本地状态管理 - 新增 `pinia` 和 `pinia-plugin-persistedstate` 依赖,实现响应式状态管理和自动持久化 - 新增 `src/stores/auth.ts` 认证状态 Store,统一管理 API Key 和认证状态 - 重构 `src/services/api.ts`,从 AuthStore 获取 API Key,移除本地状态管理逻辑 - 重构 `src/App.vue`,使用 AuthStore 替代 `isAuthenticated` 本地状态,简化认证流程 - 更新 `src/main.ts`,初始化 Pinia 和持久化插件 - 配置 `tsconfig.json` 路径别名 `@/*`,支持模块化导入 - 优势:响应式状态管理、自动持久化、更好的类型推断、代码解耦 - 涉及文件:`frontend/package.json`、`frontend/src/stores/auth.ts`、`frontend/src/services/api.ts`、`frontend/src/App.vue`、`frontend/src/main.ts`、`frontend/tsconfig.json` --- ## [v2.4.34] - 2026-01-17 ### 新增 - **会话管理增强** - 支持 Gemini API 的 `X-Gemini-Api-Privileged-User-Id` 请求头 - 在 `ExtractConversationID()` 函数中新增对该请求头的支持,用于会话亲和性管理 - 优先级顺序:Conversation_id > Session_id > X-Gemini-Api-Privileged-User-Id > prompt_cache_key > metadata.user_id - 涉及文件:`backend-go/internal/handlers/common/request.go` ### 优化 - **Gemini Dashboard API 性能优化** - 将前端 3 个独立请求合并为 1 个后端统一接口 - 新增 `/api/gemini/channels/dashboard` 端点,一次性返回 channels、metrics、stats、recentActivity 数据 - 后端新增 `internal/handlers/gemini/dashboard.go` 处理器,减少网络往返次数 - 涉及文件:`backend-go/main.go`、`backend-go/internal/handlers/gemini/dashboard.go` ### 重构 - **前端 UI 框架统一** - 移除 Tailwind CSS 和 DaisyUI,完全使用 Vuetify - 从 package.json 移除 tailwindcss、daisyui、autoprefixer、postcss 依赖 - 删除 tailwind.config.js 和 postcss.config.js 配置文件 - 更新 src/assets/style.css,移除 @tailwind 指令,保留自定义样式 - 优势:消除多框架样式冲突、减少打包体积、统一设计语言(Material Design) - 涉及文件:`frontend/package.json`、`frontend/src/assets/style.css`、`frontend/src/main.ts` --- ## [v2.4.33] - 2026-01-17 ### 新增 - **渠道实时活跃度可视化** - 在渠道列表中显示最近 15 分钟的活跃度数据 - 后端新增 `GetRecentActivityMultiURL()` 方法,按 **6 秒粒度**分段统计请求量、成功/失败数、Token 消耗(共 150 段) - **支持多 URL 和多 Key 聚合**:自动聚合渠道所有故障转移 URL 和所有活跃 API Key 的数据,提供完整的渠道活跃度视图 - Dashboard API 返回 `recentActivity` 字段,包含每个渠道的 150 段活跃度数据 - 前端渠道行显示 RPM/TPM 指标,**背景波形柱状图**实时反映活跃度变化(整体颜色根据全局失败率着色:绿色=成功率≥80%,橙色=成功率≥50%,红色=成功率<50%) - 柱状图每 2 秒自动更新,用户调用 API 后立即看到柱子"跳动",提供直观的脉冲式活跃度展示 - 涉及文件:`backend-go/internal/metrics/channel_metrics.go`、`backend-go/internal/handlers/channel_metrics_handler.go`、`frontend/src/components/ChannelOrchestration.vue`、`frontend/src/services/api.ts`、`frontend/src/App.vue` --- ## [v2.4.32] - 2026-01-14 ### ✨ 新增 - **Gemini 渠道支持 thinking 模式函数调用签名传递** - `GeminiFunctionCall` 结构体新增 `ThoughtSignature` 字段 - 用于 thinking 模式下的签名,需原样传回上游 - 涉及文件:`backend-go/internal/types/gemini.go` ### 🔧 优化 - **Gemini 渠道添加模态框增强** - 扩展服务类型和模型选项 - 服务类型新增 OpenAI 和 Claude 选项,支持更多上游协议 - 更新 Gemini 模型列表:新增 gemini-2、gemini-2.5-flash-lite、gemini-2.5-flash-image、TTS 预览模型、gemini-3 系列预览模型 - 涉及文件:`frontend/src/components/AddChannelModal.vue` ### 🐛 修复 - **修复快速输入解析器冒号分隔导致 URL 被截断的问题** - 增强 `extractTokens()` 函数支持冒号作为分隔符,同时保护 URL 完整性 - 新增 URL 占位符机制:先提取完整 URL 并替换为占位符,分割后再恢复 - 支持中文标点分隔符:逗号(,)、分号(;)、冒号(:) - 涉及文件:`frontend/src/utils/quickInputParser.ts` --- ## [v2.4.31] - 2026-01-12 ### 🐛 修复 - **修复流式工具调用输出稳定性和合并逻辑** - 增强 `stream_synthesizer.go` 的工具调用处理 - 工具调用输出按 index 排序,避免 map 遍历顺序不稳定导致日志顺序随机 - 修复 ID 生成错误:`string(rune(index))` 改为 `strconv.Itoa(index)`,避免非 ASCII 字符 - 合并逻辑增强:仅合并连续 index 的工具调用,防止误合并不相关调用 - 新增 ID 匹配检查:合并时验证两个 block 的 ID 一致(或其中一个为空) - 支持 ID 补全:合并时若 curr 无 ID 但 next 有,自动补全 - 涉及文件:`backend-go/internal/utils/stream_synthesizer.go` --- ## [v2.4.30] - 2026-01-10 ### 🐛 修复 - **修复流式响应工具调用分裂问题** - 当上游返回的工具调用被意外分成两个 content_block 时自动合并 - 问题场景:第一个 block 有 name 和 id 但参数为空 "{}",第二个 block 没有 name 但有完整参数 - 新增 `mergeSplitToolCalls()` 方法检测并合并分裂的工具调用 - 在 `GetSynthesizedContent()` 中调用,确保日志输出正确的工具调用信息 - 涉及文件:`backend-go/internal/utils/stream_synthesizer.go` --- ## [v2.4.29] - 2026-01-10 ### 🐛 修复 - **修复空 signature 字段导致 Claude API 400 错误** - 客户端可能发送带空 `signature` 字段(空字符串或 null)的请求,Claude API 会拒绝并返回 400 错误 - 新增 `RemoveEmptySignatures()` 函数,定向移除 `messages[*].content[*].signature` 路径下的空值 - 使用 `json.Decoder` 保留数字精度,`SetEscapeHTML(false)` 保持原始格式 - **注意**:当请求体被修改时,JSON 字段顺序可能发生变化(不影响 API 语义) - 在 Messages Handler 入口处调用预处理,确保请求发送前清理无效字段 - 涉及文件:`backend-go/internal/handlers/common/request.go`、`backend-go/internal/handlers/messages/handler.go` ### ✨ 改进 - **增强 Trace 亲和性日志记录** - 在关键操作点添加详细日志,方便排查亲和性相关问题 - `[Affinity-Set]` 记录新建/变更用户亲和 - `[Affinity-Remove]` 记录手动移除用户亲和 - `[Affinity-RemoveByChannel]` 记录渠道移除时批量清理 - `[Affinity-Cleanup]` 记录定时清理过期记录 - 日志在锁外执行,避免高负载下的尾延迟 - 用户 ID 分级脱敏:短 ID 也保留部分字符便于关联 - 涉及文件:`backend-go/internal/session/trace_affinity.go` ## [v2.4.28] - 2026-01-07 ### 🐛 修复 - **修复内容审核错误导致无限重试问题** - 当上游返回 `sensitive_words_detected` 等内容审核错误时,单渠道场景下会无限重试 - 根因:`classifyByStatusCode(500)` 触发 failover,但未检查 `error.code` 字段中的不可重试错误码 - 新增 `isNonRetryableErrorCode()` 函数,检测内容审核和无效请求错误码 - 新增 `isNonRetryableError()` 函数,从响应体提取并检测不可重试错误 - 在 `shouldRetryWithNextKeyNormal()` 和 `shouldRetryWithNextKeyFuzzy()` 入口处优先检测 - 不可重试错误码:`sensitive_words_detected`、`content_policy_violation`、`content_filter`、`content_blocked`、`moderation_blocked`、`invalid_request`、`invalid_request_error`、`bad_request` - 涉及文件:`backend-go/internal/handlers/common/failover.go` ### 🧪 测试 - **新增不可重试错误码测试** - 覆盖 `sensitive_words_detected` 等错误码在 Normal/Fuzzy 模式下的行为 - 涉及文件:`backend-go/internal/handlers/common/failover_test.go` ## [v2.4.27] - 2026-01-05 ### 🐛 修复 - **修复多端点 failover 渠道统计丢失问题** - 当渠道配置多个 `baseUrls` 时,请求路由到非主 URL 后指标无法正确聚合到渠道统计 - 根因:指标存储使用 `hash(baseURL + apiKey)` 作为键,但查询方法只使用主 BaseURL - 新增 4 个多 URL 聚合方法:`GetHistoricalStatsMultiURL`、`GetChannelKeyUsageInfoMultiURL`、`GetKeyHistoricalStatsMultiURL`、`calculateAggregatedTimeWindowsMultiURL` - `ToResponseMultiURL` 按 API Key 去重聚合,避免同一 Key 在多 URL 场景下产生重复条目 - Handler 层全部改用 `upstream.GetAllBaseURLs()` 获取所有 URL 进行聚合 - 涉及文件:`backend-go/internal/metrics/channel_metrics.go`、`backend-go/internal/handlers/channel_metrics_handler.go` ## [v2.4.26] - 2026-01-05 ### 🐛 修复 - **修复 Key 趋势图切换时间范围后不刷新问题** - 持久化 view/duration 选择到 localStorage,使用 requestId 防止自动刷新旧响应覆盖新选择 - 涉及文件:`frontend/src/components/KeyTrendChart.vue` - **修复 KeyTrendChart SSR 兼容性和健壮性问题** - 添加 `isLocalStorageAvailable()` 检查,防止 SSR 环境下访问 localStorage 崩溃 - 为 localStorage 读写操作添加 try/catch 异常捕获(配额超限、隐私模式等场景) - 添加 `channelType` prop 变化监听,切换渠道类型时自动重载偏好设置并刷新数据 - 优化 channelType watcher 逻辑,避免与 duration watcher 重复触发刷新 - 涉及文件:`frontend/src/components/KeyTrendChart.vue` - **修复缓存创建统计缺失问题** - 当上游仅返回 TTL 细分字段(5m/1h)时,兜底汇总为 cacheCreationTokens - 涉及文件:`backend-go/internal/metrics/channel_metrics.go` - **透传缓存 TTL 细分字段到指标层** - Responses 非流式/流式 usage 现在包含 CacheCreation5m/1h + CacheTTL - 涉及文件:`backend-go/internal/handlers/responses/handler.go` ### 🧪 测试 - **新增 TTL 细分字段兜底测试** - 覆盖 cache_creation_input_tokens 为 0 时的汇总场景 - 涉及文件:`backend-go/internal/metrics/channel_metrics_cache_stats_test.go` ## [v2.4.25] - 2026-01-04 ### 🧪 测试 - **新增 baseUrl/baseUrls 一致性测试套件** - 覆盖 URL 配置的完整场景,防止编辑渠道时数据不一致问题回归 - `TestUpdateUpstream_BaseURLConsistency`: 验证 Messages 渠道更新时 baseUrl/baseUrls 的一致性(4 场景) - `TestUpdateResponsesUpstream_BaseURLConsistency`: 验证 Responses 渠道更新一致性 - `TestUpdateGeminiUpstream_BaseURLConsistency`: 验证 Gemini 渠道更新一致性 - `TestGetAllBaseURLs_Priority`: 验证 URL 获取优先级逻辑(4 场景) - `TestGetEffectiveBaseURL_Priority`: 验证有效 URL 选择逻辑(3 场景) - `TestDeduplicateBaseURLs`: 验证 URL 去重逻辑(7 场景,含末尾斜杠/井号差异) - `TestAddUpstream_BaseURLDeduplication`: 验证添加渠道时的 URL 去重 - 涉及文件:`internal/config/config_baseurl_test.go`(新增 414 行) ### 🐛 修复 - **修复历史分桶边界导致边界点漏算** - 历史统计 API 的时间过滤条件从开区间 `(startTime, endTime)` 改为半开区间 `[startTime, endTime)`,避免恰好落在 startTime 的记录被遗漏 - 涉及文件:`internal/metrics/channel_metrics.go` - **修复历史图表时间戳错位** - 将返回的 Timestamp 从"桶结束时间"改为"桶起始时间",前端图表不再出现一格偏差 - 涉及文件:`internal/metrics/channel_metrics.go` - **修复成功计数可能重复记录** - 移除多渠道/单渠道成功路径上多余的 `RecordSuccess()` 调用,统一使用 `RecordSuccessWithUsage()` 作为唯一成功计数入口 - Messages 路径:移除重复调用,保留流式/非流式末尾的 `RecordSuccessWithUsage` - Responses compact 路径:改用 `RecordSuccessWithUsage(nil)` 替代原 `RecordSuccess`,保持指标一致性 - 涉及文件:`internal/handlers/messages/handler.go`、`internal/handlers/responses/compact.go` - **修复多 BaseURL 故障转移时成功指标归属错误** - 当请求通过 fallback BaseURL 成功时,成功指标错误地记录到主 BaseURL 而非实际成功的 URL - 根本原因:`handleNormalResponse` 和 `HandleStreamResponse` 接收的是原始 `upstream` 而非设置了 `currentBaseURL` 的 `upstreamCopy` - 修复方式:将两处调用点的参数从 `upstream` 改为 `upstreamCopy` - 影响范围:多渠道/单渠道的流式与非流式响应处理 - 涉及文件:`internal/handlers/messages/handler.go` --- ## [v2.4.24] - 2026-01-04 ### ✨ 新功能 - **缓存命中率统计** - 按 Token 口径展示各渠道缓存读/写与命中率: - 后端:在 `timeWindows` 聚合统计中新增 `inputTokens`/`outputTokens`/`cacheCreationTokens`/`cacheReadTokens`/`cacheHitRate` 字段 - 命中率定义:`cacheReadTokens / (cacheReadTokens + inputTokens) * 100` - 前端:渠道编排列表在 15 分钟有请求时额外显示缓存命中率,tooltip 中按 15m/1h/6h/24h 展示缓存统计 - 新字段均为 `omitempty`,向后兼容 ### 🎨 优化 - **调整渠道指标显示间距** - 优化缓存命中率 chip 与请求数之间的间距,避免布局拥挤 --- ## [v2.4.23] - 2026-01-03 ### ✨ 新功能 - **lowQuality 模式输出完整的 token 验证过程日志** - 启用低质量渠道时,日志会显示完整的验证过程: - 偏差 > 5% 时显示修补详情 - 偏差 ≤ 5% 时显示保留上游值 - 上游返回无效值时显示本地估算值 ### 🐛 修复 - **修复渠道列表 API 未返回 `lowQuality` 字段** - 在 `GetUpstreams` 和 `GetChannelDashboard` 函数返回的 JSON 中补充 `lowQuality` 字段: - 之前前端编辑渠道时无法正确显示已保存的"低质量渠道"开关状态 - 涉及文件:`handlers/messages/channels.go`、`handlers/responses/channels.go`、`handlers/gemini/channels.go`、`handlers/channel_metrics_handler.go` --- ## [v2.4.22] - 2026-01-02 ### ✨ 新功能 - **低质量渠道处理机制** - 新增 `lowQuality` 渠道配置选项,用于处理返回不完整数据的上游渠道: - Token 偏差检测:启用后对比上游返回值与本地估算值,偏差 > 5% 时使用本地估算值 - Model 一致性检查:验证响应中的 model 是否与请求一致,不一致则改写为请求的 model - 空 ID 补全:自动补全上游返回的空 `message.id`(生成 `msg_` 格式) - 前端支持:渠道编辑 modal 新增"低质量渠道"开关 ### 🐛 修复 - **暂停渠道时自动清除促销期** - 当用户暂停一个正在抢优先级的渠道时,自动清除其 `promotionUntil` 字段: - 避免暂停后仍显示促销期标识 - 涉及三个渠道类型:Messages、Responses、Gemini - 涉及文件:`config_messages.go`、`config_responses.go`、`config_gemini.go` - **修复 `lowQuality` 字段更新不持久化的问题** - 在 `UpdateUpstream` 系列函数中补充 `LowQuality` 字段处理: - 之前前端切换"低质量渠道"开关后变更不会被保存 - 涉及文件:`config_messages.go`、`config_responses.go`、`config_gemini.go` - **修复渠道列表 API 未返回 `lowQuality` 字段** - 在 `GetUpstreams` 和 `GetChannelDashboard` 函数返回的 JSON 中补充 `lowQuality` 字段: - 之前前端编辑渠道时无法正确显示已保存的"低质量渠道"开关状态 - 涉及文件:`handlers/messages/channels.go`、`handlers/responses/channels.go`、`handlers/gemini/channels.go`、`handlers/channel_metrics_handler.go` --- ## [v2.4.21] - 2026-01-02 ### 🐛 修复 - **修复流式响应 input_tokens 为 nil 时丢失的问题** - 当上游返回的顶层 usage 中 `input_tokens` 为 `nil` 时,之前从 `message.usage` 收集到的有效值无法被修补: - 原因:`patchUsageFieldsWithLog` 和 `checkUsageFieldsWithPatch` 函数中类型断言 `.(float64)` 失败时跳过了修补逻辑 - 表现:日志显示 `InputTokens=` 而非之前收集到的有效值(如 10920) - 修复:在两处函数中新增 `input_tokens == nil` 检测,无论是否有缓存 token 都用收集到的值修补 - 涉及文件:`backend-go/internal/handlers/common/stream.go` --- ## [v2.4.18] - 2025-12-31 ### 🐛 修复 - **Gemini 日志和 Header 透传改进** - 修复 Gemini 接口的日志显示和请求头处理: - 修复 `contents`/`parts` 字段在日志中不显示的问题 - 修复原生 Gemini handler 未透传客户端 Header 的问题 - 新增 `compactGeminiContentsArray` 和 `compactGeminiPart` 函数 - 涉及文件:`backend-go/internal/utils/json.go`、`backend-go/internal/handlers/gemini/handler.go` ### 🔧 重构 - **Gemini tools 日志简化支持** - 新增 `extractToolNames` 函数支持 Gemini 格式的工具提取: - 支持 Gemini `functionDeclarations` 数组格式 - 兼容 Claude 和 OpenAI 格式 - 日志中 tools 字段现在统一显示为 `["tool1", "tool2", ...]` 格式 - 涉及文件:`backend-go/internal/utils/json.go` - **移除非标准 Gemini API 路由** - 简化 API 端点,仅保留官方格式: - 移除:`POST /v1/models/{model}:generateContent`(非标准简化格式) - 保留:`POST /v1beta/models/{model}:generateContent`(Gemini 官方格式) - 更新前端预览 URL 显示完整路径格式 `/models/{model}:generateContent` - 涉及文件:`backend-go/main.go`、`frontend/src/components/AddChannelModal.vue` --- ## [v2.4.17] - 2025-12-30 ### 🐛 修复 - **修复 ModelMapping 导致请求字段丢失** - 解决使用模型重定向时 Claude API 返回 403 的问题: - 原因:`ClaudeRequest` 结构体缺少 `metadata` 字段,JSON 反序列化时该字段被丢弃 - 表现:配置 `modelMapping` 后请求被上游拒绝(如 `opus` → `claude-opus-4-5-20251101`) - 修复:在 `ClaudeRequest` 中添加 `Metadata map[string]interface{}` 字段 - 涉及文件:`backend-go/internal/types/types.go` --- ## [v2.4.16] - 2025-12-30 ### 🐛 修复 - **修复 Gemini 渠道预期请求 URL 预览** - 创建渠道时预览显示正确的 `/v1beta` 路径: - 原问题:Gemini 渠道预览错误显示 `/v1` 而后端实际使用 `/v1beta` - 修复:当 serviceType 为 gemini 时使用 `/v1beta` 作为版本前缀 - 涉及文件:`frontend/src/components/AddChannelModal.vue` --- ## [v2.4.15] - 2025-12-30 ### 🐛 修复 - **修复 Gemini API 路由注册失败** - 解决 Gin 框架路由 panic 问题: - 原因:Gin 不支持 `:param\:literal` 格式,即使转义冒号也会被解析为两个通配符 - 方案:使用 `*modelAction` 通配符捕获 `model:action` 整体,在 handler 内解析 - 涉及文件:`main.go`、`internal/handlers/gemini/handler.go` ### ✨ 新功能 - **Gemini 历史指标 API 完整实现** - 补全 Gemini 模块的历史数据端点: - `GET /api/gemini/channels/metrics/history` - 渠道级别指标历史 - `GET /api/gemini/channels/:id/keys/metrics/history` - Key 级别指标历史 - `GET /api/gemini/global/stats/history` - 全局统计历史 - 涉及文件:`internal/handlers/channel_metrics_handler.go`、`main.go` - **Gemini 前端管理界面完整实现** - 与 Messages/Responses 功能完全对齐: - 新增 Gemini Tab 切换,支持完整渠道 CRUD、Key 管理、状态/促销设置 - KeyTrendChart 和 GlobalStatsChart 组件支持 Gemini 数据展示(移除降级显示) - 涉及文件:`frontend/src/App.vue`、`frontend/src/components/`、`frontend/src/services/api.ts` --- ## [v2.4.14] - 2025-12-29 ### ✨ 新功能 - **新增 Gemini API 模块** - 与 `/v1/messages`、`/v1/responses` 同级的完整 Gemini 代理支持: - **代理端点**:`POST /v1/models/{model}:generateContent`(非流式)、`:streamGenerateContent`(流式) - **协议转换**:支持 Gemini 请求转发到 Claude/OpenAI/Gemini 上游,双向转换器自动处理格式差异 - **渠道管理 API**:完整 CRUD、API Key 管理、状态/促销设置、指标监控(`/api/gemini/channels/*`) - **多渠道调度**:集成 ChannelScheduler,支持优先级、熔断、Trace 亲和性 - **认证方式**:兼容 Gemini 原生格式(`x-goog-api-key` 头、`?key=` 参数) - 涉及文件:`internal/handlers/gemini/`、`internal/converters/gemini_converter.go`、`internal/types/gemini.go` ### 🔧 重构 - **config 包模块化拆分** - 将 1973 行的单文件拆分为 6 个职责清晰的模块: - `config.go`(297 行):核心类型定义 + 共享方法 - `config_loader.go`(384 行):配置加载、迁移、验证、文件监听 - `config_messages.go`(429 行):Messages 渠道 CRUD - `config_responses.go`(380 行):Responses 渠道 CRUD - `config_gemini.go`(361 行):Gemini 渠道 CRUD - `config_utils.go`(183 行):工具函数(去重、模型重定向、状态辅助) - 遵循单一职责原则,提升代码可维护性 --- ## [v2.4.12] - 2025-12-29 ### 🐛 修复 - **修复 Responses API 错误消息提取失败的问题** - 解决 upstream_error 字段无法被正确解析: - 扩展 `classifyByErrorMessage` 函数:支持多个消息字段(`message`, `upstream_error`, `detail`) - 支持嵌套对象格式:当 `upstream_error` 为对象时,提取其中的 `message` 字段 - 之前仅检查 `error.message` 字段,导致 `{type, upstream_error}` 格式的错误无法被识别 - 新增 4 个测试用例覆盖 upstream_error 字符串、嵌套对象、detail 字段等场景 - 涉及文件:`internal/handlers/common/failover.go`, `internal/handlers/common/failover_test.go` --- ## [v2.4.11] - 2025-12-29 ### 🐛 修复 - **修复 Fuzzy 模式下 403 + 预扣费消息未触发 Key 降级的问题** - 补充 v2.4.10 修复的遗漏场景: - 修改 `shouldRetryWithNextKeyFuzzy` 函数:新增 `bodyBytes` 参数,对非 402/429 状态码检查消息体中的配额关键词 - 之前 Fuzzy 模式仅检查状态码(402/429 = quota),不解析消息体,导致 403 + "预扣费额度失败" 返回 `isQuotaRelated=false` - 新增 `TestShouldRetryWithNextKey_FuzzyMode_403WithQuotaMessage` 测试用例 - 涉及文件:`internal/handlers/common/failover.go`, `internal/handlers/common/failover_test.go` ### 🔧 调试 - **添加 Key 降级调试日志** - 用于追踪 `isQuotaRelated` 值和密钥降级流程: - 在 `ShouldRetryWithNextKey` 调用后记录返回值(statusCode, shouldFailover, isQuotaRelated) - 在密钥标记为配额相关失败时记录日志 - 涉及文件:`internal/handlers/messages/handler.go` - **改进 .env.example 文档** - 添加日志配置默认值说明(默认启用,需显式设置 false 禁用) --- ## [v2.4.10] - 2025-12-29 ### 🐛 修复 - **修复 403 预扣费额度不足的 Key 未被自动降级的问题** - 解决配额不足的密钥始终被优先尝试: - 修改 `shouldRetryWithNextKeyNormal` 逻辑:即使 HTTP 状态码已触发 failover,仍检查消息体确定是否为配额相关错误 - 之前 403 状态码直接返回 `isQuotaRelated=false`,跳过消息体解析,导致 `DeprioritizeAPIKey` 未被调用 - 新增 "预扣费" 关键词到 `quotaKeywords` 列表,确保匹配中文预扣费错误消息 - 涉及文件:`internal/handlers/common/failover.go` --- ## [v2.4.9] - 2025-12-27 ### 🔧 改进 - **重构 URL 预热机制为非阻塞动态排序** - 解决首次请求延迟 500ms+ 的问题: - 移除阻塞式 ping 预热(`URLWarmupManager`),改用非阻塞的 `URLManager` - 新排序策略:基于实际请求结果动态调整 URL 顺序 - 请求成功:重置失败计数,URL 保持/提升位置 - 请求失败:增加失败计数,URL 移到末尾 - 冷却期机制:失败的 URL 在 30 秒后自动恢复可用 - 排序规则:无失败记录优先 > 冷却期已过 > 仍在冷却期 - 涉及文件:`warmup/url_manager.go`(新建)、`warmup/url_warmup.go`(删除)、`scheduler/channel_scheduler.go`、`messages/handler.go`、`responses/handler.go`、`main.go` --- ## [v2.4.8] - 2025-12-27 ### 🐛 修复 - **修复多端点渠道密钥轮换时的并发竞争问题** - 解决高并发下 BaseURL 被错误修改导致密钥跨渠道混用: - 新增 `UpstreamConfig.Clone()` 深拷贝方法,避免并发修改共享对象 - Messages/Responses Handler 改用深拷贝替代临时修改模式 - 新增 `MarkWarmupURLFailed()` 方法,请求失败时触发预热缓存失效 - HTTP 5xx 和网络超时均会触发预热缓存失效,确保失败端点被重新排序 - 涉及文件:`config/config.go`、`messages/handler.go`、`responses/handler.go`、`scheduler/channel_scheduler.go`、`warmup/url_warmup.go` --- ## [v2.4.6] - 2025-12-27 ### ✨ 新功能 - **多端点预热排序** - 渠道首次访问前自动 ping 所有端点,按延迟排序: - 新增 `internal/warmup/url_warmup.go` 预热管理器模块 - 渠道首次访问时自动并发 ping 所有 BaseURL - 排序策略:成功的端点优先,同类型按延迟从低到高排序 - ping 结果缓存 5 分钟,避免频繁测试 - 支持并发安全的预热请求去重(多个请求同时触发时只执行一次预热) - Messages 和 Responses API 均支持预热排序 --- ## [v2.4.5] - 2025-12-27 ### 🔧 改进 - **统一日志前缀规范** - Messages 和 Responses 接口日志标签标准化: - Messages 流式处理日志统一使用 `[Messages-Stream]`、`[Messages-Stream-Token]` 前缀 - Responses 流式处理日志保持 `[Responses-Stream]`、`[Responses-Stream-Token]` 前缀 - 修复 3 处遗漏前缀的错误日志(`messages/handler.go`、`responses/handler.go`) - 更新 `backend-go/CLAUDE.md` 日志规范文档 --- ## [v2.4.4] - 2025-12-27 ### ✨ 新功能 - **全局流量和 Token 统计图表** - 新增全局统计可视化功能: - 后端新增 `/api/messages/global/stats/history` 和 `/api/responses/global/stats/history` API - 支持请求数量(成功/失败/总量)和 Token 总量(输入/输出)统计 - 前端新增 `GlobalStatsChart.vue` 组件,支持流量/Token 双视图切换 - 时间范围支持 1h / 6h / 24h / 今日 多档位切换 - 用户偏好(时间范围、视图模式)按 Messages/Responses 分别保存到 localStorage - 以顶部可折叠卡片形式展示,随当前 Tab 自动切换对应 API 类型的统计 - **渠道 Key 趋势图表支持"今日"** - KeyTrendChart 新增今日时间范围选项: - 后端 `GetChannelKeyMetricsHistory` 支持 `duration=today` 参数 - 前端添加"今日"按钮,动态计算从今日 0 点到当前的时长 --- ## [v2.4.3] - 2025-12-27 ### 🐛 修复 - **Responses API Token 统计修复** - 解决上游无 usage 时本地统计无数据的问题: - 修复 SSE 事件解析格式兼容性:支持 `data:` 和 `data: ` 两种格式(某些上游不带空格) - 修复 `handleSuccess` / `handleStreamSuccess` 不返回 usage 数据的问题 - 修复调用点使用 `RecordSuccess` 而非 `RecordSuccessWithUsage` 导致 token 统计未入库 - 涉及函数:`checkResponsesEventUsage`、`injectResponsesUsageToCompletedEvent`、`patchResponsesCompletedEventUsage`、`tryChannelWithAllKeys` --- ## [v2.4.2] - 2025-12-26 ### 🐛 修复 - **原始请求日志修复** - 修复多渠道模式下原始请求头/请求体日志不显示的问题: - 将 `LogOriginalRequest` 调用移至 Handler 入口处,确保无论单/多渠道模式都只记录一次 - 移除单渠道处理函数中重复的日志调用和未使用变量 - 同时修复 Messages 和 Responses 两个处理器 ### 🧹 清理 - **移除废弃环境变量 `LOAD_BALANCE_STRATEGY`** - 负载均衡策略已迁移至 config.json 热重载配置: - 删除 `env.go` 中 `LoadBalanceStrategy` 字段 - 更新 `.env.example`、`docker-compose.yml`、`README.md` 移除相关配置 - 更新 `CLAUDE.md` 添加配置方式说明 --- ## [v2.4.0] - 2025-12-26 ### ✨ 改进 - **渠道编辑表单优化** - 改进 AddChannelModal 用户体验: - 预期请求支持显示所有 BaseURL 端点,而非仅显示首个 - 修复 Gemini 类型渠道预期请求显示错误端点的问题(应为 `/generateContent`) - 修复从快速模式切换到详细模式时 BaseURL 输入框为空的问题 - 表单字段重排:TLS 验证开关和描述字段移至表单末尾 - BaseURL 输入框不再自动修改用户输入,仅在提交时进行去重处理 - 调整预期请求区域下方间距,改善视觉效果 - **API Key/BaseURL 策略简化** - 移除过度设计,采用纯 failover 模式: - 删除 `ResourceAffinityManager` 及相关代码(资源亲和性) - 移除 API Key 策略选择(round-robin/random/failover),始终使用优先级顺序 - 移除 BaseURL 策略选择,始终使用优先级顺序并在失败时切换 - 前端删除策略选择器,简化渠道配置界面 - 保留渠道级 Trace 亲和性(TraceAffinityManager)用于会话一致性 - 清理遗留无用代码:`requestCount`/`responsesRequestCount` 字段、`EnableStreamEventDedup` 环境变量 ### 🐛 修复 - **多 BaseURL failover 失效** - 修复当所有 API Key 在首个 BaseURL 失败后不会切换到下一个 BaseURL 的问题: - 重构 `tryChannelWithAllKeys` 函数,采用嵌套循环遍历所有 BaseURL - 重构 `handleSingleChannel` 函数,单渠道模式也支持多 BaseURL failover - 每个 BaseURL 尝试所有 Key 后,若全部失败则自动切换下一个 - 每次切换 BaseURL 时重置失败 Key 列表 - 同时修复 Messages 和 Responses 两个处理器 - 修复 `GetEffectiveBaseURL()` 优先级:临时设置的 `BaseURL` 字段优先于 `BaseURLs` 数组 - 移除废弃代码:`MarkBaseURLFailed()`、`baseURLIndex` 字段 - **SSE 流式事件完整性** - 修复 Claude Provider 流式响应可能在事件边界处截断的问题: - 改用事件缓冲机制,按空行分隔完整 SSE 事件后再转发 - 确保 `event:`/`data:`/`id:`/`retry:` 等字段作为整体发送 - 处理上游未以空行结尾的边界情况 - **前端延迟测试结果被覆盖** - 修复 ping 延迟值显示几秒后消失的问题: - 新增 `mergeChannelsWithLocalData()` 函数保留本地延迟测试结果 - 应用于自动刷新、Tab 切换、手动刷新三处数据更新点 - 添加 5 分钟有效期检查,确保过期数据自动清除 --- ## [v2.3.11] - 2025-12-26 ### 🐛 修复 - **Responses API usage 字段缺失** - 修复当上游服务(OpenAI/Gemini)不返回 usage 信息时,`response.completed` 事件完全不包含 `usage` 字段的问题: - 转换器现在始终生成基础 `usage` 字段(`input_tokens`、`output_tokens`、`total_tokens`),即使值为 0 - Handler 检测到 usage 存在后,会用本地 token 估算值替换 0 值 - 确保下游客户端始终能获得合理的 token 使用估算 ### ✨ 新功能 - **API Key/Base URL 去重** - 前后端全链路自动去重: - 前端详细表单模式输入时自动过滤重复 URL(忽略末尾 `/` 和 `#` 差异) - 后端 AddUpstream/UpdateUpstream 接口添加去重逻辑 - 同时覆盖 Messages 和 Responses 渠道 ### 🔧 改进 - **API Key 策略推荐调整** - 将默认推荐策略从"轮询"改为"故障转移",更符合实际使用场景 - **延迟测试结果持久显示** - 优化渠道延迟测试体验: - 测试结果直接显示在故障转移序列列表中,不再使用短暂 Toast 通知 - 延迟结果保持显示 5 分钟后自动清除 - 支持单个渠道测试和批量测试统一行为 --- ## [v2.3.10] - 2025-12-25 ### ✨ 新功能 - **快速添加支持等号分割** - 输入 `KEY=value` 格式时自动按等号分割,识别 `value` 为 API Key - **快速添加支持多 Base URL** - 自动识别输入中所有 HTTP 链接作为 Base URL(最多 10 个) - **多 URL 预期请求展示** - 快速添加模式下逐一展示每个 URL 的预期请求地址 --- ## [v2.3.9] - 2025-12-25 ### ✨ 新功能 - **渠道级 API Key 策略** - 每个渠道可独立配置 API Key 分配策略: - `round-robin`(默认):轮询分发请求到不同 Key - `random`:随机选择 Key - `failover`:故障转移,优先使用第一个 Key - 单 Key 时自动强制使用 `failover`,UI 显示禁用状态 - **多 BaseURL 支持** - 单个渠道可配置多个 BaseURL,支持三种策略: - `round-robin`(默认):轮询分发请求,自动分散负载 - `random`:随机选择 URL - `failover`:手动故障转移(需配合外部监控切换) - **促销期状态展示** - 渠道列表显示正在"抢优先级"的渠道,带火箭图标和剩余时间 - **延迟测试优化** - 批量测试时直接在列表显示每个渠道的延迟值,颜色根据延迟等级变化(绿/黄/红) - **多 URL 延迟测试** - 当渠道配置多个 BaseURL 时,并发测试所有 URL 并显示最快的延迟 - **资源亲和性** - 记录用户成功使用的 BaseURL 和 API Key 索引,后续请求优先使用相同资源组合,减少不必要的资源切换 --- ## [v2.3.8] - 2025-12-24 ### 🔨 重构 - **日志输出规范化** - 移除所有 emoji 符号,统一使用 `[Component-Action]` 标签格式,确保跨平台兼容性 --- ## [v2.3.7] - 2025-12-24 ### 🐛 修复 - **滑动窗口重建逻辑优化** - 服务重启时只从最近 15 分钟的历史记录重建滑动窗口,避免历史失败记录导致渠道长期处于不健康状态 --- ## [v2.3.6] - 2025-12-24 ### ✨ 新功能 - **快速添加渠道 - API Key 识别增强** - 大幅改进 `quickInputParser` 的密钥识别能力 - 新增各平台特定格式支持:OpenAI (sk-/sk-proj-)、Anthropic (sk-ant-api03-)、Google Gemini (AIza)、OpenRouter (sk-or-v1-)、Hugging Face (hf_)、Groq (gsk_)、Perplexity (pplx-)、Replicate (r8_)、智谱 AI (id.secret)、火山引擎 (UUID/AK) - 新增宽松兜底规则:常见前缀 (sk/api/key/ut/hf/gsk/cr/ms/r8/pplx) + 任意后缀,支持识别短密钥如 `sk-111` - 新增配置键名排除:全大写下划线分隔格式 (如 `API_TIMEOUT_MS`) 不再被误识别为密钥 ### 🐛 修复 - **Claude Code settings.json 解析修复** - 粘贴 Claude Code 配置时,不再将键名 (`ANTHROPIC_AUTH_TOKEN` 等) 误识别为 API 密钥 --- ## [v2.3.5] - 2025-12-24 ### ✨ 新功能 - **Responses API Token 统计补全** - 为 Responses 接口添加完整的输入输出 Token 统计功能 - 非流式响应:自动检测上游是否返回 usage,无 usage 时本地估算,修补虚假值(`input_tokens/output_tokens <= 1`) - 流式响应:累积收集流事件中的文本内容,在 `response.completed` 事件中检测并修补 Token 统计 - 新增 `EstimateResponsesRequestTokens`、`EstimateResponsesOutputTokens` 专用估算函数 - 支持缓存 Token 细分统计(5m/1h TTL) - 与 Messages API 保持一致的处理逻辑 ### 🐛 修复 - **缓存 Token 5m/1h 字段检测完善** - 修复缓存 Token 检测逻辑,同时检测 `cache_creation_5m_input_tokens` 和 `cache_creation_1h_input_tokens` 字段 - **类型化 ResponsesItem 处理** - `EstimateResponsesOutputTokens` 现支持直接处理 `[]types.ResponsesItem` 类型 - **total_tokens 零值补全** - 修复当上游返回有效 `input_tokens/output_tokens` 但 `total_tokens` 为 0 时未自动补全的问题(非流式和流式均已修复) - **特殊类型 Token 估算回退** - 当 `ResponsesItem` 的 `Type` 为 `function_call`、`reasoning` 等特殊类型时,自动序列化整个结构进行估算 - **流式 delta 类型扩展** - `extractResponsesTextFromEvent` 现支持更多 delta 事件类型:`output_json.delta`、`content_part.delta`、`audio.delta`、`audio_transcript.delta` - **流式缓冲区内存保护** - `outputTextBuffer` 添加 1MB 大小上限,防止长流式响应导致内存溢出 - **Claude/OpenAI 缓存格式区分** - 新增 `HasClaudeCache` 标志,正确区分 Claude 原生缓存字段(`cache_creation/read_input_tokens`)和 OpenAI 格式(`input_tokens_details.cached_tokens`),避免 OpenAI 格式错误阻止 `input_tokens` 补全 - **流式缓存标志传播** - 修复 `updateResponsesStreamUsage` 未传播 `HasClaudeCache` 标志的问题,确保流式响应正确识别 Claude 缓存 --- ## [v2.3.4] - 2025-12-23 ### ✨ 新功能 - **Models API 增强** - `/v1/models` 端点重大改进 - 使用调度器按故障转移顺序选择渠道(与 Messages/Responses API 一致) - 同时从 Messages 和 Responses 两种渠道获取模型列表并合并去重 - 添加详细日志:渠道名称、脱敏 Key、选择原因 - 移除对 Claude 原生渠道的跳过限制(第三方 Claude 代理通常支持 /models) - 移除不常用的 `DELETE /v1/models/:model` 端点 --- ## [v2.3.3] - 2025-12-23 ### ✨ 新功能 - **Models API 端点支持** - 新增 `/v1/models` 系列端点,转发到上游 OpenAI 兼容服务 - `GET /v1/models` - 获取模型列表 - `GET /v1/models/:model` - 获取单个模型详情 - `DELETE /v1/models/:model` - 删除微调模型 - 自动跳过不支持的 Claude 原生渠道,遍历所有上游直到成功或返回 404 --- ## [v2.3.2] - 2025-12-23 ### ✨ 新功能 - **快速添加渠道自动检测协议类型** - 根据 URL 路径自动选择正确的服务类型 - `/messages` → Claude 协议 - `/chat/completions` → OpenAI 协议 - `/responses` → Responses 协议 - `/generateContent` → Gemini 协议 - **快速添加支持 `%20` 分隔符** - 解析输入时自动将 URL 编码的空格转换为实际空格 --- ## [v2.3.1] - 2025-12-22 ### ✨ 新功能 - **HTTP 响应头超时可配置** - 新增 `RESPONSE_HEADER_TIMEOUT` 环境变量(默认 60 秒,范围 30-120 秒),解决上游响应慢导致的 `http2: timeout awaiting response headers` 错误 --- ## [v2.3.0] - 2025-12-22 ### ✨ 新功能 - **快速添加渠道支持引号内容提取** - 支持从双引号/单引号中提取 URL 和 API Key,可直接粘贴 Claude Code 环境变量 JSON 配置格式 - **SQLite 指标持久化存储** - 服务重启后不再丢失历史指标数据,启动时自动加载最近 24 小时数据 - 新增 `METRICS_PERSISTENCE_ENABLED`(默认 true)和 `METRICS_RETENTION_DAYS`(默认 7)配置 - 异步批量写入(100 条/批或每 30 秒),WAL 模式高并发,自动清理过期数据 - **完整的 Responses API Token Usage 统计** - 支持多格式自动检测(Claude/Gemini/OpenAI)、缓存 TTL 细分统计(5m/1h) - **Messages API 缓存 TTL 细分统计** - 区分 5 分钟和 1 小时 TTL 的缓存创建统计 ### 🔨 重构 - **SQLite 驱动切换为纯 Go 实现** - 从 `go-sqlite3`(CGO)切换为 `modernc.org/sqlite`,简化交叉编译 ### 🐛 修复 - **Usage 解析数值类型健壮性** - 支持 `float64`/`int`/`int64`/`int32` 四种数值类型 - **CachedTokens 重复计算** - `CachedTokens` 仅包含 `cache_read`,不再包含 `cache_creation` - **流式响应纯缓存场景 Usage 丢失** - 有任何 usage 字段时都记录 --- ## [v2.2.0] - 2025-12-21 ### 🔨 重构 - **Handlers 模块重构为同级子包结构** - 将 Messages/Responses API 处理器重构为同级模块,新增 `handlers/common/` 公共包,代码量减少约 180 行 ### 🐛 修复 - **Stream 错误处理完善** - 流式传输错误时发送 SSE 错误事件并记录失败指标 - **CountTokens 端点安全加固** - 应用请求体大小限制 - **非 failover 错误指标记录** - 400/401/403 等错误正确记录失败指标 --- ## [v2.1.35] - 2025-12-21 - **流量图表失败率可视化** - 失败率超过 10% 显示红色背景,Tooltip 显示详情 --- ## [v2.1.34] - 2025-12-20 - **Key 级别使用趋势图表** - 支持流量/Token I/O/缓存三种视图,智能 Key 筛选 - **合并 Dashboard API** - 3 个并行请求优化为 1 个 --- ## [v2.1.33] - 2025-12-20 - **Fuzzy Mode 错误处理开关** - 所有非 2xx 错误自动触发 failover - **渠道指标历史数据 API** - 支持时间序列图表 --- ## [v2.1.25] - 2025-12-18 ### ✨ 新功能 - **TransformerMetadata 和 CacheControl 支持** - 转换器元数据保留原始格式信息,实现特性透传 - **FinishReason 统一映射函数** - OpenAI/Anthropic/Responses 三种协议间双向映射 - **原始日志输出开关** - `RAW_LOG_OUTPUT` 环境变量,开启后不进行格式化或截断 --- ## [v2.1.23] - 2025-12-13 - 修复编辑渠道弹窗中基础 URL 布局和验证问题 --- ## [v2.1.31] - 2025-12-19 - **前端显示版本号和更新检查** - 自动检查 GitHub 最新版本 --- ## [v2.1.30] - 2025-12-19 - **强制探测模式** - 所有 Key 熔断时自动启用强制探测 --- ## [v2.1.28] - 2025-12-19 - **BaseURL 支持 `#` 结尾跳过自动添加 `/v1`** --- ## [v2.1.27] - 2025-12-19 - 移除 Claude Provider 畸形 tool_call 修复逻辑 --- ## [v2.1.26] - 2025-12-19 - Responses 渠道新增 `gpt-5.2-codex` 模型选项 --- ## [v2.1.24] - 2025-12-17 - Responses 渠道新增 `gpt-5.2`、`gpt-5` 模型选项 - 移除 openaiold 服务类型支持 --- ## [v2.1.23] - 2025-12-13 - 修复 402 状态码未触发 failover 的问题 - 重构 HTTP 状态码 failover 判断逻辑(两层分类策略) --- ## [v2.1.22] - 2025-12-13 ### 🐛 修复 - **流式日志合成器类型修复** - 所有 Provider 的 HandleStreamResponse 都将响应转换为 Claude SSE 格式,日志合成器使用 "claude" 类型解析 - **insecureSkipVerify 字段提交修复** - 修复前端 insecureSkipVerify 为 false 时不提交的问题 --- ## [v2.1.21] - 2025-12-13 ### 🐛 修复 - **促销渠道绕过健康检查** - 促销渠道现在绕过健康检查直接尝试使用,只有本次请求实际失败后才跳过 --- ## [v2.1.20] - 2025-12-12 - 渠道名称支持点击打开编辑弹窗 --- ## [v2.1.19] - 2025-12-12 - 修复添加渠道弹窗密钥重复错误状态残留 - 新增 `/v1/responses/compact` 端点 --- ## [v2.1.15] - 2025-12-12 ### 🔒 安全加固 - **请求体大小限制** - 新增 `MAX_REQUEST_BODY_SIZE_MB` 环境变量(默认 50MB),超限返回 413 - **Goroutine 泄漏修复** - ConfigManager 添加 `stopChan` 和 `Close()` 方法释放资源 - **数据竞争修复** - 负载均衡计数器改用 `sync/atomic` 原子操作 - **优雅关闭** - 监听 SIGINT/SIGTERM,10 秒超时优雅关闭 --- ## [v2.1.14] - 2025-12-12 - 修复流式响应 Token 计数中间更新被覆盖 --- ## [v2.1.12] - 2025-12-11 - 支持 Claude 缓存 Token 计数 --- ## [v2.1.10] - 2025-12-11 - 修复流式响应 Token 计数补全逻辑 --- ## [v2.1.8] - 2025-12-11 - 重构过长方法,提升代码可读性 --- ## [v2.1.7] - 2025-12-11 ### 🐛 修复 - 修复前端 MDI 图标无法显示 - **Token 计数补全虚假值处理** - 当 `input_tokens <= 1` 或 `output_tokens == 0` 时用本地估算值覆盖 --- ## [v2.1.6] - 2025-12-11 ### ✨ 新功能 - **Messages API Token 计数补全** - 当上游不返回 usage 时,本地估算 token 数量并附加到响应中 --- ## [v2.1.4] - 2025-12-11 - 修复前端渠道健康度统计不显示数据 --- ## [v2.1.1] - 2025-12-11 - 新增 `QUIET_POLLING_LOGS` 环境变量(默认 true),过滤前端轮询日志噪音 --- ## [v2.1.0] - 2025-12-11 ### 🔨 重构 - **指标系统重构:Key 级别绑定** - 指标键改为 `hash(baseURL + apiKey)`,每个 Key 独立追踪 - **熔断器生效修复** - 在 `tryChannelWithAllKeys` 中调用 `ShouldSuspendKey()` 跳过熔断的 Key - **单渠道路径指标记录** - 转换失败、发送失败、failover、成功时正确记录指标 --- ## [v2.0.20-go] - 2025-12-08 - 修复单渠道模式渠道选择逻辑 --- ## [v2.0.11-go] - 2025-12-06 ### 🚀 多渠道智能调度器 - **ChannelScheduler** - 基于优先级的渠道选择、Trace 亲和性、失败率检测和自动熔断 - **MetricsManager** - 滑动窗口算法计算实时成功率 - **TraceAffinityManager** - 用户会话与渠道绑定 ### 🎨 渠道编排面板 - 拖拽排序、实时指标、状态切换、备用池管理 --- ## [v2.0.10-go] - 2025-12-06 ### 🎨 复古像素主题 - Neo-Brutalism 设计语言:无圆角、等宽字体、粗实体边框、硬阴影 --- ## [v2.0.5-go] - 2025-11-15 ### 🚀 Responses API 转换器架构重构 - 策略模式 + 工厂模式实现多上游转换器 - 完整支持 Responses API 标准格式 --- ## [v2.0.4-go] - 2025-11-14 ### ✨ Responses API 透明转发 - Codex Responses API 端点 (`/v1/responses`) - 会话管理系统(多轮对话跟踪) - Messages API 多上游协议支持(Claude/OpenAI/Gemini) --- ## [v2.0.0-go] - 2025-10-15 ### 🎉 Go 语言重写版本 - **性能提升**: 启动速度 20x,内存占用 -70% - **单文件部署**: 前端资源嵌入二进制 - **完整功能移植**: 所有上游适配器、协议转换、流式响应、配置热重载 --- ## 历史版本
v1.x TypeScript 版本 ### v1.2.0 - 2025-09-19 - Web 管理界面、模型映射、渠道置顶、API 密钥故障转移 ### v1.1.0 - 2025-09-17 - SSE 数据解析优化、Bearer Token 处理简化、代码重构 ### v1.0.0 - 2025-09-13 - 初始版本:多上游支持、负载均衡、配置管理
================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## 项目概述 Claude / Codex / Gemini API Proxy - 支持多上游 AI 服务的协议转换代理,提供 Web 管理界面和统一 API 入口。 **技术栈**: Go 1.22 (后端) + Vue 3 + Vuetify (前端) + Docker ## 常用命令 ```bash # 根目录(推荐) make dev # Go 后端热重载开发(不含前端) make run # 构建前端并运行 Go 后端 make frontend-dev # 前端开发服务器 make build # 构建前端并编译 Go 后端 make clean # 清理构建文件 docker-compose up -d # Docker 部署 # Go 后端开发 (backend-go/) make dev # 热重载开发模式 make test # 运行所有测试 make test-cover # 测试 + 覆盖率报告(生成 coverage.html) make build # 构建生产版本 make lint # 代码检查(需要 golangci-lint) make fmt # 格式化代码 make deps # 更新依赖 # 运行特定测试 go test -v ./internal/converters/... # 运行单个包测试 go test -v -run TestName ./internal/... # 运行单个测试 # 前端开发 (frontend/) bun install && bun run dev # 开发服务器 bun run build # 生产构建 ``` ## 架构概览 ``` claude-proxy/ ├── backend-go/ # Go 后端(主程序) │ ├── main.go # 入口、路由配置 │ └── internal/ │ ├── handlers/ # HTTP 处理器 (proxy.go, responses.go, config.go) │ ├── providers/ # 上游适配器 (openai.go, gemini.go, claude.go) │ ├── converters/ # 协议转换器(工厂模式) │ ├── scheduler/ # 多渠道调度器(优先级、熔断) │ ├── session/ # 会话管理 + Trace 亲和性 │ ├── metrics/ # 渠道指标(滑动窗口算法) │ ├── config/ # 配置管理(fsnotify 热重载) │ └── middleware/ # 认证、CORS、日志过滤 ├── frontend/ # Vue 3 + Vuetify 前端 │ └── src/ │ ├── components/ # Vue 组件 │ └── services/ # API 服务封装 └── .config/ # 运行时配置(热重载) ``` ## 核心设计模式 1. **Provider Pattern** - `internal/providers/`: 所有上游实现统一 `Provider` 接口 2. **Converter Pattern** - `internal/converters/`: 协议转换,工厂模式创建转换器 3. **Session Manager** - `internal/session/`: 基于 `previous_response_id` 的多轮对话跟踪 4. **Scheduler Pattern** - `internal/scheduler/`: 优先级调度、Trace 亲和性、自动熔断 ## API 端点 **代理端点**: - `POST /v1/messages` - Claude Messages API(支持 OpenAI/Gemini 协议转换) - `POST /v1/messages/count_tokens` - Token 计数 - `POST /v1/responses` - Codex Responses API(支持会话管理) - `POST /v1/responses/compact` - 精简版 Responses API - `GET /health` - 健康检查(无需认证) **管理 API** (`/api/`): - `/api/messages/channels` - Messages 渠道 CRUD - `/api/responses/channels` - Responses 渠道 CRUD - `/api/messages/channels/metrics` - 渠道指标 - `/api/messages/channels/scheduler/stats` - 调度器统计 - `/api/messages/ping/:id` - 渠道连通性测试 ## 关键配置 | 环境变量 | 默认值 | 说明 | |---------|--------|------| | `PORT` | 3000 | 服务器端口 | | `ENV` | production | 运行环境 | | `PROXY_ACCESS_KEY` | - | **必须设置** 访问密钥 | | `QUIET_POLLING_LOGS` | true | 静默轮询日志 | | `MAX_REQUEST_BODY_SIZE_MB` | 50 | 请求体最大大小 | 完整配置参考 `backend-go/.env.example` ## 常见任务 1. **添加新的上游服务**: 在 `internal/providers/` 实现 `Provider` 接口,在 `GetProvider()` 注册 2. **修改协议转换**: 编辑 `internal/converters/` 中的转换器 3. **调整调度策略**: 修改 `internal/scheduler/channel_scheduler.go` 4. **前端界面调整**: 编辑 `frontend/src/components/` 中的 Vue 组件 ## 重要提示 - **Git 操作**: 未经用户明确要求,不要执行 git commit/push/branch 操作 - **配置热重载**: `backend-go/.config/config.json` 修改后自动生效,无需重启 - **环境变量变更**: 修改 `.env` 后需要重启服务 - **认证**: 所有端点(除 `/health`)需要 `x-api-key` 头或 `PROXY_ACCESS_KEY` ## Git 命令注意事项 - 执行 `git add`/`git commit` 前确保在项目根目录 - `git diff` 查看特定文件时使用 `--` 分隔符避免歧义:`git diff -- path/to/file` - 错误示例:`git diff frontend/src/file.vue`(可能报 `unknown revision` 错误) - 正确示例:`git diff -- frontend/src/file.vue` ## 模块文档 - [backend-go/CLAUDE.md](backend-go/CLAUDE.md) - Go 后端详细文档 - [frontend/CLAUDE.md](frontend/CLAUDE.md) - Vue 前端详细文档 - [ARCHITECTURE.md](ARCHITECTURE.md) - 详细架构设计 - [ENVIRONMENT.md](ENVIRONMENT.md) - 完整环境变量配置 ================================================ FILE: CONTRIBUTING.md ================================================ # 贡献指南 本文档为项目贡献者提供了一套标准化的指导,以确保代码库的一致性和高质量。 ## 如何贡献 欢迎通过提交 Issue 和 Pull Request 为本项目贡献力量! 1. Fork 本项目。 2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)。 3. 提交改动 (`git commit -m 'feat: Add some AmazingFeature'`)。 4. 推送到分支 (`git push origin feature/AmazingFeature`)。 5. 开启 Pull Request。 ## 版本规范 项目遵循 **语义化版本 2.0.0 (Semantic Versioning)** 规范。版本格式为 `主版本号.次版本号.修订号` (MAJOR.MINOR.PATCH),版本号递增规则如下: - **主版本号 (MAJOR)**: 当你做了不兼容的 API 修改。 - **次版本号 (MINOR)**: 当你做了向下兼容的功能性新增。 - **修订号 (PATCH)**: 当你做了向下兼容的问题修正。 ## 发布流程 本节为项目维护者提供了版本发布流程概述。贡献者通常不需要直接执行这些步骤。 1. **准备工作**: * 确保本地 `main` 分支是最新的。 * 确认所有计划内的功能和修复已合并。 * 运行类型检查 (`bun run type-check`) 和构建验证 (`bun run build`)。 2. **更新日志**: 更新 `CHANGELOG.md`,新增版本标题,并分类记录变更内容。 3. **更新版本号**: 更新 `package.json` 中的 `version` 字段。 4. **提交**: 提交 `CHANGELOG.md` 和 `package.json` 的修改,提交信息格式为 `chore(release): prepare for vX.Y.Z`。 5. **创建标签**: 为此次提交创建附注标签 `git tag -a vX.Y.Z -m "Release vX.Y.Z"` 并推送到远程。 6. **GitHub Release**: 在 GitHub 上创建 Release,将 `CHANGELOG.md` 中的对应版本内容复制到发布说明中。 ## 编码规范 ### 设计原则 项目严格遵循以下软件工程原则: 1. **KISS 原则 (Keep It Simple, Stupid)**: 追求代码和设计的极致简洁,优先选择最直观的解决方案。 2. **DRY 原则 (Don't Repeat Yourself)**: 消除重复代码,提取共享函数,统一相似功能的实现方式。 3. **YAGNI 原则 (You Aren't Gonna Need It)**: 仅实现当前明确所需的功能,删除未使用的代码和依赖,避免过度设计。 4. **函数式编程优先**: 优先使用 `map`、`reduce`、`filter` 等函数式方法和不可变数据操作。 ### 代码质量标准 - 使用 TypeScript 严格模式,避免 `any` 类型。 - 所有函数都有明确的类型声明。 - 实现优雅的错误处理和日志记录。 - 遵循 Prettier 格式化(2空格、单引号、无分号、宽度120、LF EOL)。 ### 文件命名规范 - **文件名**: `kebab-case` (例: `config-manager.ts`) - **类名**: `PascalCase` (例: `ConfigManager`) - **Vue 组件名**: `PascalCase` (例: `ChannelCard.vue`) - **类型/接口名**: `PascalCase` - **函数名**: `camelCase` (例: `getNextApiKey`) - **常量名**: `SCREAMING_SNAKE_CASE` (例: `DEFAULT_CONFIG`) ### TypeScript 规范 - 使用严格的 TypeScript 配置。 - 所有函数和变量都有明确的类型声明。 - 使用接口定义数据结构。 - 避免使用 `any` 类型。 ## 测试指南 ### 开发测试 在提交代码前,请确保: - 运行 TypeScript 类型检查:`bun run type-check` - 运行构建验证:`bun run build` - 通过健康检查端点 (`GET http://localhost:3000/health`) 进行冒烟测试。 - 对于 UI 变更,在 Pull Request 中包含简短的测试计划和截图/GIF。 ### 提交与 Pull Request 指南 - **Conventional Commits**: 提交信息遵循 `conventional-commits` 规范,例如 `feat:`, `fix:`, `refactor:`, `chore:`。 - 示例: `feat(frontend): add ESC to close modal`, `fix(backend): redact Authorization header`。 - **PR 内容**: Pull Request 必须包含: - 目的说明 - 关联的 Issue (如果有) - 详细的测试步骤 - 配置/环境变量变更说明 - UI 变更的截图/GIF ## 安全与配置提示 - **切勿提交敏感信息**: 永远不要将密钥或敏感配置提交到版本控制中。使用 `.env` 文件和 `backend/config.json` 进行管理。 - **访问密钥**: `PROXY_ACCESS_KEY` 是代理访问的必需密钥。避免在日志中记录完整的 API 密钥。 ## Agent-Specific Notes - 保持代码差异最小化,与现有代码风格保持一致。 - 当行为发生变化时,及时更新相关文档。 - 除非必要,否则避免进行重命名或大规模重构。 ================================================ FILE: DEVELOPMENT.md ================================================ # 开发指南 本文档为开发者提供开发环境配置、工作流程、调试技巧和最佳实践。 > 📚 **相关文档** > - 架构设计和技术选型: [ARCHITECTURE.md](ARCHITECTURE.md) > - 环境变量配置: [ENVIRONMENT.md](ENVIRONMENT.md) > - 贡献规范: [CONTRIBUTING.md](CONTRIBUTING.md) --- ## 🎯 推荐开发方式 | 开发方式 | 启动速度 | 热重载 | 适用场景 | |---------|---------|-------|---------| | **🚀 根目录 Make 命令** | ⚡ 极快 | ✅ 支持 | **推荐:日常开发** | | **🔧 backend-go Make** | ⚡ 极快 | ✅ 支持 | Go 后端专项开发 | | **🐳 Docker** | 🔄 中等 | ❌ 需重启 | 生产环境测试 | --- ## 方式一:🚀 根目录开发(推荐) **适合日常开发,自动处理前端构建和后端启动** ### 快速开始 ```bash # 在项目根目录执行 # 查看所有可用命令 make help # 开发模式(后端热重载) make dev # 构建前端并运行后端 make run # 前端独立开发服务器 make frontend-dev # 完整构建(前端 + 后端) make build # 清理构建产物 make clean ``` ### 开发环境要求 - Go 1.22+ - Make(构建工具) - Bun(前端构建) --- ## 方式二:🔧 backend-go 目录开发 **适合专注 Go 后端开发和调试** ```bash cd backend-go # 查看所有可用命令 make help # 开发模式(支持热重载) make dev # 运行测试 make test # 测试 + 覆盖率 make test-cover # 构建当前平台二进制 make build-current # 构建并运行 make build-run ``` --- ## 🪟 Windows 环境配置 Windows 用户在开发本项目时可能遇到一些工具缺失的问题,以下是常见问题的解决方案。 ### 问题 1: 没有 `make` 命令 Windows 默认不包含 `make` 工具,有以下几种解决方案: #### 方案 A: 安装 Make (推荐) ```powershell # 使用 Chocolatey (推荐) choco install make # 或使用 Scoop scoop install make # 或使用 winget winget install GnuWin32.Make ``` #### 方案 B: 直接使用 Go 命令 (无需安装 make) ```powershell cd backend-go # 替代 make dev (需要先安装 air: go install github.com/air-verse/air@latest) air # 替代 make build go build -o claude-proxy.exe . # 替代 make run go run main.go # 替代 make test go test ./... # 替代 make fmt go fmt ./... ``` ### 问题 2: 没有 `vite` 命令 这是因为前端依赖未安装,`vite` 是项目的开发依赖。 #### 解决步骤 ```powershell cd frontend # 使用 bun 安装依赖 (推荐) bun install # 或使用 npm npm install # 安装完成后运行开发服务器 bun run dev # 或 npm run dev ``` ### Windows 完整开发环境配置 #### 1. 安装包管理器 (可选但推荐) ```powershell # 安装 Scoop (无需管理员权限) Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser irm get.scoop.sh | iex # 或安装 Chocolatey (需要管理员权限) Set-ExecutionPolicy Bypass -Scope Process -Force [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) ``` #### 2. 安装开发工具 ```powershell # 使用 Scoop scoop install git go bun make # 或使用 Chocolatey choco install git golang bun make -y ``` #### 3. 验证安装 ```powershell go version # 应显示 go1.22+ bun --version # 应显示版本号 make --version # 应显示 GNU Make 版本 git --version # 应显示 git 版本 ``` ### Windows 快速启动流程 ```powershell # 1. 克隆项目 git clone https://github.com/BenedictKing/claude-proxy cd claude-proxy # 2. 安装前端依赖 cd frontend bun install # 或 npm install # 3. 配置环境变量 cd ../backend-go copy .env.example .env # 编辑 .env 文件设置 PROXY_ACCESS_KEY # 4. 启动后端 (选择以下方式之一) # 方式 A: 使用 make (如果已安装) make dev # 方式 B: 直接使用 Go go run main.go # 5. 另开终端,启动前端开发服务器 (如需单独开发前端) cd frontend bun run dev ``` ### Windows 常见问题 #### PowerShell 执行策略限制 ```powershell # 如果遇到脚本执行限制,以管理员身份运行 Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser ``` #### 端口被占用 ```powershell # 查看端口占用 netstat -ano | findstr :3000 # 终止占用进程 (替换 PID 为实际进程 ID) taskkill /PID /F ``` #### 路径包含空格 确保项目路径不包含空格和中文字符,推荐使用如 `C:\projects\claude-proxy` 这样的路径。 --- ## 方式三:🐳 Docker 开发 **适合测试生产环境或隔离开发** ```bash # 使用 docker-compose 启动 docker-compose up -d # 查看日志 docker-compose logs -f # 重新构建并启动 docker-compose up -d --build # 停止服务 docker-compose down ``` --- ## 前端独立开发 前端使用 Vue 3 + Vuetify + Vite,可独立开发: ```bash cd frontend # 安装依赖 bun install # 启动开发服务器(端口 5173) bun run dev # 构建生产版本 bun run build # 预览构建结果 bun run preview ``` **开发服务器代理配置**: Vite 开发服务器会自动将 `/api` 和 `/v1` 请求代理到后端(默认 `http://localhost:3000`): ```typescript // frontend/vite.config.ts server: { port: 5173, proxy: { '/api': { target: backendUrl, changeOrigin: true }, '/v1': { target: backendUrl, changeOrigin: true } } } ``` **环境变量**: - `VITE_PROXY_TARGET` - 后端代理目标(默认 `http://localhost:3000`) - `VITE_FRONTEND_PORT` - 前端开发服务器端口(默认 `5173`) --- ## 文件监听策略 ### 配置文件(无需重启) - `backend-go/.config/config.json` - 主配置文件 **变化时**: 自动重载配置,服务保持运行 ### 环境变量文件(需要重启) - `backend-go/.env` - 环境变量配置 **变化时**: 需要重启服务以加载新的环境变量 ## 开发模式特性 ### 1. 热重载开发 (`make dev`) - ✅ Go 源码变化自动重新编译 - ✅ 配置文件变化自动重载(不重启) - ✅ 优雅关闭处理 - ✅ 详细的开发日志 ### 2. 配置热重载 - ✅ 配置文件变化自动重载 - ✅ 无需重启服务器 - ✅ 自动备份配置(最多 10 个) --- ## 🎯 代码质量标准 > 📚 完整的编码规范和设计模式请参考 [ARCHITECTURE.md](ARCHITECTURE.md) ### 编程原则 项目严格遵循以下软件工程原则: #### 1. KISS 原则 (Keep It Simple, Stupid) - 追求代码和设计的极致简洁 - 优先选择最直观的解决方案 - 使用正则表达式替代复杂的字符串处理逻辑 #### 2. DRY 原则 (Don't Repeat Yourself) - 消除重复代码,提取共享函数 - 统一相似功能的实现方式 - 例:`normalizeClaudeRole` 函数的提取和共享 #### 3. YAGNI 原则 (You Aren't Gonna Need It) - 仅实现当前明确所需的功能 - 删除未使用的代码和依赖 - 避免过度设计和未来特性预留 #### 4. 函数式编程优先 - 使用 `map`、`reduce`、`filter` 等函数式方法 - 优先使用不可变数据操作 - 例:命令行参数解析使用 `reduce()` 替代传统循环 ### 代码优化检查清单 在提交代码前,请确保: - [ ] Go 代码通过 `make lint` 检查 - [ ] 通过 `make test` 测试 - [ ] 前端代码通过 `bun run build` 构建验证 ### Go 代码规范 - 使用 `gofmt` 格式化代码 - 遵循 Go 官方代码规范 - 错误处理要完整 - 适当添加注释 ### 命名规范 - **文件名**: snake_case (例: `config_manager.go`) - **函数名**: PascalCase 导出 / camelCase 私有 (例: `GetProvider` / `parseRequest`) - **常量名**: PascalCase 或 SCREAMING_SNAKE_CASE - **接口名**: PascalCase,通常以 -er 结尾 (例: `Provider`) ### 错误处理 ```go result, err := riskyOperation() if err != nil { log.Printf("Operation failed: %v", err) return fmt.Errorf("specific error: %w", err) } ``` ### 日志规范 使用 Go 标准日志或结构化日志: ```go log.Printf("🎯 使用上游: %s", upstream.Name) log.Printf("⚠️ 警告: %s", message) log.Printf("💥 错误: %v", err) ``` ## 🧪 测试策略 ### 手动测试 #### 1. 基础功能测试 ```bash # 测试健康检查 curl http://localhost:3000/health # 测试基础对话 curl -X POST http://localhost:3000/v1/messages \ -H "x-api-key: test-key" \ -H "Content-Type: application/json" \ -d '{"model":"claude-3-5-sonnet-20241022","max_tokens":100,"messages":[{"role":"user","content":"Hello"}]}' # 测试流式响应 curl -X POST http://localhost:3000/v1/messages \ -H "x-api-key: test-key" \ -H "Content-Type: application/json" \ -d '{"model":"claude-3-5-sonnet-20241022","stream":true,"max_tokens":100,"messages":[{"role":"user","content":"Count to 10"}]}' ``` #### 2. 负载均衡测试 ```bash # 添加多个 API 密钥 bun run config key test-upstream add key1 key2 key3 # 设置轮询策略 bun run config balance round-robin # 发送多个请求观察密钥轮换 for i in {1..5}; do curl -X POST http://localhost:3000/v1/messages \ -H "x-api-key: test-key" \ -H "Content-Type: application/json" \ -d '{"model":"claude-3-5-sonnet-20241022","max_tokens":10,"messages":[{"role":"user","content":"Test '$i'"}]}' done ``` ### 集成测试 #### Claude Code 集成测试 1. 配置 Claude Code 使用本地代理 2. 测试基础对话功能 3. 测试工具调用功能 4. 测试流式响应 5. 验证错误处理 #### 压力测试 ```bash # 使用 ab (Apache Bench) 进行压力测试 ab -n 100 -c 10 -p request.json -T application/json \ -H "x-api-key: test-key" \ http://localhost:3000/v1/messages ``` ## 🔧 调试技巧 ### 1. 日志分析 ```bash # 实时查看日志 tail -f server.log # 过滤错误日志 grep -i "error" server.log # 分析请求模式 grep -o "POST /v1/messages" server.log | wc -l ``` ### 2. 配置调试 ```bash # 验证配置文件 cat config.json | jq . # 检查环境变量 env | grep -E "(PORT|LOG_LEVEL)" ``` ### 3. 网络调试 ```bash # 测试上游连接 curl -I https://api.openai.com # 检查 DNS 解析 nslookup api.openai.com # 测试端口连通性 telnet localhost 3000 ``` ## 🚀 部署指南 ### 开发环境部署 ```bash # 1. 克隆项目 git clone https://github.com/BenedictKing/claude-proxy cd claude-proxy # 2. 配置环境变量 cp backend-go/.env.example backend-go/.env vim backend-go/.env # 3. 启动开发服务器 make dev ``` ### 生产环境部署 ```bash # 1. 构建生产版本 make build # 2. 配置环境变量 cp backend-go/.env.example backend-go/.env # 修改 ENV=production 和 PROXY_ACCESS_KEY # 3. 运行服务 ./backend-go/dist/claude-proxy ``` ### Docker 部署 ```bash # 使用预构建镜像 docker-compose up -d # 或本地构建 docker-compose build docker-compose up -d ``` ## 🤝 贡献与发布 ### 贡献指南 欢迎提交 Issue 和 Pull Request! > 📚 详细的贡献规范和提交指南请参考 [CONTRIBUTING.md](CONTRIBUTING.md) ### 版本发布 > 📚 维护者版本发布流程请参考 [RELEASE.md](RELEASE.md) ================================================ FILE: Dockerfile ================================================ # --- 阶段 1: 准备 Bun 运行时 --- FROM oven/bun:alpine AS bun-runtime # --- 阶段 2: 构建阶段 (Go + Bun) --- FROM golang:1.22-alpine AS builder # 声明 VERSION 构建参数(用于 CI 传入版本号,留空则从 VERSION 文件读取) ARG VERSION WORKDIR /src # 安装必要的构建工具和 bun 依赖(libstdc++ libgcc 是 bun:alpine 运行所需) RUN apk add --no-cache git make libstdc++ libgcc # 从 bun-runtime 复制 bun 和 bunx 到 Go 镜像 COPY --from=bun-runtime /usr/local/bin/bun /usr/local/bin/bun COPY --from=bun-runtime /usr/local/bin/bunx /usr/local/bin/bunx # 将 bun 添加到 PATH ENV PATH="/usr/local/bin:${PATH}" # 复制项目必要文件(.dockerignore 会排除不需要的文件) COPY Makefile VERSION ./ COPY frontend/ ./frontend/ COPY backend-go/ ./backend-go/ # 使用 bun 安装前端依赖(比 npm 快 10-100 倍) RUN cd frontend && bun install # 安装 Go 后端依赖(go mod tidy 确保 go.sum 完整) RUN cd backend-go && go mod tidy && go mod download # 使用 Makefile 构建整个项目(前端 + 后端) # 如果 CI 传入了 VERSION 则使用,否则 Makefile 会从 VERSION 文件读取 RUN if [ -n "${VERSION}" ]; then VERSION=${VERSION} make build; else make build; fi # --- 阶段 3: 运行时 --- FROM alpine:latest AS runtime WORKDIR /app # 安装运行时依赖 RUN apk --no-cache add ca-certificates tzdata # 从构建阶段复制 Go 二进制文件(已内嵌前端资源) COPY --from=builder /src/dist/claude-proxy-go /app/claude-proxy # 创建配置目录和日志目录 RUN mkdir -p /app/.config/backups /app/logs # 设置时区(可选) ENV TZ=Asia/Shanghai # 暴露端口 EXPOSE 3000 # 健康检查 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 # 启动命令 CMD ["/app/claude-proxy"] ================================================ FILE: Dockerfile_China ================================================ # --- 阶段 1: 准备 Bun 运行时 --- FROM docker.1ms.run/oven/bun:alpine AS bun-runtime # --- 阶段 2: 构建阶段 (Go + Bun) --- FROM docker.1ms.run/library/golang:1.22-alpine AS builder # 声明 VERSION 构建参数(用于 CI 传入版本号,留空则从 VERSION 文件读取) ARG VERSION WORKDIR /src # 配置 Go 代理(使用国内镜像) ENV GOPROXY=https://goproxy.cn,direct ENV GO111MODULE=on # 配置国内镜像源并安装构建工具和 bun 依赖(libstdc++ libgcc 是 bun:alpine 运行所需) RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \ apk add --no-cache git make libstdc++ libgcc # 从 bun-runtime 复制 bun 和 bunx 到 Go 镜像 COPY --from=bun-runtime /usr/local/bin/bun /usr/local/bin/bun COPY --from=bun-runtime /usr/local/bin/bunx /usr/local/bin/bunx # 将 bun 添加到 PATH ENV PATH="/usr/local/bin:${PATH}" # 复制项目必要文件(.dockerignore 会排除不需要的文件) COPY Makefile VERSION ./ COPY frontend/ ./frontend/ COPY backend-go/ ./backend-go/ # 使用 bun 安装前端依赖(使用国内镜像,比 npm 快 10-100 倍) RUN cd frontend && bun install --registry https://registry.npmmirror.com # 安装 Go 后端依赖(go mod tidy 确保 go.sum 完整) RUN cd backend-go && go mod tidy && go mod download # 使用 Makefile 构建整个项目(前端 + 后端) # 如果 CI 传入了 VERSION 则使用,否则 Makefile 会从 VERSION 文件读取 RUN if [ -n "${VERSION}" ]; then VERSION=${VERSION} make build; else make build; fi # --- 阶段 3: 运行时 --- FROM docker.1ms.run/library/alpine:latest AS runtime WORKDIR /app # 配置国内镜像源并安装运行时依赖 RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \ apk --no-cache add ca-certificates tzdata # 从构建阶段复制 Go 二进制文件(已内嵌前端资源) COPY --from=builder /src/dist/claude-proxy-go /app/claude-proxy # 创建配置目录和日志目录 RUN mkdir -p /app/.config/backups /app/logs # 设置时区 ENV TZ=Asia/Shanghai # 暴露端口 EXPOSE 3000 # 健康检查 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 # 启动命令 CMD ["/app/claude-proxy"] ================================================ FILE: ENVIRONMENT.md ================================================ # 环境变量配置指南 ## 概述 本项目使用分层的环境变量配置系统,支持开发、生产等不同环境的端口和API配置。前端通过 Vite 的环境变量系统动态连接后端服务。 ## 配置文件结构 ``` claude-proxy/ ├── frontend/ │ ├── .env # 前端默认配置 │ ├── .env.development # 开发环境配置 │ ├── .env.production # 生产环境配置 │ └── vite.config.ts # Vite 构建配置 └── backend-go/ └── .env # Go 后端环境配置 ``` ## 环境变量详解 ### 前端配置变量 #### 开发环境变量 前端使用 Vite,环境变量需以 `VITE_` 前缀: - `VITE_PROXY_TARGET` - 后端代理目标地址(默认 `http://localhost:3000`) - `VITE_FRONTEND_PORT` - 前端开发服务器端口(默认 `5173`) - `VITE_BACKEND_URL` - 开发环境后端 URL(用于 API 服务) - `VITE_API_BASE_PATH` - API 基础路径(默认 `/api`) - `VITE_PROXY_API_PATH` - 代理 API 路径(默认 `/v1`) - `VITE_APP_ENV` - 应用环境标识 ### 后端配置 (Go) 后端支持以下环境变量: ```bash # 服务器配置 PORT=3000 # 服务器端口 # 运行环境 ENV=production # 运行环境: development | production # NODE_ENV=production # 向后兼容 (已弃用,请使用 ENV) # 访问控制 PROXY_ACCESS_KEY=your-secret-key # 访问密钥 (必须设置!) # Web UI ENABLE_WEB_UI=true # 是否启用 Web 管理界面 # 日志配置 LOG_LEVEL=info # 日志级别: debug | info | warn | error ENABLE_REQUEST_LOGS=true # 是否记录请求日志 ENABLE_RESPONSE_LOGS=false # 是否记录响应日志 QUIET_POLLING_LOGS=true # 静默前端轮询端点日志(/api/channels 等) # 性能配置 REQUEST_TIMEOUT=300000 # 请求超时时间(毫秒) MAX_REQUEST_BODY_SIZE_MB=50 # 请求体最大大小(MB,默认 50) # CORS 配置 ENABLE_CORS=false # 是否启用 CORS CORS_ORIGIN=* # CORS 允许的源 # 熔断指标配置 METRICS_WINDOW_SIZE=10 # 滑动窗口大小(最小 3,默认 10) METRICS_FAILURE_THRESHOLD=0.5 # 失败率阈值(0-1,默认 0.5 即 50%) ``` #### 日志等级说明 项目采用标准的四级日志系统,等级从高到低: | 等级 | 值 | 说明 | 典型场景 | |------|----|----|---------| | `error` | 0 | 错误日志(最高优先级) | 致命错误、异常情况 | | `warn` | 1 | 警告日志 | 非致命问题、降级操作 | | `info` | 2 | 信息日志(默认) | 常规操作、状态变化 | | `debug` | 3 | 调试日志(最低优先级) | 详细调试信息、敏感数据 | **等级控制规则**:设置 `LOG_LEVEL=info` 时,会输出 `error`、`warn`、`info` 级别的日志,但不输出 `debug` 级别。 #### 日志控制机制 项目使用三种机制来控制日志输出: ##### 1. 显式等级控制(推荐) ```go // 代码示例 if envCfg.ShouldLog("info") { log.Printf("🎯 使用上游: %s", upstream.Name) } ``` - **适用场景**:通用信息输出 - **控制变量**:`LOG_LEVEL` ##### 2. 开关控制(分类日志) ```go // 代码示例 if envCfg.EnableRequestLogs { log.Printf("📥 收到请求: %s", c.Request.URL.Path) } ``` - **适用场景**:请求/响应类日志 - **控制变量**:`ENABLE_REQUEST_LOGS`、`ENABLE_RESPONSE_LOGS` ##### 3. 环境门控(开发专用) ```go // 代码示例 if envCfg.EnableRequestLogs && envCfg.IsDevelopment() { log.Printf("📄 原始请求体:\n%s", formattedBody) } ``` - **适用场景**:敏感/详细信息(请求体、请求头等) - **控制变量**:`ENV=development` #### 日志输出对照表 | 日志内容 | 控制条件 | 等效等级 | 生产环境 | 开发环境 | |---------|---------|---------|---------|---------| | `📄 原始请求体` | `EnableRequestLogs && IsDevelopment()` | debug | ❌ 不输出 | ✅ 输出 | | `📋 实际请求头` | `EnableRequestLogs && IsDevelopment()` | debug | ❌ 不输出 | ✅ 输出 | | `📦 响应体` | `EnableResponseLogs && IsDevelopment()` | debug | ❌ 不输出 | ✅ 输出 | | `📥 收到请求` | `EnableRequestLogs` | info | ⚙️ 可配置 | ✅ 输出 | | `⏱️ 响应完成` | `EnableResponseLogs` | info | ⚙️ 可配置 | ✅ 输出 | | `🎯 使用上游` | `ShouldLog("info")` | info | ⚙️ 可配置 | ✅ 输出 | | `ℹ️ 客户端中断` | `ShouldLog("info")` | info | ⚙️ 可配置 | ✅ 输出 | | `⚠️ API密钥失败` | 无条件 | warn | ✅ 输出 | ✅ 输出 | | `💥 所有密钥失败` | 无条件 | error | ✅ 输出 | ✅ 输出 | #### 配置组合效果 **开发环境 + 完整日志**: ```env ENV=development LOG_LEVEL=debug ENABLE_REQUEST_LOGS=true ENABLE_RESPONSE_LOGS=true ``` - ✅ 输出所有日志,包括完整请求体、请求头、响应体 - ✅ 适合本地开发调试 - ⚠️ 可能包含敏感信息,不要在生产环境使用 **生产环境 + 最小日志**: ```env ENV=production LOG_LEVEL=warn ENABLE_REQUEST_LOGS=false ENABLE_RESPONSE_LOGS=false ``` - ✅ 只输出警告和错误 - ✅ 最小性能影响 - ✅ 不输出敏感信息 - ⚠️ 排查问题时信息较少 **生产环境 + 适度日志**(推荐): ```env ENV=production LOG_LEVEL=info ENABLE_REQUEST_LOGS=true ENABLE_RESPONSE_LOGS=false ``` - ✅ 输出基本请求信息(如 `📥 收到请求`) - ✅ 不输出详细内容(请求体、响应体等) - ✅ 平衡了可观测性和性能 - ✅ 不泄露敏感信息 **调试模式**: ```env ENV=development LOG_LEVEL=debug ENABLE_REQUEST_LOGS=true ENABLE_RESPONSE_LOGS=true ``` - ✅ 最详细的日志输出 - ✅ 查看完整的请求/响应数据流 - ⚠️ 仅用于故障排查,排查完成后应恢复正常配置 #### 性能影响说明 | 配置 | CPU 影响 | 内存影响 | 磁盘 I/O | |-----|---------|---------|----------| | `LOG_LEVEL=error` | 极低 | 极低 | 极低 | | `LOG_LEVEL=warn` | 极低 | 极低 | 低 | | `LOG_LEVEL=info` | 低 | 低 | 中 | | `LOG_LEVEL=debug` | 中 | 中 | 高 | | `ENABLE_REQUEST_LOGS=true` | 低 | 低 | 中 | | `ENABLE_RESPONSE_LOGS=true` | 低-中 | 中-高 | 高 | **生产环境建议**: - 日常运行:`LOG_LEVEL=info`,`ENABLE_RESPONSE_LOGS=false` - 故障排查:临时开启 `ENABLE_RESPONSE_LOGS=true` - 高负载场景:使用 `LOG_LEVEL=warn` 减少开销 ### ENV 变量影响 | 配置项 | `development` | `production` | |--------|---------------|--------------| | Gin 模式 | DebugMode | ReleaseMode | | `/admin/dev/info` | ✅ 开启 | ❌ 关闭 | | CORS | 宽松(localhost自动允许)| 严格 | | 日志 | 详细 | 最小 | ## 配置文件内容 ### frontend/.env ```env # 前端环境配置 # 后端API服务器配置 VITE_BACKEND_URL=http://localhost:3000 # 前端开发服务器配置 VITE_FRONTEND_PORT=5173 # API路径配置 VITE_API_BASE_PATH=/api VITE_PROXY_API_PATH=/v1 ``` ### frontend/.env.development ```env # 开发环境配置 # 后端API服务器配置 VITE_BACKEND_URL=http://localhost:3000 # 前端开发服务器配置 VITE_FRONTEND_PORT=5173 # API路径配置 VITE_API_BASE_PATH=/api VITE_PROXY_API_PATH=/v1 # 开发模式标识 VITE_APP_ENV=development ``` ### frontend/.env.production ```env # 生产环境配置 VITE_API_BASE_PATH=/api VITE_PROXY_API_PATH=/v1 VITE_APP_ENV=production ``` ### backend-go/.env.example ```env # 服务器配置 PORT=3000 # 运行环境 ENV=production # 访问控制 (必须修改!) PROXY_ACCESS_KEY=your-super-strong-secret-key # Web UI ENABLE_WEB_UI=true # 日志配置 LOG_LEVEL=info ENABLE_REQUEST_LOGS=false ENABLE_RESPONSE_LOGS=false ``` ## API 基础URL 生成逻辑 前端通过以下逻辑动态确定API基础URL: ```typescript const getApiBase = () => { // 生产环境:直接使用当前域名 if (import.meta.env.PROD) { return '/api' } // 开发环境:使用配置的后端URL const backendUrl = import.meta.env.VITE_BACKEND_URL const apiBasePath = import.meta.env.VITE_API_BASE_PATH || '/api' if (backendUrl) { return `${backendUrl}${apiBasePath}` } // 回退到默认配置 return '/api' } ``` ## 开发服务器代理配置 Vite 开发服务器自动配置代理,将前端请求转发到后端: ```typescript // vite.config.ts server: { port: Number(env.VITE_FRONTEND_PORT) || 5173, proxy: { '/api': { target: backendUrl, changeOrigin: true, secure: false } } } ``` ## 环境切换 ### 开发环境启动 ```bash # 方式 1: 根目录启动(推荐) make dev # 方式 2: 分别启动 # 启动后端 (端口 3000) cd backend-go && make dev # 启动前端 (端口 5173) cd frontend && bun run dev ``` ### 生产环境构建 ```bash # 完整构建 make build # Docker 部署 docker-compose up -d ``` ## 端口配置优先级 1. **环境变量** - 从 `.env.*` 文件读取 2. **默认值** - 代码中定义的回退值 3. **系统环境变量** - `PORT` (后端) ## 常见配置场景 ### 场景1:更改后端端口到 8080 ```env # backend-go/.env PORT=8080 # frontend/.env.development VITE_BACKEND_URL=http://localhost:8080 ``` ### 场景2:使用远程后端服务 ```env # frontend/.env.development VITE_BACKEND_URL=https://api.example.com ``` ### 场景3:自定义前端开发端口 ```env # frontend/.env.development VITE_FRONTEND_PORT=3000 ``` ### 场景4:生产环境配置 #### 4.1 高性能模式(最小日志) ```env # backend-go/.env ENV=production PORT=3000 PROXY_ACCESS_KEY=$(openssl rand -base64 32) # 最小日志输出 LOG_LEVEL=warn ENABLE_REQUEST_LOGS=false ENABLE_RESPONSE_LOGS=false ENABLE_WEB_UI=true ``` - ✅ 适合:高并发场景、性能敏感应用 - ✅ 特点:最低资源消耗,只记录警告和错误 - ⚠️ 注意:排查问题时信息较少 #### 4.2 标准模式(推荐) ```env # backend-go/.env ENV=production PORT=3000 PROXY_ACCESS_KEY=$(openssl rand -base64 32) # 适度日志输出 LOG_LEVEL=info ENABLE_REQUEST_LOGS=true ENABLE_RESPONSE_LOGS=false ENABLE_WEB_UI=true ``` - ✅ 适合:大多数生产环境 - ✅ 特点:平衡可观测性和性能,不泄露敏感信息 - ✅ 优势:足够的信息用于监控和问题排查 #### 4.3 调试模式(临时排查) ```env # backend-go/.env ENV=production PORT=3000 PROXY_ACCESS_KEY=$(openssl rand -base64 32) # 详细日志输出(临时使用) LOG_LEVEL=info ENABLE_REQUEST_LOGS=true ENABLE_RESPONSE_LOGS=true ENABLE_WEB_UI=true ``` - ⚠️ 适合:故障排查时临时启用 - ⚠️ 注意:会输出完整响应内容,增加日志量 - 🔄 建议:问题解决后立即恢复标准配置 #### 4.4 开发环境配置 ```env # backend-go/.env ENV=development PORT=3000 PROXY_ACCESS_KEY=dev-test-key # 完整日志输出 LOG_LEVEL=debug ENABLE_REQUEST_LOGS=true ENABLE_RESPONSE_LOGS=true ENABLE_WEB_UI=true ``` - ✅ 适合:本地开发和调试 - ✅ 特点:输出所有详细信息,包括请求体、响应体 - ⚠️ 警告:包含敏感信息,仅限开发环境使用 ## 调试配置 开发环境下,前端会在控制台输出当前API配置: ```javascript console.log('🔗 API Configuration:', { API_BASE: '/api', BACKEND_URL: 'http://localhost:3000', IS_DEV: true, IS_PROD: false }) ``` ## 注意事项 1. **变量前缀**:前端环境变量必须以 `VITE_` 开头才能在浏览器中访问 2. **构建时解析**:Vite 在构建时静态替换环境变量,运行时无法修改 3. **生产环境**:生产环境不需要指定后端URL,通过反向代理或一体化部署处理 4. **类型安全**:使用 `Number()` 转换端口号确保类型正确 5. **密钥安全**:切勿在版本控制中提交 `.env` 文件,使用 `.env.example` 作为模板 ## 安全最佳实践 ### 生成强密钥 ```bash # 生成随机密钥 PROXY_ACCESS_KEY=$(openssl rand -base64 32) echo "生成的密钥: $PROXY_ACCESS_KEY" ``` ### 生产环境配置清单 ```bash # 1. 强密钥 (必须!) PROXY_ACCESS_KEY= # 2. 生产模式 ENV=production # 3. 适度日志(推荐) LOG_LEVEL=info ENABLE_REQUEST_LOGS=true ENABLE_RESPONSE_LOGS=false # 4. 启用 Web UI (可选) ENABLE_WEB_UI=true ``` ### 日志安全建议 #### 敏感信息保护 项目已自动对以下信息进行脱敏处理: - ✅ API密钥:只显示前4位和后4位(如 `sk-a***b`) - ✅ Authorization 请求头:完全隐藏 - ✅ x-api-key 请求头:完全隐藏 #### 推荐配置 ```bash # 生产环境:不输出详细内容 ENV=production ENABLE_REQUEST_LOGS=true # ✅ 基本请求信息 ENABLE_RESPONSE_LOGS=false # ❌ 不输出响应体 # 开发环境:可以输出详细内容 ENV=development ENABLE_REQUEST_LOGS=true ENABLE_RESPONSE_LOGS=true ``` #### 日志存储注意事项 1. **日志轮转**:定期清理旧日志,避免磁盘空间耗尽 2. **访问控制**:限制日志文件的访问权限 ```bash chmod 600 /var/log/claude-proxy/*.log ``` 3. **敏感数据**:即使有脱敏,也应定期审查日志内容 4. **合规要求**:根据数据保护法规(GDPR、CCPA等)管理日志 #### 故障排查时的安全做法 ```bash # ✅ 推荐:临时开启详细日志,排查完成后恢复 ENABLE_RESPONSE_LOGS=true # 临时启用 # 🔄 排查完成后立即恢复 ENABLE_RESPONSE_LOGS=false # ❌ 不推荐:在生产环境长期开启 debug 级别 LOG_LEVEL=debug # 可能泄露敏感信息 ``` ## 故障排除 ### 问题:前端无法连接后端 1. 检查后端是否在正确端口启动 ```bash curl http://localhost:3000/health ``` 2. 确认 `VITE_BACKEND_URL` 配置正确 3. 查看浏览器控制台的API配置输出 ### 问题:构建后API请求失败 1. 确认生产环境配置了正确的反向代理或使用一体化部署 2. 检查 `VITE_API_BASE_PATH` 设置 3. 验证后端API路径匹配 ### 问题:环境变量不生效 1. 确认变量名以 `VITE_` 开头 (前端) 或在后端代码中正确读取 2. 重启开发服务器 3. 检查 `.env` 文件语法正确 (无多余空格、引号等) ### 问题:认证失败 ```bash # 检查密钥设置 echo $PROXY_ACCESS_KEY # 测试认证 curl -H "x-api-key: $PROXY_ACCESS_KEY" http://localhost:3000/health ``` ### 问题:日志输出过多或过少 #### 日志过多(影响性能) **症状**:日志文件快速增长,磁盘空间不足,或系统性能下降 **解决方案**: 1. 降低日志等级 ```env LOG_LEVEL=warn # 从 info 或 debug 降级 ``` 2. 关闭详细日志 ```env ENABLE_REQUEST_LOGS=false ENABLE_RESPONSE_LOGS=false ``` 3. 使用日志轮转(推荐) ```bash # 使用 systemd 日志轮转 journalctl --vacuum-time=7d # 或使用 logrotate # /etc/logrotate.d/claude-proxy /var/log/claude-proxy/*.log { daily rotate 7 compress delaycompress missingok notifempty } ``` #### 日志过少(排查困难) **症状**:出现问题时没有足够的日志信息 **解决方案**: 1. 提高日志等级 ```env LOG_LEVEL=info # 从 warn 提升 ``` 2. 临时开启详细日志 ```env ENABLE_REQUEST_LOGS=true ENABLE_RESPONSE_LOGS=true ``` 3. 使用开发模式(仅限测试环境) ```env ENV=development LOG_LEVEL=debug ``` #### 看不到请求体/响应体 **症状**:日志中没有详细的请求/响应内容 **原因**:详细内容只在开发环境 (`ENV=development`) 输出 **解决方案**: ```env # 方案1:临时切换到开发模式(不推荐生产环境) ENV=development ENABLE_REQUEST_LOGS=true ENABLE_RESPONSE_LOGS=true # 方案2:查看是否开启了日志开关 ENABLE_REQUEST_LOGS=true # 必须为 true ENABLE_RESPONSE_LOGS=true # 必须为 true # 方案3:检查当前环境 echo $ENV # 必须是 development ``` **安全提醒**: - ⚠️ 请求体和响应体可能包含敏感信息(API密钥、用户数据等) - ⚠️ 生产环境建议关闭 `ENABLE_RESPONSE_LOGS` - ⚠️ 排查完成后立即恢复安全配置 ### 问题:日志格式混乱 **症状**:日志输出格式不统一或难以阅读 **检查项**: 1. 确认是否混用了多个日志系统 2. 检查是否有第三方库输出了额外日志 3. 验证环境变量是否正确加载 ```bash # 打印当前日志配置 curl -H "x-api-key: $PROXY_ACCESS_KEY" http://localhost:3000/health ``` ## 文档资源 - **项目架构**: 参见 [ARCHITECTURE.md](ARCHITECTURE.md) - **快速开始**: 参见 [README.md](README.md) - **贡献指南**: 参见 [CONTRIBUTING.md](CONTRIBUTING.md) ================================================ FILE: LICENSE ================================================ Copyright (c) 2025 wangyusong Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ # Claude Proxy Makefile GREEN=\033[0;32m YELLOW=\033[0;33m NC=\033[0m .PHONY: help dev run build clean frontend-dev frontend-build embed-frontend help: @echo "$(GREEN)Claude Proxy - 可用命令:$(NC)" @echo "" @echo "$(YELLOW)开发:$(NC)" @echo " make dev - Go 后端热重载开发(不含前端)" @echo " make run - 构建前端并运行 Go 后端" @echo " make frontend-dev - 前端开发服务器" @echo "" @echo "$(YELLOW)构建:$(NC)" @echo " make build - 构建前端并编译 Go 后端" @echo " make frontend-build - 仅构建前端" @echo " make clean - 清理构建文件" dev: @echo "$(GREEN)🚀 启动前后端开发模式...$(NC)" @cd frontend && bun run dev & @cd backend-go && $(MAKE) dev run: embed-frontend @cd backend-go && $(MAKE) run build: embed-frontend @cd backend-go && $(MAKE) build embed-frontend: @echo "$(GREEN)📦 构建前端...$(NC)" @cd frontend && bun run build @echo "$(GREEN)📋 嵌入前端到 Go 后端...$(NC)" @rm -rf backend-go/frontend/dist @mkdir -p backend-go/frontend/dist @cp -r frontend/dist/* backend-go/frontend/dist/ clean: @cd backend-go && $(MAKE) clean @rm -rf frontend/dist frontend-dev: @cd frontend && bun run dev frontend-build: @cd frontend && bun run build ================================================ FILE: README.md ================================================ > ⚠️ **项目已重命名**: 本项目已重命名为 **[CCX](https://github.com/BenedictKing/ccx)**,请访问新仓库获取最新版本和更新。本仓库已归档,不再维护。 --- # Claude / Codex / Gemini API Proxy [![GitHub release](https://img.shields.io/github/v/release/BenedictKing/claude-proxy)](https://github.com/BenedictKing/claude-proxy/releases/latest) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 一个高性能的 Claude API 代理服务器,支持多种上游 AI 服务提供商(Claude、Codex、Gemini),提供故障转移、多 API 密钥管理和统一入口访问。 ## 🚀 功能特性 - **🖥️ 一体化架构**: 后端集成前端,单容器部署,完全替代 Nginx - **🔐 统一认证**: 一个密钥保护所有入口(前端界面、管理 API、代理 API) - **📱 Web 管理面板**: 现代化可视化界面,支持渠道管理、实时监控和配置 - **三 API 支持**: 同时支持 Claude Messages API (`/v1/messages`)、Codex Responses API (`/v1/responses`) 和 Gemini API - **统一入口**: 通过统一端点访问不同的 AI 服务 - **多上游支持**: 支持 Claude、Codex 和 Gemini 等多种上游服务 - **🔌 协议转换**: Messages API 支持协议自动转换,统一接入不同上游服务 - **🎯 智能调度**: 多渠道智能调度器,支持优先级排序、健康检查和自动熔断 - **📊 渠道编排**: 可视化渠道管理,拖拽调整优先级,实时查看健康状态 - **🔄 Trace 亲和**: 同一用户会话自动绑定到同一渠道,提升一致性体验 - **故障转移**: 自动切换到可用渠道,确保服务高可用 - **多 API 密钥**: 每个上游可配置多个 API 密钥,自动轮换使用(推荐 failover 策略以最大化利用 Prompt Caching) - **🧠 缓存统计**: 按 Token 口径展示各渠道缓存读/写与命中率(命中率 = `cache_read_tokens / (cache_read_tokens + input_tokens)`) - **增强的稳定性**: 内置上游请求超时与重试机制,确保服务在网络波动时依然可靠 - **自动重试与密钥降级**: 检测到额度/余额不足等错误时自动切换下一个可用密钥;若后续请求成功,再将失败密钥移动到末尾(降级);所有密钥均失败时按上游原始错误返回 - **⚡ 自动熔断**: 基于滑动窗口算法检测渠道健康度,失败率过高自动熔断,15 分钟后自动恢复 - **双重配置**: 支持命令行工具和 Web 界面管理上游配置 - **环境变量**: 通过 `.env` 文件灵活配置服务器参数 - **健康检查**: 内置健康检查端点和实时状态监控 - **日志系统**: 完整的请求/响应日志记录 - **📡 支持流式和非流式响应** - **🛠️ 支持工具调用** - **💬 会话管理**: Responses API 支持多轮对话的会话跟踪和上下文保持 ## 📄 许可证 本项目基于 MIT 许可证开源 - 查看 [LICENSE](LICENSE) 文件了解详情。 ================================================ FILE: RELEASE.md ================================================ # 发布指南 本文档为项目维护者提供了一套标准的版本发布流程,以确保版本迭代的一致性和清晰度。 ## 版本规范 项目遵循**语义化版本 2.0.0 (Semantic Versioning)** 规范。版本格式为 `主版本号.次版本号.修订号` (MAJOR.MINOR.PATCH),版本号递增规则如下: - **主版本号 (MAJOR)**: 当你做了不兼容的 API 修改。 - **次版本号 (MINOR)**: 当你做了向下兼容的功能性新增。 - **修订号 (PATCH)**: 当你做了向下兼容的问题修正。 ## 发布流程 ### 步骤 1: 准备工作 1. 确保本地的 `main` 分支是最新且稳定的。 ```bash git checkout main git pull origin main ``` 2. 确认所有计划内的功能和修复都已合并到 `main` 分支。 3. 验证代码质量和构建状态。 ```bash # TypeScript 类型检查 bun run type-check # 构建验证 bun run build ``` ### 步骤 2: 更新版本日志 (`CHANGELOG.md`) 1. 打开 `CHANGELOG.md` 文件。 2. 在文件顶部新增一个版本标题,格式为 `## vX.Y.Z - YYYY-MM-DD`。 3. 在标题下,根据本次版本的变更内容,添加相应的分类,如: - `### ✨ 新功能` - `### 🐛 Bug 修复` - `### ♻️ 重构` - `### 📚 文档` - `### ⚙️ 其他` 4. 运行以下命令,查看自上一个版本以来的所有提交记录,以帮助你整理更新日志。 ```bash # 将 v1.0.0 替换为上一个版本的标签 git log v1.0.0...HEAD --oneline ``` ### 步骤 3: 更新 `package.json` 中的版本号 1. 打开 `package.json` 文件。 2. 将 `version` 字段的值更新为新的版本号。 例如,从 `1.0.0` 更新到 `1.0.1`: ```diff - "version": "1.0.0", + "version": "1.0.1", ``` ### 步骤 4: 提交版本更新 1. 将 `CHANGELOG.md` 和 `package.json` 的修改提交到暂存区。 ```bash git add CHANGELOG.md package.json ``` 2. 使用标准化的提交信息进行提交。 ```bash # 将 vX.Y.Z 替换为新版本号 git commit -m "chore(release): prepare for vX.Y.Z" ``` 3. 将提交推送到远程 `main` 分支。 ```bash git push origin main ``` ### 步骤 5: 创建并推送 Git 标签 1. 为此次提交创建一个附注标签(annotated tag)。 ```bash # 将 vX.Y.Z 替换为新版本号 git tag -a vX.Y.Z -m "Release vX.Y.Z" ``` 2. 将新创建的标签推送到远程仓库。 ```bash # 将 vX.Y.Z 替换为新版本号 git push origin vX.Y.Z ``` 3. 推送 tag 后,GitHub Actions 会自动触发以下构建任务(**三平台并行执行**): - `release-linux.yml` - 构建 Linux amd64/arm64 版本 - `release-macos.yml` - 构建 macOS amd64/arm64 版本 - `release-windows.yml` - 构建 Windows amd64/arm64 版本 - `docker-build.yml` - 构建并推送 Docker 镜像 > **注意**: 各平台使用独立的 concurrency group (`${{ github.workflow }}-${{ github.ref }}`),确保并行构建互不阻塞。 ### 步骤 6: 在 GitHub 上创建 Release (可选但推荐) 1. 访问项目的 GitHub 页面,进入 "Releases" 部分。 2. 点击 "Draft a new release"。 3. 从 "Choose a tag" 下拉菜单中选择你刚刚推送的标签(如 `vX.Y.Z`)。 4. 将 `CHANGELOG.md` 中对应版本的更新内容复制到发布说明中。 5. 点击 "Publish release"。 至此,新版本的发布流程已全部完成。 ================================================ FILE: VERSION ================================================ v2.5.13 ================================================ FILE: backend-go/.air.toml ================================================ # Air 配置文件 - Go热重载工具 # 文档: https://github.com/air-verse/air # 工作目录 root = "." testdata_dir = "testdata" tmp_dir = "tmp" # 构建配置 [build] # 编译命令 (可以添加自定义参数) cmd = "go build -o ./tmp/main ." # 运行的二进制文件 bin = "tmp/main" # 自定义运行时参数 (比如: ["run", "test", "-v"]) full_bin = "" # 监听的文件扩展名 include_ext = ["go", "tpl", "tmpl", "html", "yaml", "yml", "toml", "env"] # 忽略的文件/文件夹 exclude_dir = ["assets", "tmp", "vendor", "testdata", "frontend", "dist", ".git", ".github", ".vscode", ".idea"] # 监听指定的目录 include_dir = [] # 监听指定的文件(包括 .env) include_file = [".env"] # 忽略的文件 exclude_file = [] # 忽略未修改的文件 exclude_unchanged = false # 监听符号链接的目录 follow_symlink = false # 文件变更延迟触发编译 (毫秒) delay = 1000 # 编译错误时是否停止旧的二进制文件 stop_on_error = true # 运行二进制文件时的日志文件 log = "build-errors.log" # air启动时是否发送中断信号 send_interrupt = true # 中断信号延迟 (毫秒) kill_delay = 500 # 在发送中断信号时添加额外的参数 rerun = false # 运行后的延迟 (毫秒) rerun_delay = 500 # 添加额外的参数 args_bin = [] # 自定义shell命令 [build.pre_cmd] # 数组形式的命令会按顺序执行 # enable = true # cmds = [ # "echo 'Building...'" # ] [build.post_cmd] # enable = true # cmds = [ # "echo 'Build complete!'" # ] # 日志配置 [log] # 是否开启主进程日志 main_only = false # 时间格式 time = true # 颜色配置 [color] # 自定义颜色 main = "magenta" watcher = "cyan" build = "yellow" runner = "green" # app日志颜色 app = "" # 其他配置 [misc] # 是否在退出时清理tmp目录 clean_on_exit = true # 屏幕清理 [screen] # 重新编译时清屏 clear_on_rebuild = true # 保持滚动 keep_scroll = true ================================================ FILE: backend-go/.env.example ================================================ # 环境变量示例配置 # 复制此文件为 .env 并修改配置 # ============ 服务器配置 ============ PORT=3000 # 运行环境: development | production # 影响: # - production: Gin ReleaseMode(高性能)、关闭/admin/dev/info、严格CORS # - development: Gin DebugMode(详细日志)、开启/admin/dev/info、宽松CORS # 生产环境务必设置为 production! ENV=production # ============ Web UI 配置 ============ # 是否启用 Web 管理界面 ENABLE_WEB_UI=true # ============ 访问控制 ============ # 代理访问密钥(必须修改!) PROXY_ACCESS_KEY=your-secure-access-key-here # ============ 日志配置 ============ # 日志级别: error | warn | info | debug LOG_LEVEL=info # 是否启用请求/响应日志 # 注意:默认值为 true,注释掉此项等于启用日志 # 要禁用日志必须显式设置为 false,不能通过注释来禁用 ENABLE_REQUEST_LOGS=false ENABLE_RESPONSE_LOGS=false # 静默前端轮询端点日志(调试时避免刷屏) QUIET_POLLING_LOGS=true # 原始日志输出(不缩进、不截断、不重排序,直接输出完整请求/响应内容) RAW_LOG_OUTPUT=false # SSE 调试级别: off | summary | full # full: 记录每个 SSE 事件的类型、长度、content_block 详情 # summary: 仅在流结束时记录事件统计摘要 # off: 关闭 SSE 调试日志(默认) SSE_DEBUG_LEVEL=off # 是否改写响应中的 model 字段为请求的 model(默认 false) # 启用后,当上游返回的 model 与请求的 model 不一致时,会自动改写为请求的 model # 注意:仅影响 Messages API 的流式响应,不影响 Responses API 和 Gemini API REWRITE_RESPONSE_MODEL=false # ============ 性能配置 ============ # 请求超时时间(毫秒) REQUEST_TIMEOUT=300000 # 请求体最大大小(MB),默认 50 MAX_REQUEST_BODY_SIZE_MB=50 # 等待上游响应头超时时间(秒),默认 60,范围 30-120 # 如果遇到 "http2: timeout awaiting response headers" 错误,可以适当调高 RESPONSE_HEADER_TIMEOUT=60 # ============ CORS 配置 ============ ENABLE_CORS=false CORS_ORIGIN=* # ============ 熔断指标配置 ============ # 滑动窗口大小(最小 3,默认 10) METRICS_WINDOW_SIZE=10 # 失败率阈值(0-1,默认 0.5 即 50%) METRICS_FAILURE_THRESHOLD=0.5 # ============ 指标持久化配置 ============ # 是否启用 SQLite 持久化(默认 true) # 启用后重启服务不会丢失历史指标数据 METRICS_PERSISTENCE_ENABLED=true # 数据保留天数(3-30,默认 7) METRICS_RETENTION_DAYS=7 ================================================ FILE: backend-go/.gitignore ================================================ # 忽略编译产物 dist/ *.exe claude-proxy-* claude-proxy stream_verify # 忽略前端资源(会在构建时复制) frontend/ # 忽略配置文件 .env .config/ # Go 相关 vendor/ # 操作系统文件 .DS_Store Thumbs.db # IDE .vscode/ .idea/ *.swp *.swo *~ tmp/ !docs/ ================================================ FILE: backend-go/CLAUDE.md ================================================ # backend-go 模块文档 [← 根目录](../CLAUDE.md) ## 模块职责 Go 后端核心服务:HTTP API、多上游适配、协议转换、智能调度、会话管理、配置热重载。 ## 启动命令 ```bash make dev # 热重载开发 make test # 运行测试 make test-cover # 测试 + 覆盖率 make build # 构建二进制 ``` ## API 端点 | 端点 | 方法 | 功能 | |------|------|------| | `/health` | GET | 健康检查(无需认证) | | `/v1/messages` | POST | Claude Messages API | | `/v1/messages/count_tokens` | POST | Token 计数 | | `/v1/responses` | POST | Codex Responses API | | `/v1/responses/compact` | POST | 精简版 Responses API | | `/api/messages/channels` | CRUD | Messages 渠道管理 | | `/api/responses/channels` | CRUD | Responses 渠道管理 | | `/api/messages/ping/:id` | GET | 渠道连通性测试 | | `/api/messages/channels/metrics` | GET | 渠道指标 | | `/api/messages/channels/scheduler/stats` | GET | 调度器统计 | ## 指标历史数据聚合粒度 `/api/messages/channels/:id/keys/metrics/history` 端点根据查询时间范围自动选择聚合间隔: | 时间范围 | 聚合间隔 | 数据点数 | |----------|----------|----------| | 1h | 1 分钟 | ~60 点 | | 6h | 5 分钟 | ~72 点 | | 24h | 15 分钟 | ~96 点 | 可通过 `interval` 参数手动指定(最小 1 分钟)。 ## Provider 接口 所有上游服务实现 `internal/providers/Provider` 接口: ```go type Provider interface { ConvertToProviderRequest(c *gin.Context, upstream *config.UpstreamConfig, apiKey string) (*http.Request, []byte, error) ConvertToClaudeResponse(providerResp *types.ProviderResponse) (*types.ClaudeResponse, error) HandleStreamResponse(body io.ReadCloser) (<-chan string, <-chan error, error) } ``` **实现**: `ClaudeProvider`, `OpenAIProvider`, `GeminiProvider` ## 核心模块 | 模块 | 职责 | |------|------| | `handlers/` | HTTP 处理器(proxy.go, responses.go) | | `providers/` | 上游适配器 | | `converters/` | 协议转换器(工厂模式) | | `scheduler/` | 多渠道调度(优先级、熔断) | | `session/` | 会话管理(Trace 亲和性) | | `config/` | 配置管理(热重载) | ## 日志规范 所有日志输出使用 `[Component-Action]` 标签格式,禁止使用 emoji 符号(确保跨平台兼容性)。 **格式规范**: ```go // 标准格式 log.Printf("[Component-Action] 消息内容: %v", value) // 警告信息 log.Printf("[Component] 警告: 消息内容") ``` **标签命名示例**: | 组件 | 标签 | 用途 | |------|------|------| | 调度器 | `[Scheduler-Channel]` | 渠道选择 | | 调度器 | `[Scheduler-Promotion]` | 促销渠道 | | 调度器 | `[Scheduler-Affinity]` | Trace 亲和性 | | 调度器 | `[Scheduler-Fallback]` | 降级选择 | | 认证 | `[Auth-Failed]` | 认证失败 | | 认证 | `[Auth-Success]` | 认证成功 | | 指标 | `[Metrics-Store]` | 指标存储 | | 会话 | `[Session-Manager]` | 会话管理 | | 配置 | `[Config-Watcher]` | 配置热重载 | | 压缩 | `[Gzip]` | Gzip 解压缩 | | Messages | `[Messages-Stream]` | Messages 流式处理 | | Messages | `[Messages-Stream-Token]` | Messages Token 统计 | | Messages | `[Messages-Models]` | Messages Models API 操作 | | Responses | `[Responses-Stream]` | Responses 流式处理 | | Responses | `[Responses-Stream-Token]` | Responses Token 统计 | | Responses | `[Responses-Models]` | Responses Models API 操作 | | Models | `[Models]` | 跨接口的模型列表合并操作 | ## 扩展指南 **添加新上游服务**: 1. 在 `internal/providers/` 创建新文件 2. 实现 `Provider` 接口 3. 在 `GetProvider()` 注册 **调度优先级规则**: 1. 促销期渠道优先 2. Priority 字段排序 3. Trace 亲和性绑定 4. 熔断状态过滤 ## 工具使用注意事项 **Edit 工具与 Tab 缩进**: - Go 文件使用 tab 缩进,`Edit` 工具匹配时可能因空白字符差异失败 - 失败时可用 `sed -i '' 's/old/new/g' file.go` 替代 ================================================ FILE: backend-go/DEV_GUIDE.md ================================================ # Go 后端开发指南 - 热重载模式 ## 🚀 快速开始 ### 1. 安装 Air 热重载工具 Air 项目已迁移至新仓库 `github.com/air-verse/air`(原 `cosmtrek/air`) ```bash # 推荐方式 make install-air # 或手动安装 go install github.com/air-verse/air@latest ``` ### 2. 启动热重载开发模式 ```bash # 启动开发模式 make dev # 输出示例: # 🚀 启动开发模式 (热重载开启) # 📝 监听文件变化: *.go, *.yaml, *.toml, *.env # 🔄 修改代码后将自动重启... ``` ## 📁 热重载配置 ### 监听的文件类型 - `*.go` - Go 源代码 - `*.yaml`, `*.yml` - YAML 配置文件 - `*.toml` - TOML 配置文件 - `*.env` - 环境变量文件 - `*.html`, `*.tpl`, `*.tmpl` - 模板文件 ### 忽略的目录 - `tmp/` - Air 临时编译目录 - `vendor/` - Go 依赖目录 - `frontend/` - 前端源码(不影响后端) - `dist/` - 构建输出目录 - `.git/`, `.github/` - Git 相关 - `.vscode/`, `.idea/` - IDE 配置 ### 性能优化设置 - **编译延迟**: 1000ms(避免保存过程中频繁编译) - **错误处理**: 编译错误时保持旧版本运行 - **信号处理**: 优雅关闭,500ms 延迟 - **清屏设置**: 每次重编译时自动清屏 ## 🎯 开发流程 ### 典型开发场景 1. **修改业务逻辑** ```bash # 编辑 handlers/proxy.go # 保存文件 → Air 检测到变化 → 1秒后自动重编译 → 重启服务 ``` 2. **更新配置文件** ```bash # 编辑 .env # 保存文件 → Air 重新加载配置 → 服务自动重启 ``` 3. **处理编译错误** ```bash # 代码有语法错误 # Air 显示错误信息 → 保持旧版本运行 # 修复错误并保存 → 自动重新编译 → 恢复正常 ``` ## 🛠️ Make 命令参考 | 命令 | 说明 | 使用场景 | |------|------|---------| | `make dev` | 启动热重载开发模式 | 日常开发主要命令 | | `make install-air` | 安装 Air 工具 | 首次设置或更新 Air | | `make run` | 直接运行(无热重载) | 快速测试 | | `make build` | 构建生产版本 | 部署准备 | | `make build-local` | 构建本地版本 | 本地测试 | | `make test` | 运行测试 | 功能验证 | | `make fmt` | 格式化代码 | 代码规范化 | | `make clean` | 清理临时文件 | 清理环境 | ## 🔧 Air 高级配置 ### 自定义 .air.toml ```toml # 添加预编译命令 [build.pre_cmd] enable = true cmds = [ "echo '开始编译...'", "go mod tidy" ] # 添加后编译命令 [build.post_cmd] enable = true cmds = [ "echo '编译完成!'" ] # 自定义运行参数 [build] # 添加构建标签 cmd = "go build -tags dev -o ./tmp/main ." # 传递运行时参数 args_bin = ["--debug", "--verbose"] ``` ### 环境变量 ```bash # 开发模式专用环境变量 ENV=development LOG_LEVEL=debug ENABLE_REQUEST_LOGS=true ENABLE_RESPONSE_LOGS=true ``` ## 🐛 问题排查 ### Air 命令未找到 ```bash # 检查安装 which air # 添加到 PATH export PATH=$PATH:$(go env GOPATH)/bin # 或添加到 shell 配置 echo 'export PATH=$PATH:$(go env GOPATH)/bin' >> ~/.zshrc source ~/.zshrc ``` ### 热重载不触发 ```bash # 检查 Air 进程 ps aux | grep air # 查看 Air 日志 tail -f build-errors.log # 清理并重启 make clean && make dev ``` ### 端口占用 ```bash # 查找占用端口的进程 lsof -i :3000 # 终止进程 kill -9 ``` ### 文件权限问题 ```bash # 修复权限 chmod -R 755 . chmod 644 .air.toml ``` ## 📊 性能对比 | 操作 | 无热重载 | 有热重载 | 提升 | |------|---------|---------|------| | 修改代码后重启 | 手动 10-15秒 | 自动 1-2秒 | **10倍** | | 处理编译错误 | 中断→修复→重启 | 保持运行→修复→自动恢复 | **无中断** | | 配置更新 | 停止→修改→启动 | 修改→自动重启 | **3倍** | | 开发效率 | 低 | 高 | **显著提升** | ## 💡 最佳实践 1. **保持 Air 运行**: 开发期间始终使用 `make dev` 2. **合理设置延迟**: 1秒延迟平衡了响应速度和性能 3. **利用彩色输出**: 不同颜色快速区分日志类型 4. **定期清理**: 使用 `make clean` 清理临时文件 5. **版本控制**: `.air.toml` 应该加入版本控制 ## 🔗 相关资源 - [Air 官方文档](https://github.com/air-verse/air) - [Air 配置示例](https://github.com/air-verse/air/blob/master/air_example.toml) - [Gin 开发模式文档](https://gin-gonic.com/docs/quickstart/) --- **提示**: 如果遇到任何问题,请先运行 `make clean && make install-air` 重置环境。 ================================================ FILE: backend-go/Makefile ================================================ # Makefile for Claude Proxy Go Backend # 变量定义 BINARY_NAME=claude-proxy-go MAIN_PATH=. BUILD_DIR=../dist AIR_VERSION=v1.52.0 # 支持环境变量覆盖 VERSION(用于 Docker 构建时传入) VERSION?=$(shell cat ../VERSION 2>/dev/null || echo "v0.0.0-dev") BUILD_TIME=$(shell date '+%Y-%m-%d_%H:%M:%S_%Z') GIT_COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") LDFLAGS=-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) -X main.GitCommit=$(GIT_COMMIT) # 颜色输出 GREEN=\033[0;32m YELLOW=\033[0;33m NC=\033[0m # No Color .PHONY: help dev build run clean install-air test copy-frontend # 默认目标 - 显示帮助 help: @echo "$(GREEN)Claude Proxy Go Backend - 可用命令:$(NC)" @echo "" @echo "$(YELLOW)开发命令:$(NC)" @echo " make dev - 启动开发模式 (热重载)" @echo " make install-air - 安装 Air 热重载工具" @echo "" @echo "$(YELLOW)构建命令:$(NC)" @echo " make build - 构建生产版本" @echo " make run - 直接运行 (自动复制前端)" @echo " make copy-frontend- 复制前端资源" @echo " make clean - 清理构建文件" @echo "" @echo "$(YELLOW)测试命令:$(NC)" @echo " make test - 运行测试" @echo " make test-cover - 运行测试并生成覆盖率" # 安装 Air 工具 install-air: @echo "$(GREEN)正在安装 Air 热重载工具...$(NC)" @go install github.com/air-verse/air@latest @echo "$(GREEN)✅ Air 安装完成!$(NC)" @echo "使用方法: make dev" # 开发模式 - 使用 Air 热重载 dev: @if ! command -v air &> /dev/null; then \ echo "$(YELLOW)⚠️ Air 未安装,正在自动安装...$(NC)"; \ $(MAKE) install-air; \ fi @echo "$(GREEN)🚀 启动开发模式 (热重载开启)$(NC)" @echo "$(YELLOW)📝 监听文件变化: *.go, *.yaml, *.toml, *.env$(NC)" @echo "$(YELLOW)🔄 修改代码后将自动重启...$(NC)" @echo "" @air # 开发模式 - 使用系统环境变量 dev-with-env: @echo "$(GREEN)🚀 启动开发模式 (使用系统环境变量)$(NC)" @ENV_MODE=development air # 构建生产版本 build: @echo "$(GREEN)📦 构建生产版本...$(NC)" @CGO_ENABLED=0 go build -ldflags="$(LDFLAGS) -s -w" -o $(BUILD_DIR)/$(BINARY_NAME) $(MAIN_PATH) @echo "$(GREEN)✅ 构建完成: $(BUILD_DIR)/$(BINARY_NAME)$(NC)" # 构建当前平台版本 build-local: @echo "$(GREEN)📦 构建本地版本...$(NC)" @go build -o $(BINARY_NAME) $(MAIN_PATH) @echo "$(GREEN)✅ 构建完成: ./$(BINARY_NAME)$(NC)" # 直接运行 (带版本信息) run: copy-frontend @echo "$(GREEN)▶️ 直接运行...$(NC)" @go run -ldflags="$(LDFLAGS)" $(MAIN_PATH) # 运行测试 test: @echo "$(GREEN)🧪 运行测试...$(NC)" @go test -v ./... # 运行测试并生成覆盖率报告 test-cover: @echo "$(GREEN)🧪 运行测试并生成覆盖率...$(NC)" @go test -v -cover -coverprofile=coverage.out ./... @go tool cover -html=coverage.out -o coverage.html @echo "$(GREEN)✅ 覆盖率报告已生成: coverage.html$(NC)" # 清理构建文件 clean: @echo "$(YELLOW)🧹 清理构建文件...$(NC)" @rm -rf tmp/ @rm -rf frontend/dist/ @rm -f $(BINARY_NAME) @rm -f $(BUILD_DIR)/$(BINARY_NAME) @rm -f coverage.out coverage.html @rm -f build-errors.log @echo "$(GREEN)✅ 清理完成$(NC)" # 复制前端资源 copy-frontend: @echo "$(GREEN)📦 复制前端资源...$(NC)" @rm -rf frontend/dist @mkdir -p frontend/dist @if [ -d "../frontend/dist" ]; then \ cp -r ../frontend/dist/* frontend/dist/; \ echo "$(GREEN)✅ 前端资源复制完成$(NC)"; \ else \ echo "$(YELLOW)⚠️ 前端构建产物不存在,请先构建前端: cd ../frontend && bun run build$(NC)"; \ fi # 代码格式化 fmt: @echo "$(GREEN)🎨 格式化代码...$(NC)" @go fmt ./... @echo "$(GREEN)✅ 格式化完成$(NC)" # 代码检查 lint: @echo "$(GREEN)🔍 检查代码...$(NC)" @if ! command -v golangci-lint &> /dev/null; then \ echo "$(YELLOW)安装 golangci-lint...$(NC)"; \ go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest; \ fi @golangci-lint run # 依赖管理 deps: @echo "$(GREEN)📚 更新依赖...$(NC)" @go mod tidy @go mod download @echo "$(GREEN)✅ 依赖更新完成$(NC)" # 查看依赖树 deps-tree: @echo "$(GREEN)🌳 依赖树:$(NC)" @go mod graph # 安装开发工具 install-tools: install-air @echo "$(GREEN)🔧 安装开发工具...$(NC)" @go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest @echo "$(GREEN)✅ 所有工具安装完成$(NC)" ================================================ FILE: backend-go/README.md ================================================ # Claude / Codex / Gemini API Proxy - Go 版本 > 🚀 高性能的 Claude / Codex / Gemini API Proxy - Go 语言实现,支持多种上游AI服务提供商,内置前端管理界面 ## 特性 - ✅ **完整的 TypeScript 后端功能移植**:所有原 TS 后端功能完整实现 - 🚀 **高性能**:Go 语言实现,性能优于 Node.js 版本 - 📦 **单文件部署**:前端资源嵌入二进制文件,无需额外配置 - 🔄 **协议转换**:自动转换 Claude 格式请求到不同上游服务商格式 - ⚖️ **故障转移**:支持多 API 密钥的智能分配和自动切换 - 🖥️ **Web 管理界面**:内置的前端管理界面(嵌入式) - 🛡️ **高可用性**:健康检查、错误处理和优雅降级 ## 支持的上游服务 - ✅ OpenAI (GPT-4, GPT-3.5 等) - ✅ Gemini (Google AI) - ✅ Claude (Anthropic) - ✅ OpenAI Old (旧版兼容) ## 最新更新 (v2.0.1) ### 🐛 重要修复 - ✅ 修复前端资源加载问题(Vite base 路径配置) - ✅ 修复静态文件 MIME 类型错误(favicon.ico 等) - ✅ 修复 API 路由与前端不匹配问题 - ✅ 修复版本信息未注入问题 ### ⚡ 性能优化 - ✅ 智能前端构建缓存(无变更时 0.07秒启动,提升 142 倍) - ✅ 优化代码分割(vue-vendor 独立打包) ### 📝 改进 - ✅ ENV 环境变量标准化(替代 NODE_ENV,向后兼容) - ✅ 添加 favicon 支持(SVG 格式) - ✅ 完善文档和开发指南 --- ## 快速开始 ### 方式1:下载预编译二进制文件(推荐) 1. 从 [Releases](https://github.com/yourusername/claude-proxy/releases) 下载对应平台的二进制文件 2. 创建 `.env` 文件: ```bash # 复制示例配置 cp .env.example .env # 编辑配置 nano .env ``` 3. 运行服务器: ```bash # Linux / macOS ./claude-proxy-linux-amd64 # Windows claude-proxy-windows-amd64.exe ``` ### 方式2:从源码构建 #### 前置要求 - Go 1.22 或更高版本 - Node.js 18+ (用于构建前端) #### 构建步骤 ```bash # 1. 克隆项目 git clone https://github.com/yourusername/claude-proxy.git cd claude-proxy # 2. 构建前端 cd frontend npm install npm run build cd .. # 3. 构建 Go 后端(包含前端资源) cd backend-go ./build.sh # 构建产物位于 dist/ 目录 ``` ## 配置说明 ### 环境变量配置 (.env) ```env # ============ 服务器配置 ============ PORT=3000 # 运行环境: development | production # 影响: # - production: Gin ReleaseMode(高性能)、关闭/admin/dev/info、严格CORS # - development: Gin DebugMode(详细日志)、开启/admin/dev/info、宽松CORS ENV=production # ============ Web UI 配置 ============ ENABLE_WEB_UI=true # ============ 访问控制 ============ # 代理访问密钥(必须修改!) PROXY_ACCESS_KEY=your-secure-access-key # ============ 日志配置 ============ # 日志级别: error | warn | info | debug LOG_LEVEL=info # 是否启用请求/响应日志 ENABLE_REQUEST_LOGS=true ENABLE_RESPONSE_LOGS=true # ============ 性能配置 ============ # 请求超时时间(毫秒) REQUEST_TIMEOUT=30000 # ============ CORS 配置 ============ ENABLE_CORS=true CORS_ORIGIN=* ``` ### 环境模式详解 | 配置项 | development | production | |--------|-------------|------------| | **Gin 模式** | DebugMode (详细日志) | ReleaseMode (高性能) | | **开发端点** | `/admin/dev/info` 开启 | `/admin/dev/info` 关闭 | | **CORS 策略** | 自动允许所有 localhost 源 | 严格使用 CORS_ORIGIN 配置 | | **日志输出** | 路由注册、请求详情 | 仅错误和警告 | | **安全性** | 低(暴露调试信息) | 高(最小信息暴露) | **建议**: - 开发测试时使用 `ENV=development` - 生产部署时务必使用 `ENV=production` ### 渠道配置 服务启动后,通过 Web 管理界面 (http://localhost:3000) 配置上游渠道和 API 密钥。 或者直接编辑配置文件 `.config/config.json`: ```json { "upstream": [ { "name": "OpenAI", "baseUrl": "https://api.openai.com/v1", "apiKeys": ["sk-your-api-key"], "serviceType": "openai", "status": "active" } ], "loadBalance": "failover" } ``` ### 渠道状态自动变化 以下场景会触发渠道状态的自动变化: | 场景 | 触发条件 | 自动行为 | |------|----------|----------| | **单 Key 更换自动激活** | 渠道只有 1 个 Key,且更新为不同的 Key | 1. 状态从 `suspended` 变为 `active`
2. 重置熔断状态(清除错误计数) | | **熔断自动恢复** | 渠道熔断后超过恢复时间(默认 15 分钟) | 自动清除熔断标记,渠道恢复可用 | | **无 Key 自动暂停** | 渠道配置为 `active` 但没有 API Key | 状态自动设为 `suspended` | **设计说明:** - 单 Key 更换时自动激活,因为用户明显想要使用新 Key - 多 Key 场景不会自动激活,避免误操作(用户可能只是添加/删除部分 Key) - `disabled` 状态不受影响,用户主动禁用的渠道不会被自动激活 ### 渠道促销期(Promotion) 促销期机制用于临时提升某个渠道的优先级,让新渠道能够快速获得流量进行测试。 **促销期特性:** - 处于促销期的渠道会被**优先选择**,忽略 trace 亲和性 - 同一时间**只能有一个渠道**处于促销期(设置新渠道会自动清除旧渠道的促销期) - 促销期有**时间限制**,到期后自动失效 - 促销渠道如果**不健康**(熔断/无可用密钥),会自动跳过 **自动触发场景:** | 场景 | 触发条件 | 自动行为 | |------|----------|----------| | **快速添加渠道** | 通过 Web UI 快速添加新渠道 | 1. 新渠道排序到第一位
2. 设置 5 分钟促销期 | **API 使用:** ```bash # 设置渠道促销期(600秒 = 10分钟) curl -X POST http://localhost:3000/api/channels/0/promotion \ -H "x-api-key: your-proxy-access-key" \ -H "Content-Type: application/json" \ -d '{"duration": 600}' # 清除渠道促销期 curl -X POST http://localhost:3000/api/channels/0/promotion \ -H "x-api-key: your-proxy-access-key" \ -H "Content-Type: application/json" \ -d '{"duration": 0}' ``` **适用场景:** - 新增渠道后,临时提升优先级进行测试 - 更换 Key 后,验证新 Key 是否正常工作 - 临时将流量切换到特定渠道 ## 使用方法 ### 访问 Web 管理界面 打开浏览器访问: http://localhost:3000 首次访问需要输入 `PROXY_ACCESS_KEY` ### API 调用 ```bash curl -X POST http://localhost:3000/v1/messages \ -H "x-api-key: your-proxy-access-key" \ -H "Content-Type: application/json" \ -d '{ "model": "claude-3-5-sonnet-20241022", "max_tokens": 1024, "messages": [ {"role": "user", "content": "Hello, Claude!"} ] }' ``` ## 架构对比 | 特性 | TypeScript 版本 | Go 版本 | |------|----------------|---------| | 运行时 | Node.js/Bun | Go (编译型) | | 性能 | 中等 | 高 | | 内存占用 | 较高 | 较低 | | 部署 | 需要 Node.js 环境 | 单文件可执行 | | 启动速度 | 较慢 | 快速 | | 并发处理 | 事件循环 | Goroutine(原生并发)| ## 目录结构 ``` backend-go/ ├── main.go # 主程序入口 ├── go.mod # Go 模块定义 ├── build.sh # 构建脚本 ├── internal/ │ ├── config/ # 配置管理 │ │ ├── env.go # 环境变量配置 │ │ └── config.go # 配置文件管理 │ ├── providers/ # 上游服务适配器 │ │ ├── provider.go # Provider 接口 │ │ ├── openai.go # OpenAI 适配器 │ │ ├── gemini.go # Gemini 适配器 │ │ └── claude.go # Claude 适配器 │ ├── middleware/ # HTTP 中间件 │ │ ├── cors.go # CORS 中间件 │ │ └── auth.go # 认证中间件 │ ├── handlers/ # HTTP 处理器 │ │ ├── health.go # 健康检查 │ │ ├── config.go # 配置管理 API │ │ ├── proxy.go # 代理处理逻辑 │ │ └── frontend.go # 前端资源服务 │ └── types/ # 类型定义 │ └── types.go # 请求/响应类型 └── frontend/dist/ # 嵌入的前端资源(构建时生成) ``` ## 性能优化 Go 版本相比 TypeScript 版本的性能优势: 1. **更低的内存占用**:Go 的垃圾回收机制更高效 2. **更快的启动速度**:编译型语言,无需运行时解析 3. **更好的并发性能**:原生 Goroutine 支持 4. **更小的部署包**:单文件可执行,无需 node_modules ## 常见问题 ### 1. 如何更新前端资源? 重新构建前端后,运行 `./build.sh` 重新打包。 ### 2. 如何禁用 Web UI? 在 `.env` 文件中设置 `ENABLE_WEB_UI=false` ### 3. 支持热重载配置吗? 支持!配置文件(`.config/config.json`)变更会自动重载,无需重启服务器。 ### 4. 如何添加自定义上游服务? 实现 `providers.Provider` 接口并在 `providers.GetProvider` 中注册即可。 ## 开发 ### 🔥 热重载开发模式(新增) Go 版本现在支持代码热重载,修改代码后自动重新编译和重启! #### 安装热重载工具 ```bash # 方式一:使用 make(推荐) make install-air # 方式二:使用 npm/bun npm run dev:go:install # 方式三:直接安装 go install github.com/air-verse/air@latest ``` #### 启动热重载开发模式 ```bash # 方式一:使用 make(推荐) make dev # 自动检测并安装 Air,启动热重载 # 方式二:使用 npm/bun npm run dev:go # 或 bun run dev:go # 方式三:直接使用 air cd backend-go && air ``` **热重载特性:** - ✅ **自动重启** - 修改 `.go` 文件后自动重新编译和重启 - ✅ **配置监听** - 修改 `.yaml`, `.toml`, `.env` 文件也会触发重启 - ✅ **错误恢复** - 编译错误时保持运行,修复后自动恢复 - ✅ **彩色日志** - 不同类型日志使用不同颜色,便于调试 - ✅ **性能优化** - 1秒延迟编译,避免频繁重启 ### 推荐开发流程(智能缓存) ```bash # 使用 Makefile - 自动管理前端构建缓存 make dev # 🔥 热重载开发模式(推荐) make run # 首次构建前端,后续仅在源文件变更时重新编译 make build # 构建生产版本 make clean # 清除所有构建缓存和临时文件 # 手动控制 make build-local # 构建本地版本 make test # 运行测试 make test-cover # 生成测试覆盖率报告 make fmt # 格式化代码 make lint # 代码检查 make deps # 更新依赖 ``` **智能缓存机制:** - ✅ `make run` 自动检测 `frontend/src` 目录文件变更 - ✅ 未变更时跳过编译,**秒级启动**服务器 - ✅ 首次运行或源文件修改后自动重新编译 - ✅ 使用标记文件 `.build-marker` 追踪构建状态 ### Air 配置说明 `.air.toml` 文件定义了热重载行为: ```toml # 监听的文件类型 include_ext = ["go", "tpl", "tmpl", "html", "yaml", "yml", "toml", "env"] # 忽略的目录 exclude_dir = ["assets", "tmp", "vendor", "frontend", "dist"] # 编译延迟(毫秒) delay = 1000 # 编译错误时是否停止 stop_on_error = true ``` ### 传统开发方式 ```bash # 直接运行(不推荐 - 无版本信息) go run main.go # 运行测试 go test ./... # 格式化代码 go fmt ./... # 静态检查 go vet ./... ``` ### 开发技巧 1. **使用热重载**:`make dev` 启动后,专注于代码编写,无需手动重启 2. **查看日志**:热重载模式下日志有颜色区分,更易阅读 3. **错误处理**:编译错误会显示在控制台,修复后自动重新编译 4. **配置更新**:修改 `.env` 或配置文件也会触发重启 ## 版本管理 ### 升级版本 只需修改根目录的 `VERSION` 文件: ```bash # 编辑 VERSION 文件 echo "v1.1.0" > ../VERSION # 重新构建即可 make build ``` 所有构建产物会自动包含新版本号,无需修改代码! ### 查看版本信息 ```bash # 查看项目版本信息 make info # 启动服务器后查看版本 curl http://localhost:3000/health | jq '.version' # 输出示例: # { # "version": "v1.0.0", # "buildTime": "2025-01-15_10:30:45_UTC", # "gitCommit": "abc1234" # } ``` ## 许可证 MIT License ## 贡献 欢迎提交 Issue 和 Pull Request! --- **注意**: 这是 Claude Proxy 的 Go 语言重写版本,完整实现了原 TypeScript 版本的所有功能,并提供了更好的性能和部署体验。 ================================================ FILE: backend-go/build.sh ================================================ #!/bin/bash # Claude Proxy Go 版本构建脚本 set -e # 版本信息 - 从根目录 VERSION 文件读取 VERSION=$(cat ../VERSION 2>/dev/null || echo "v0.0.0-dev") BUILD_TIME=$(date '+%Y-%m-%d_%H:%M:%S_%Z') GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") # 构建标志 LDFLAGS="-X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.GitCommit=${GIT_COMMIT}" echo "🚀 开始构建 Claude Proxy Go 版本..." echo "📌 版本: ${VERSION}" echo "🕐 构建时间: ${BUILD_TIME}" echo "🔖 Git提交: ${GIT_COMMIT}" echo "" # 检查前端构建产物是否存在 if [ ! -d "../frontend/dist" ]; then echo "❌ 前端构建产物不存在,请先构建前端:" echo " cd ../frontend && npm run build" exit 1 fi # 创建 frontend/dist 目录并复制前端资源 echo "📦 复制前端资源..." rm -rf frontend/dist mkdir -p frontend/dist cp -r ../frontend/dist/* frontend/dist/ # 下载依赖 echo "📥 下载 Go 依赖..." go mod download go mod tidy # 创建输出目录 mkdir -p dist # 构建二进制文件 echo "🔨 构建二进制文件..." # Linux echo " - 构建 Linux (amd64)..." CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o dist/claude-proxy-linux-amd64 . # Linux ARM64 echo " - 构建 Linux (arm64)..." CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o dist/claude-proxy-linux-arm64 . # macOS echo " - 构建 macOS (amd64)..." CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o dist/claude-proxy-darwin-amd64 . # macOS ARM64 (M1/M2) echo " - 构建 macOS (arm64)..." CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "${LDFLAGS}" -o dist/claude-proxy-darwin-arm64 . # Windows echo " - 构建 Windows (amd64)..." CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o dist/claude-proxy-windows-amd64.exe . echo "" echo "✅ 构建完成!" echo "" echo "📁 构建产物位于 dist/ 目录:" ls -lh dist/ echo "" echo "💡 使用方法:" echo " 1. 复制对应平台的二进制文件到目标机器" echo " 2. 创建 .env 文件配置环境变量" echo " 3. 运行: ./claude-proxy-linux-amd64" echo "" echo "📌 版本信息已注入到二进制文件中" ================================================ FILE: backend-go/docs/MALFORMED_TOOLCALL_MEMO.md ================================================ # 畸形 tool_call 问题备忘录 > 创建时间: 2025-12-19 > 状态: 待观察,暂不修复 ## 问题描述 上游 Claude API 在流式返回时,偶尔会在同一个 `content_block` 中错误地发送多个工具调用的参数。 ### 表现形式 1. **参数拼接**:两个工具的 JSON 参数被拼接成无效格式 ```json {"command": "git diff --stat", "description": "..."}{"command": "git diff xxx", "description": "..."} ``` 2. **元数据缺失**:第二个工具缺少必要的 `name` 和 `id` 3. **下游解析失败**:客户端(如 Claude Code)收到畸形数据后可能无法正确解析 ### 日志示例 ``` 2025/12/19 11:00:51.203101 🛰️ 上游流式响应合成内容: ... Tool Call: Bash({"command": "git diff --stat", "description": "获取变更统计摘要"}{"command": "git diff backend-go/internal/providers/claude.go", "description": "获取 claude.go 的详细变更内容"}) [ID: toolu_01S6L3ngcGA9XKQrT1o2PLQa] ``` ## 曾尝试的修复方案 ### 方案 1: 实时流处理修复(已放弃) 在 SSE 流传输过程中实时检测并修复畸形数据。 **实现内容**: - `toolCallFixer` 结构体跟踪状态 - `findJSONObjectBoundary()` 使用状态机检测 JSON 边界 - `inferToolName()` 根据参数推断工具名称 - `shouldFilterStop()` 过滤重复的 stop 事件 - `cleanupOnStop()` 清理内存 **放弃原因**: - 逻辑复杂,经过多轮 Codex Review 仍有边缘问题 - 合成 block 的 index 可能与后续上游 index 冲突 - 需要处理重复 `content_block_stop` 事件 - 内存管理复杂 ### 方案 2: 流结束后修复(未实现) 在流式响应完全结束后,检测并修复拼接的 tool_call。 **优势**: - 逻辑简单:只在流结束时处理一次 - 无状态冲突:不需要实时跟踪 block index **未实现原因**: - 问题发生频率较低 - 等待上游修复 ## 工具名称推断规则 如果将来需要实现修复,可根据参数 key 组合推断工具类型: | 参数组合 | 工具名称 | |---------|---------| | `file_path` + `content` | Write | | `file_path` + `old_string` + `new_string` | Edit | | `file_path` (仅) | Read | | `command` | Bash | | `pattern` + `output_mode`/`glob`/`type` | Grep | | `pattern` (仅) | Glob | | `url` | WebFetch | | `query` | WebSearch | | `todos` | TodoWrite | | `prompt` + `subagent_type` | Task | ## 相关文件 - `internal/providers/claude.go` - Claude Provider 流式处理 - `internal/utils/stream_synthesizer.go` - 日志合成器 ## 后续行动 - [ ] 持续观察问题发生频率 - [ ] 如频繁触发,考虑实现"流结束后修复"方案 - [ ] 关注上游 Claude API 是否修复此问题 ================================================ FILE: backend-go/go.mod ================================================ module github.com/BenedictKing/claude-proxy go 1.22 require ( github.com/fsnotify/fsnotify v1.7.0 github.com/gin-gonic/gin v1.10.0 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/stretchr/testify v1.9.0 github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 gopkg.in/natefinch/lumberjack.v2 v2.2.1 modernc.org/sqlite v1.34.4 ) require ( github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/crypto v0.23.0 // indirect golang.org/x/net v0.25.0 // indirect golang.org/x/sys v0.22.0 // indirect golang.org/x/text v0.15.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect modernc.org/libc v1.55.3 // indirect modernc.org/mathutil v1.6.0 // indirect modernc.org/memory v1.8.0 // indirect modernc.org/strutil v1.2.0 // indirect modernc.org/token v1.1.0 // indirect ) ================================================ FILE: backend-go/go.sum ================================================ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8= modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= ================================================ FILE: backend-go/internal/config/config.go ================================================ package config import ( "fmt" "log" "sync" "time" "github.com/BenedictKing/claude-proxy/internal/utils" "github.com/fsnotify/fsnotify" ) // ============== 核心类型定义 ============== // UpstreamConfig 上游配置 type UpstreamConfig struct { BaseURL string `json:"baseUrl"` BaseURLs []string `json:"baseUrls,omitempty"` // 多 BaseURL 支持(failover 模式) APIKeys []string `json:"apiKeys"` HistoricalAPIKeys []string `json:"historicalApiKeys,omitempty"` // 历史 API Key(用于统计聚合,换 Key 后保留旧 Key 的统计数据) ServiceType string `json:"serviceType"` // gemini, openai, claude Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` Website string `json:"website,omitempty"` InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` ModelMapping map[string]string `json:"modelMapping,omitempty"` // 多渠道调度相关字段 Priority int `json:"priority"` // 渠道优先级(数字越小优先级越高,默认按索引) Status string `json:"status"` // 渠道状态:active(正常), suspended(暂停), disabled(备用池) PromotionUntil *time.Time `json:"promotionUntil,omitempty"` // 促销期截止时间,在此期间内优先使用此渠道(忽略trace亲和) LowQuality bool `json:"lowQuality,omitempty"` // 低质量渠道标记:启用后强制本地估算 token,偏差>5%时使用本地值 // Gemini 特定配置 InjectDummyThoughtSignature bool `json:"injectDummyThoughtSignature,omitempty"` // 给空 thought_signature 注入 dummy 值(兼容 x666.me 等要求必须有该字段的 API) StripThoughtSignature bool `json:"stripThoughtSignature,omitempty"` // 移除 thought_signature 字段(兼容旧版 Gemini API) } // UpstreamUpdate 用于部分更新 UpstreamConfig type UpstreamUpdate struct { Name *string `json:"name"` ServiceType *string `json:"serviceType"` BaseURL *string `json:"baseUrl"` BaseURLs []string `json:"baseUrls"` APIKeys []string `json:"apiKeys"` Description *string `json:"description"` Website *string `json:"website"` InsecureSkipVerify *bool `json:"insecureSkipVerify"` ModelMapping map[string]string `json:"modelMapping"` // 多渠道调度相关字段 Priority *int `json:"priority"` Status *string `json:"status"` PromotionUntil *time.Time `json:"promotionUntil"` LowQuality *bool `json:"lowQuality"` // Gemini 特定配置 InjectDummyThoughtSignature *bool `json:"injectDummyThoughtSignature"` StripThoughtSignature *bool `json:"stripThoughtSignature"` } // Config 配置结构 type Config struct { Upstream []UpstreamConfig `json:"upstream"` CurrentUpstream int `json:"currentUpstream,omitempty"` // 已废弃:旧格式兼容用 LoadBalance string `json:"loadBalance"` // round-robin, random, failover // Responses 接口专用配置(独立于 /v1/messages) ResponsesUpstream []UpstreamConfig `json:"responsesUpstream"` CurrentResponsesUpstream int `json:"currentResponsesUpstream,omitempty"` // 已废弃:旧格式兼容用 ResponsesLoadBalance string `json:"responsesLoadBalance"` // Gemini 接口专用配置(独立于 /v1/messages 和 /v1/responses) GeminiUpstream []UpstreamConfig `json:"geminiUpstream"` GeminiLoadBalance string `json:"geminiLoadBalance"` // Fuzzy 模式:启用时模糊处理错误,所有非 2xx 错误都尝试 failover FuzzyModeEnabled bool `json:"fuzzyModeEnabled"` } // FailedKey 失败密钥记录 type FailedKey struct { Timestamp time.Time FailureCount int } // ConfigManager 配置管理器 type ConfigManager struct { mu sync.RWMutex config Config configFile string watcher *fsnotify.Watcher failedKeysCache map[string]*FailedKey keyRecoveryTime time.Duration maxFailureCount int stopChan chan struct{} // 用于通知 goroutine 停止 closeOnce sync.Once // 确保 Close 只执行一次 } // ============== 核心共享方法 ============== // GetConfig 获取配置(返回深拷贝,确保并发安全) func (cm *ConfigManager) GetConfig() Config { cm.mu.RLock() defer cm.mu.RUnlock() // 深拷贝整个 Config 结构体 cloned := cm.config // 深拷贝 Upstream slice if cm.config.Upstream != nil { cloned.Upstream = make([]UpstreamConfig, len(cm.config.Upstream)) for i := range cm.config.Upstream { cloned.Upstream[i] = *cm.config.Upstream[i].Clone() } } // 深拷贝 ResponsesUpstream slice if cm.config.ResponsesUpstream != nil { cloned.ResponsesUpstream = make([]UpstreamConfig, len(cm.config.ResponsesUpstream)) for i := range cm.config.ResponsesUpstream { cloned.ResponsesUpstream[i] = *cm.config.ResponsesUpstream[i].Clone() } } // 深拷贝 GeminiUpstream slice if cm.config.GeminiUpstream != nil { cloned.GeminiUpstream = make([]UpstreamConfig, len(cm.config.GeminiUpstream)) for i := range cm.config.GeminiUpstream { cloned.GeminiUpstream[i] = *cm.config.GeminiUpstream[i].Clone() } } return cloned } // GetNextAPIKey 获取下一个 API 密钥(纯 failover 模式) // apiType: 接口类型(Messages/Responses/Gemini),用于日志标签前缀 func (cm *ConfigManager) GetNextAPIKey(upstream *UpstreamConfig, failedKeys map[string]bool, apiType string) (string, error) { if len(upstream.APIKeys) == 0 { return "", fmt.Errorf("上游 %s 没有可用的API密钥", upstream.Name) } // 单 Key 直接返回 if len(upstream.APIKeys) == 1 { return upstream.APIKeys[0], nil } // 筛选可用密钥:排除临时失败密钥和内存中的失败密钥 availableKeys := []string{} for _, key := range upstream.APIKeys { if !failedKeys[key] && !cm.isKeyFailed(key) { availableKeys = append(availableKeys, key) } } if len(availableKeys) == 0 { // 如果所有密钥都失效,尝试选择失败时间最早的密钥(恢复尝试) var oldestFailedKey string oldestTime := time.Now() cm.mu.RLock() for _, key := range upstream.APIKeys { if !failedKeys[key] { // 排除本次请求已经尝试过的密钥 if failure, exists := cm.failedKeysCache[key]; exists { if failure.Timestamp.Before(oldestTime) { oldestTime = failure.Timestamp oldestFailedKey = key } } } } cm.mu.RUnlock() if oldestFailedKey != "" { log.Printf("[%s-Key] 警告: 所有密钥都失效,尝试最早失败的密钥: %s", apiType, utils.MaskAPIKey(oldestFailedKey)) return oldestFailedKey, nil } return "", fmt.Errorf("上游 %s 的所有API密钥都暂时不可用", upstream.Name) } // 纯 failover:按优先级顺序选择第一个可用密钥 selectedKey := availableKeys[0] // 获取该密钥在原始列表中的索引 keyIndex := 0 for i, key := range upstream.APIKeys { if key == selectedKey { keyIndex = i + 1 break } } log.Printf("[%s-Key] 故障转移选择密钥 %s (%d/%d)", apiType, utils.MaskAPIKey(selectedKey), keyIndex, len(upstream.APIKeys)) return selectedKey, nil } // MarkKeyAsFailed 标记密钥失败 // apiType: 接口类型(Messages/Responses/Gemini),用于日志标签前缀 func (cm *ConfigManager) MarkKeyAsFailed(apiKey string, apiType string) { cm.mu.Lock() defer cm.mu.Unlock() if failure, exists := cm.failedKeysCache[apiKey]; exists { failure.FailureCount++ failure.Timestamp = time.Now() } else { cm.failedKeysCache[apiKey] = &FailedKey{ Timestamp: time.Now(), FailureCount: 1, } } failure := cm.failedKeysCache[apiKey] recoveryTime := cm.keyRecoveryTime if failure.FailureCount > cm.maxFailureCount { recoveryTime = cm.keyRecoveryTime * 2 } log.Printf("[%s-Key] 标记API密钥失败: %s (失败次数: %d, 恢复时间: %v)", apiType, utils.MaskAPIKey(apiKey), failure.FailureCount, recoveryTime) } // isKeyFailed 检查密钥是否失败 func (cm *ConfigManager) isKeyFailed(apiKey string) bool { cm.mu.RLock() defer cm.mu.RUnlock() failure, exists := cm.failedKeysCache[apiKey] if !exists { return false } recoveryTime := cm.keyRecoveryTime if failure.FailureCount > cm.maxFailureCount { recoveryTime = cm.keyRecoveryTime * 2 } return time.Since(failure.Timestamp) < recoveryTime } // IsKeyFailed 检查 Key 是否在冷却期(公开方法) func (cm *ConfigManager) IsKeyFailed(apiKey string) bool { return cm.isKeyFailed(apiKey) } // clearFailedKeysForUpstream 清理指定渠道的所有失败 key 记录 // 当渠道被删除时调用,避免内存泄漏和冷却状态残留 // apiType: 接口类型(Messages/Responses/Gemini),用于日志标签前缀 func (cm *ConfigManager) clearFailedKeysForUpstream(upstream *UpstreamConfig, apiType string) { for _, key := range upstream.APIKeys { if _, exists := cm.failedKeysCache[key]; exists { delete(cm.failedKeysCache, key) log.Printf("[%s-Key] 已清理被删除渠道 %s 的失败密钥记录: %s", apiType, upstream.Name, utils.MaskAPIKey(key)) } } } // cleanupExpiredFailures 清理过期的失败记录 func (cm *ConfigManager) cleanupExpiredFailures() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for { select { case <-cm.stopChan: return case <-ticker.C: cm.mu.Lock() now := time.Now() for key, failure := range cm.failedKeysCache { recoveryTime := cm.keyRecoveryTime if failure.FailureCount > cm.maxFailureCount { recoveryTime = cm.keyRecoveryTime * 2 } if now.Sub(failure.Timestamp) > recoveryTime { delete(cm.failedKeysCache, key) log.Printf("[Config-Key] API密钥 %s 已从失败列表中恢复", utils.MaskAPIKey(key)) } } cm.mu.Unlock() } } } // ============== Fuzzy 模式相关方法 ============== // GetFuzzyModeEnabled 获取 Fuzzy 模式状态 func (cm *ConfigManager) GetFuzzyModeEnabled() bool { cm.mu.RLock() defer cm.mu.RUnlock() return cm.config.FuzzyModeEnabled } // SetFuzzyModeEnabled 设置 Fuzzy 模式状态 func (cm *ConfigManager) SetFuzzyModeEnabled(enabled bool) error { cm.mu.Lock() defer cm.mu.Unlock() cm.config.FuzzyModeEnabled = enabled if err := cm.saveConfigLocked(cm.config); err != nil { return err } status := "关闭" if enabled { status = "启用" } log.Printf("[Config-FuzzyMode] Fuzzy 模式已%s", status) return nil } ================================================ FILE: backend-go/internal/config/config_baseurl_test.go ================================================ package config import ( "os" "path/filepath" "testing" ) // TestUpdateUpstream_BaseURLConsistency 测试更新 baseUrl 时的一致性 // 覆盖场景: // 1. 只更新 baseUrl 时,baseUrls 应被清空 // 2. 只更新 baseUrls 时,baseUrl 应保持不变 // 3. 同时更新 baseUrl 和 baseUrls 时,两者应独立更新 // 4. 都不更新时,两者应保持原值 func TestUpdateUpstream_BaseURLConsistency(t *testing.T) { // 创建临时配置文件 tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") initialConfig := `{ "upstream": [{ "name": "test-channel", "baseUrl": "https://old.example.com", "baseUrls": ["https://old-1.example.com", "https://old-2.example.com"], "apiKeys": ["test-key"], "serviceType": "claude" }], "loadBalance": "failover" }` if err := os.WriteFile(configPath, []byte(initialConfig), 0644); err != nil { t.Fatalf("写入初始配置失败: %v", err) } // 初始化配置管理器 cm, err := NewConfigManager(configPath) if err != nil { t.Fatalf("初始化配置管理器失败: %v", err) } defer cm.Close() tests := []struct { name string updates UpstreamUpdate wantBaseURL string wantBaseURLs []string wantBaseURLsNil bool // 期望 baseUrls 为 nil(而非空切片) }{ { name: "只更新 baseUrl 时 baseUrls 应被清空", updates: UpstreamUpdate{ BaseURL: strPtr("https://new.example.com"), }, wantBaseURL: "https://new.example.com", wantBaseURLsNil: true, }, { name: "只更新 baseUrls 时 baseUrl 应保持不变", updates: UpstreamUpdate{ BaseURLs: []string{"https://urls-1.example.com", "https://urls-2.example.com"}, }, wantBaseURL: "https://new.example.com", // 保持上个测试的值 wantBaseURLs: []string{"https://urls-1.example.com", "https://urls-2.example.com"}, }, { name: "同时更新 baseUrl 和 baseUrls 时两者独立更新", updates: UpstreamUpdate{ BaseURL: strPtr("https://both-base.example.com"), BaseURLs: []string{"https://both-urls-1.example.com", "https://both-urls-2.example.com"}, }, wantBaseURL: "https://both-base.example.com", wantBaseURLs: []string{"https://both-urls-1.example.com", "https://both-urls-2.example.com"}, }, { name: "都不更新时保持原值", updates: UpstreamUpdate{Name: strPtr("renamed-channel")}, wantBaseURL: "https://both-base.example.com", wantBaseURLs: []string{"https://both-urls-1.example.com", "https://both-urls-2.example.com"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := cm.UpdateUpstream(0, tt.updates) if err != nil { t.Fatalf("UpdateUpstream 失败: %v", err) } cfg := cm.GetConfig() upstream := cfg.Upstream[0] if upstream.BaseURL != tt.wantBaseURL { t.Errorf("BaseURL = %q, want %q", upstream.BaseURL, tt.wantBaseURL) } if tt.wantBaseURLsNil { if upstream.BaseURLs != nil { t.Errorf("BaseURLs = %v, want nil", upstream.BaseURLs) } } else { if len(upstream.BaseURLs) != len(tt.wantBaseURLs) { t.Errorf("BaseURLs length = %d, want %d", len(upstream.BaseURLs), len(tt.wantBaseURLs)) } else { for i, url := range upstream.BaseURLs { if url != tt.wantBaseURLs[i] { t.Errorf("BaseURLs[%d] = %q, want %q", i, url, tt.wantBaseURLs[i]) } } } } }) } } // TestUpdateResponsesUpstream_BaseURLConsistency 测试 Responses 渠道的 baseUrl 一致性 func TestUpdateResponsesUpstream_BaseURLConsistency(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") initialConfig := `{ "upstream": [], "responsesUpstream": [{ "name": "responses-channel", "baseUrl": "https://old.responses.com", "baseUrls": ["https://old-1.responses.com", "https://old-2.responses.com"], "apiKeys": ["test-key"], "serviceType": "claude" }], "loadBalance": "failover", "responsesLoadBalance": "failover" }` if err := os.WriteFile(configPath, []byte(initialConfig), 0644); err != nil { t.Fatalf("写入初始配置失败: %v", err) } cm, err := NewConfigManager(configPath) if err != nil { t.Fatalf("初始化配置管理器失败: %v", err) } defer cm.Close() // 测试:只更新 baseUrl 时 baseUrls 应被清空 t.Run("只更新 baseUrl 时 baseUrls 应被清空", func(t *testing.T) { _, err := cm.UpdateResponsesUpstream(0, UpstreamUpdate{ BaseURL: strPtr("https://new.responses.com"), }) if err != nil { t.Fatalf("UpdateResponsesUpstream 失败: %v", err) } cfg := cm.GetConfig() upstream := cfg.ResponsesUpstream[0] if upstream.BaseURL != "https://new.responses.com" { t.Errorf("BaseURL = %q, want %q", upstream.BaseURL, "https://new.responses.com") } if upstream.BaseURLs != nil { t.Errorf("BaseURLs = %v, want nil", upstream.BaseURLs) } }) } // TestUpdateGeminiUpstream_BaseURLConsistency 测试 Gemini 渠道的 baseUrl 一致性 func TestUpdateGeminiUpstream_BaseURLConsistency(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") initialConfig := `{ "upstream": [], "geminiUpstream": [{ "name": "gemini-channel", "baseUrl": "https://old.gemini.com", "baseUrls": ["https://old-1.gemini.com", "https://old-2.gemini.com"], "apiKeys": ["test-key"], "serviceType": "gemini" }], "loadBalance": "failover", "geminiLoadBalance": "failover" }` if err := os.WriteFile(configPath, []byte(initialConfig), 0644); err != nil { t.Fatalf("写入初始配置失败: %v", err) } cm, err := NewConfigManager(configPath) if err != nil { t.Fatalf("初始化配置管理器失败: %v", err) } defer cm.Close() // 测试:只更新 baseUrl 时 baseUrls 应被清空 t.Run("只更新 baseUrl 时 baseUrls 应被清空", func(t *testing.T) { _, err := cm.UpdateGeminiUpstream(0, UpstreamUpdate{ BaseURL: strPtr("https://new.gemini.com"), }) if err != nil { t.Fatalf("UpdateGeminiUpstream 失败: %v", err) } cfg := cm.GetConfig() upstream := cfg.GeminiUpstream[0] if upstream.BaseURL != "https://new.gemini.com" { t.Errorf("BaseURL = %q, want %q", upstream.BaseURL, "https://new.gemini.com") } if upstream.BaseURLs != nil { t.Errorf("BaseURLs = %v, want nil", upstream.BaseURLs) } }) } // TestGetAllBaseURLs_Priority 测试 GetAllBaseURLs 的优先级逻辑 func TestGetAllBaseURLs_Priority(t *testing.T) { tests := []struct { name string upstream UpstreamConfig want []string }{ { name: "baseUrls 非空时优先返回 baseUrls", upstream: UpstreamConfig{ BaseURL: "https://single.example.com", BaseURLs: []string{"https://multi-1.example.com", "https://multi-2.example.com"}, }, want: []string{"https://multi-1.example.com", "https://multi-2.example.com"}, }, { name: "baseUrls 为空时回退到 baseUrl", upstream: UpstreamConfig{ BaseURL: "https://single.example.com", BaseURLs: nil, }, want: []string{"https://single.example.com"}, }, { name: "两者都为空时返回 nil", upstream: UpstreamConfig{ BaseURL: "", BaseURLs: nil, }, want: nil, }, { name: "baseUrls 为空切片时回退到 baseUrl", upstream: UpstreamConfig{ BaseURL: "https://single.example.com", BaseURLs: []string{}, }, want: []string{"https://single.example.com"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.upstream.GetAllBaseURLs() if len(got) != len(tt.want) { t.Errorf("GetAllBaseURLs() length = %d, want %d", len(got), len(tt.want)) return } for i := range got { if got[i] != tt.want[i] { t.Errorf("GetAllBaseURLs()[%d] = %q, want %q", i, got[i], tt.want[i]) } } }) } } // TestGetEffectiveBaseURL_Priority 测试 GetEffectiveBaseURL 的优先级逻辑 func TestGetEffectiveBaseURL_Priority(t *testing.T) { tests := []struct { name string upstream UpstreamConfig want string }{ { name: "baseUrl 非空时优先返回 baseUrl", upstream: UpstreamConfig{ BaseURL: "https://single.example.com", BaseURLs: []string{"https://multi-1.example.com", "https://multi-2.example.com"}, }, want: "https://single.example.com", }, { name: "baseUrl 为空时回退到 baseUrls[0]", upstream: UpstreamConfig{ BaseURL: "", BaseURLs: []string{"https://multi-1.example.com", "https://multi-2.example.com"}, }, want: "https://multi-1.example.com", }, { name: "两者都为空时返回空字符串", upstream: UpstreamConfig{ BaseURL: "", BaseURLs: nil, }, want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.upstream.GetEffectiveBaseURL() if got != tt.want { t.Errorf("GetEffectiveBaseURL() = %q, want %q", got, tt.want) } }) } } // TestDeduplicateBaseURLs 测试 BaseURLs 去重逻辑 func TestDeduplicateBaseURLs(t *testing.T) { tests := []struct { name string input []string want []string }{ { name: "精确重复应去重", input: []string{"https://a.com", "https://b.com", "https://a.com"}, want: []string{"https://a.com", "https://b.com"}, }, { name: "末尾斜杠差异应视为相同", input: []string{"https://a.com", "https://a.com/"}, want: []string{"https://a.com"}, }, { name: "末尾井号差异应视为相同", input: []string{"https://a.com", "https://a.com#"}, want: []string{"https://a.com"}, }, { name: "保持原始顺序", input: []string{"https://c.com", "https://a.com", "https://b.com"}, want: []string{"https://c.com", "https://a.com", "https://b.com"}, }, { name: "单个元素不变", input: []string{"https://only.com"}, want: []string{"https://only.com"}, }, { name: "空切片返回空切片", input: []string{}, want: []string{}, }, { name: "nil 返回 nil", input: nil, want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := deduplicateBaseURLs(tt.input) if len(got) != len(tt.want) { t.Errorf("deduplicateBaseURLs() length = %d, want %d", len(got), len(tt.want)) return } for i := range got { if got[i] != tt.want[i] { t.Errorf("deduplicateBaseURLs()[%d] = %q, want %q", i, got[i], tt.want[i]) } } }) } } // TestAddUpstream_BaseURLDeduplication 测试添加渠道时的 BaseURLs 去重 func TestAddUpstream_BaseURLDeduplication(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") initialConfig := `{"upstream": [], "loadBalance": "failover"}` if err := os.WriteFile(configPath, []byte(initialConfig), 0644); err != nil { t.Fatalf("写入初始配置失败: %v", err) } cm, err := NewConfigManager(configPath) if err != nil { t.Fatalf("初始化配置管理器失败: %v", err) } defer cm.Close() // 添加包含重复 URL 的渠道 err = cm.AddUpstream(UpstreamConfig{ Name: "dedup-test", BaseURL: "https://main.example.com", BaseURLs: []string{"https://a.com", "https://b.com", "https://a.com/", "https://c.com"}, APIKeys: []string{"key1"}, ServiceType: "claude", }) if err != nil { t.Fatalf("AddUpstream 失败: %v", err) } cfg := cm.GetConfig() upstream := cfg.Upstream[0] // 期望去重后只有 3 个 URL expectedURLs := []string{"https://a.com", "https://b.com", "https://c.com"} if len(upstream.BaseURLs) != len(expectedURLs) { t.Errorf("BaseURLs length = %d, want %d", len(upstream.BaseURLs), len(expectedURLs)) } for i, url := range upstream.BaseURLs { if url != expectedURLs[i] { t.Errorf("BaseURLs[%d] = %q, want %q", i, url, expectedURLs[i]) } } } // strPtr 辅助函数:返回字符串指针 func strPtr(s string) *string { return &s } ================================================ FILE: backend-go/internal/config/config_gemini.go ================================================ package config import ( "fmt" "log" "strings" "time" "github.com/BenedictKing/claude-proxy/internal/utils" ) // ============== Gemini 渠道方法 ============== // GetCurrentGeminiUpstream 获取当前 Gemini 上游配置 // 优先选择第一个 active 状态的渠道,若无则回退到第一个渠道 func (cm *ConfigManager) GetCurrentGeminiUpstream() (*UpstreamConfig, error) { cm.mu.RLock() defer cm.mu.RUnlock() if len(cm.config.GeminiUpstream) == 0 { return nil, fmt.Errorf("未配置任何 Gemini 渠道") } // 优先选择第一个 active 状态的渠道 for i := range cm.config.GeminiUpstream { status := cm.config.GeminiUpstream[i].Status if status == "" || status == "active" { return &cm.config.GeminiUpstream[i], nil } } // 没有 active 渠道,回退到第一个渠道 return &cm.config.GeminiUpstream[0], nil } // AddGeminiUpstream 添加 Gemini 上游 func (cm *ConfigManager) AddGeminiUpstream(upstream UpstreamConfig) error { cm.mu.Lock() defer cm.mu.Unlock() // 新建渠道默认设为 active if upstream.Status == "" { upstream.Status = "active" } // 去重 API Keys 和 Base URLs upstream.APIKeys = deduplicateStrings(upstream.APIKeys) upstream.BaseURLs = deduplicateBaseURLs(upstream.BaseURLs) cm.config.GeminiUpstream = append(cm.config.GeminiUpstream, upstream) if err := cm.saveConfigLocked(cm.config); err != nil { return err } log.Printf("[Config-Upstream] 已添加 Gemini 上游: %s", upstream.Name) return nil } // UpdateGeminiUpstream 更新 Gemini 上游 // 返回值:shouldResetMetrics 表示是否需要重置渠道指标(熔断状态) func (cm *ConfigManager) UpdateGeminiUpstream(index int, updates UpstreamUpdate) (shouldResetMetrics bool, err error) { cm.mu.Lock() defer cm.mu.Unlock() if index < 0 || index >= len(cm.config.GeminiUpstream) { return false, fmt.Errorf("无效的 Gemini 上游索引: %d", index) } upstream := &cm.config.GeminiUpstream[index] if updates.Name != nil { upstream.Name = *updates.Name } if updates.BaseURL != nil { upstream.BaseURL = *updates.BaseURL // 当 BaseURL 被更新且 BaseURLs 未被显式设置时,清空 BaseURLs 保持一致性 // 避免出现 baseUrl 和 baseUrls[0] 不一致的情况 if updates.BaseURLs == nil { upstream.BaseURLs = nil } } if updates.BaseURLs != nil { upstream.BaseURLs = deduplicateBaseURLs(updates.BaseURLs) } if updates.ServiceType != nil { upstream.ServiceType = *updates.ServiceType } if updates.Description != nil { upstream.Description = *updates.Description } if updates.Website != nil { upstream.Website = *updates.Website } if updates.APIKeys != nil { // 记录被移除的 Key 到历史列表(用于统计聚合) newKeys := make(map[string]bool) for _, key := range updates.APIKeys { newKeys[key] = true } // 找出被移除的 Key(在旧列表中但不在新列表中) for _, key := range upstream.APIKeys { if !newKeys[key] { // 检查是否已在历史列表中 alreadyInHistory := false for _, hk := range upstream.HistoricalAPIKeys { if hk == key { alreadyInHistory = true break } } if !alreadyInHistory { upstream.HistoricalAPIKeys = append(upstream.HistoricalAPIKeys, key) log.Printf("[Config-Upstream] Gemini 渠道 [%d] %s: Key %s 已移入历史列表", index, upstream.Name, utils.MaskAPIKey(key)) } } } // 如果新 Key 在历史列表中,从历史列表移除(换回来了) var newHistoricalKeys []string for _, hk := range upstream.HistoricalAPIKeys { if !newKeys[hk] { newHistoricalKeys = append(newHistoricalKeys, hk) } else { log.Printf("[Config-Upstream] Gemini 渠道 [%d] %s: Key %s 已从历史列表恢复", index, upstream.Name, utils.MaskAPIKey(hk)) } } upstream.HistoricalAPIKeys = newHistoricalKeys // 只有单 key 场景且 key 被更换时,才自动激活并重置熔断 if len(upstream.APIKeys) == 1 && len(updates.APIKeys) == 1 && upstream.APIKeys[0] != updates.APIKeys[0] { shouldResetMetrics = true if upstream.Status == "suspended" { upstream.Status = "active" log.Printf("[Config-Upstream] Gemini 渠道 [%d] %s 已从暂停状态自动激活(单 key 更换)", index, upstream.Name) } } upstream.APIKeys = deduplicateStrings(updates.APIKeys) } if updates.ModelMapping != nil { upstream.ModelMapping = updates.ModelMapping } if updates.InsecureSkipVerify != nil { upstream.InsecureSkipVerify = *updates.InsecureSkipVerify } if updates.Priority != nil { upstream.Priority = *updates.Priority } if updates.Status != nil { upstream.Status = *updates.Status } if updates.PromotionUntil != nil { upstream.PromotionUntil = updates.PromotionUntil } if updates.LowQuality != nil { upstream.LowQuality = *updates.LowQuality } if updates.InjectDummyThoughtSignature != nil { upstream.InjectDummyThoughtSignature = *updates.InjectDummyThoughtSignature } if updates.StripThoughtSignature != nil { upstream.StripThoughtSignature = *updates.StripThoughtSignature } if err := cm.saveConfigLocked(cm.config); err != nil { return false, err } log.Printf("[Config-Upstream] 已更新 Gemini 上游: [%d] %s", index, cm.config.GeminiUpstream[index].Name) return shouldResetMetrics, nil } // RemoveGeminiUpstream 删除 Gemini 上游 func (cm *ConfigManager) RemoveGeminiUpstream(index int) (*UpstreamConfig, error) { cm.mu.Lock() defer cm.mu.Unlock() if index < 0 || index >= len(cm.config.GeminiUpstream) { return nil, fmt.Errorf("无效的 Gemini 上游索引: %d", index) } removed := cm.config.GeminiUpstream[index] cm.config.GeminiUpstream = append(cm.config.GeminiUpstream[:index], cm.config.GeminiUpstream[index+1:]...) // 清理被删除渠道的失败 key 冷却记录 cm.clearFailedKeysForUpstream(&removed, "Gemini") if err := cm.saveConfigLocked(cm.config); err != nil { return nil, err } log.Printf("[Config-Upstream] 已删除 Gemini 上游: %s", removed.Name) return &removed, nil } // AddGeminiAPIKey 添加 Gemini 上游的 API 密钥 func (cm *ConfigManager) AddGeminiAPIKey(index int, apiKey string) error { cm.mu.Lock() defer cm.mu.Unlock() if index < 0 || index >= len(cm.config.GeminiUpstream) { return fmt.Errorf("无效的上游索引: %d", index) } // 检查密钥是否已存在 for _, key := range cm.config.GeminiUpstream[index].APIKeys { if key == apiKey { return fmt.Errorf("API密钥已存在") } } cm.config.GeminiUpstream[index].APIKeys = append(cm.config.GeminiUpstream[index].APIKeys, apiKey) // 如果该 Key 在历史列表中,从历史列表移除(换回来了) var newHistoricalKeys []string for _, hk := range cm.config.GeminiUpstream[index].HistoricalAPIKeys { if hk != apiKey { newHistoricalKeys = append(newHistoricalKeys, hk) } else { log.Printf("[Gemini-Key] 上游 [%d] %s: Key %s 已从历史列表恢复", index, cm.config.GeminiUpstream[index].Name, utils.MaskAPIKey(hk)) } } cm.config.GeminiUpstream[index].HistoricalAPIKeys = newHistoricalKeys if err := cm.saveConfigLocked(cm.config); err != nil { return err } log.Printf("[Gemini-Key] 已添加API密钥到 Gemini 上游 [%d] %s", index, cm.config.GeminiUpstream[index].Name) return nil } // RemoveGeminiAPIKey 删除 Gemini 上游的 API 密钥 func (cm *ConfigManager) RemoveGeminiAPIKey(index int, apiKey string) error { cm.mu.Lock() defer cm.mu.Unlock() if index < 0 || index >= len(cm.config.GeminiUpstream) { return fmt.Errorf("无效的上游索引: %d", index) } // 查找并删除密钥 keys := cm.config.GeminiUpstream[index].APIKeys found := false for i, key := range keys { if key == apiKey { cm.config.GeminiUpstream[index].APIKeys = append(keys[:i], keys[i+1:]...) found = true break } } if !found { return fmt.Errorf("API密钥不存在") } // 将被移除的 Key 添加到历史列表(用于统计聚合) alreadyInHistory := false for _, hk := range cm.config.GeminiUpstream[index].HistoricalAPIKeys { if hk == apiKey { alreadyInHistory = true break } } if !alreadyInHistory { cm.config.GeminiUpstream[index].HistoricalAPIKeys = append(cm.config.GeminiUpstream[index].HistoricalAPIKeys, apiKey) log.Printf("[Gemini-Key] 上游 [%d] %s: Key %s 已移入历史列表", index, cm.config.GeminiUpstream[index].Name, utils.MaskAPIKey(apiKey)) } if err := cm.saveConfigLocked(cm.config); err != nil { return err } log.Printf("[Gemini-Key] 已从 Gemini 上游 [%d] %s 删除API密钥", index, cm.config.GeminiUpstream[index].Name) return nil } // GetNextGeminiAPIKey 获取下一个 Gemini API 密钥(纯 failover 模式) func (cm *ConfigManager) GetNextGeminiAPIKey(upstream *UpstreamConfig, failedKeys map[string]bool) (string, error) { return cm.GetNextAPIKey(upstream, failedKeys, "Gemini") } // MoveGeminiAPIKeyToTop 将指定 Gemini 渠道的 API 密钥移到最前面 func (cm *ConfigManager) MoveGeminiAPIKeyToTop(upstreamIndex int, apiKey string) error { cm.mu.Lock() defer cm.mu.Unlock() if upstreamIndex < 0 || upstreamIndex >= len(cm.config.GeminiUpstream) { return fmt.Errorf("无效的上游索引: %d", upstreamIndex) } upstream := &cm.config.GeminiUpstream[upstreamIndex] index := -1 for i, key := range upstream.APIKeys { if key == apiKey { index = i break } } if index <= 0 { return nil } upstream.APIKeys = append([]string{apiKey}, append(upstream.APIKeys[:index], upstream.APIKeys[index+1:]...)...) return cm.saveConfigLocked(cm.config) } // MoveGeminiAPIKeyToBottom 将指定 Gemini 渠道的 API 密钥移到最后面 func (cm *ConfigManager) MoveGeminiAPIKeyToBottom(upstreamIndex int, apiKey string) error { cm.mu.Lock() defer cm.mu.Unlock() if upstreamIndex < 0 || upstreamIndex >= len(cm.config.GeminiUpstream) { return fmt.Errorf("无效的上游索引: %d", upstreamIndex) } upstream := &cm.config.GeminiUpstream[upstreamIndex] index := -1 for i, key := range upstream.APIKeys { if key == apiKey { index = i break } } if index == -1 || index == len(upstream.APIKeys)-1 { return nil } upstream.APIKeys = append(upstream.APIKeys[:index], upstream.APIKeys[index+1:]...) upstream.APIKeys = append(upstream.APIKeys, apiKey) return cm.saveConfigLocked(cm.config) } // ReorderGeminiUpstreams 重新排序 Gemini 渠道优先级 // order 是渠道索引数组,按新的优先级顺序排列(只更新传入的渠道,支持部分排序) func (cm *ConfigManager) ReorderGeminiUpstreams(order []int) error { cm.mu.Lock() defer cm.mu.Unlock() if len(order) == 0 { return fmt.Errorf("排序数组不能为空") } seen := make(map[int]bool) for _, idx := range order { if idx < 0 || idx >= len(cm.config.GeminiUpstream) { return fmt.Errorf("无效的渠道索引: %d", idx) } if seen[idx] { return fmt.Errorf("重复的渠道索引: %d", idx) } seen[idx] = true } // 更新传入渠道的优先级(未传入的渠道保持原优先级不变) // 注意:priority 从 1 开始,避免 omitempty 吞掉 0 值 for i, idx := range order { cm.config.GeminiUpstream[idx].Priority = i + 1 } if err := cm.saveConfigLocked(cm.config); err != nil { return err } log.Printf("[Config-Reorder] 已更新 Gemini 渠道优先级顺序 (%d 个渠道)", len(order)) return nil } // SetGeminiChannelStatus 设置 Gemini 渠道状态 func (cm *ConfigManager) SetGeminiChannelStatus(index int, status string) error { cm.mu.Lock() defer cm.mu.Unlock() if index < 0 || index >= len(cm.config.GeminiUpstream) { return fmt.Errorf("无效的上游索引: %d", index) } // 状态值转为小写,支持大小写不敏感 status = strings.ToLower(status) if status != "active" && status != "suspended" && status != "disabled" { return fmt.Errorf("无效的状态: %s (允许值: active, suspended, disabled)", status) } cm.config.GeminiUpstream[index].Status = status // 暂停时清除促销期 if status == "suspended" && cm.config.GeminiUpstream[index].PromotionUntil != nil { cm.config.GeminiUpstream[index].PromotionUntil = nil log.Printf("[Config-Status] 已清除 Gemini 渠道 [%d] %s 的促销期", index, cm.config.GeminiUpstream[index].Name) } if err := cm.saveConfigLocked(cm.config); err != nil { return err } log.Printf("[Config-Status] 已设置 Gemini 渠道 [%d] %s 状态为: %s", index, cm.config.GeminiUpstream[index].Name, status) return nil } // SetGeminiChannelPromotion 设置 Gemini 渠道促销期 func (cm *ConfigManager) SetGeminiChannelPromotion(index int, duration time.Duration) error { cm.mu.Lock() defer cm.mu.Unlock() if index < 0 || index >= len(cm.config.GeminiUpstream) { return fmt.Errorf("无效的 Gemini 上游索引: %d", index) } if duration <= 0 { cm.config.GeminiUpstream[index].PromotionUntil = nil log.Printf("[Config-Promotion] 已清除 Gemini 渠道 [%d] %s 的促销期", index, cm.config.GeminiUpstream[index].Name) } else { // 清除其他渠道的促销期(同一时间只允许一个促销渠道) for i := range cm.config.GeminiUpstream { if i != index && cm.config.GeminiUpstream[i].PromotionUntil != nil { cm.config.GeminiUpstream[i].PromotionUntil = nil } } promotionEnd := time.Now().Add(duration) cm.config.GeminiUpstream[index].PromotionUntil = &promotionEnd log.Printf("[Config-Promotion] 已设置 Gemini 渠道 [%d] %s 进入促销期,截止: %s", index, cm.config.GeminiUpstream[index].Name, promotionEnd.Format(time.RFC3339)) } return cm.saveConfigLocked(cm.config) } // GetPromotedGeminiChannel 获取当前处于促销期的 Gemini 渠道索引 func (cm *ConfigManager) GetPromotedGeminiChannel() (int, bool) { cm.mu.RLock() defer cm.mu.RUnlock() for i, upstream := range cm.config.GeminiUpstream { if IsChannelInPromotion(&upstream) && GetChannelStatus(&upstream) == "active" { return i, true } } return -1, false } // SetGeminiLoadBalance 设置 Gemini 负载均衡策略 func (cm *ConfigManager) SetGeminiLoadBalance(strategy string) error { cm.mu.Lock() defer cm.mu.Unlock() if err := validateLoadBalanceStrategy(strategy); err != nil { return err } cm.config.GeminiLoadBalance = strategy if err := cm.saveConfigLocked(cm.config); err != nil { return err } log.Printf("[Config-LoadBalance] 已设置 Gemini 负载均衡策略: %s", strategy) return nil } ================================================ FILE: backend-go/internal/config/config_loader.go ================================================ package config import ( "encoding/json" "fmt" "log" "os" "path/filepath" "time" "github.com/fsnotify/fsnotify" ) const ( maxBackups = 10 keyRecoveryTime = 5 * time.Minute maxFailureCount = 3 ) // NewConfigManager 创建配置管理器 func NewConfigManager(configFile string) (*ConfigManager, error) { cm := &ConfigManager{ configFile: configFile, failedKeysCache: make(map[string]*FailedKey), keyRecoveryTime: keyRecoveryTime, maxFailureCount: maxFailureCount, stopChan: make(chan struct{}), } // 加载配置 if err := cm.loadConfig(); err != nil { return nil, err } // 启动文件监听 if err := cm.startWatcher(); err != nil { log.Printf("[Config-Watcher] 警告: 启动配置文件监听失败: %v", err) } // 启动定期清理 go cm.cleanupExpiredFailures() return cm, nil } // loadConfig 加载配置 func (cm *ConfigManager) loadConfig() error { cm.mu.Lock() defer cm.mu.Unlock() // 如果配置文件不存在,创建默认配置 if _, err := os.Stat(cm.configFile); os.IsNotExist(err) { return cm.createDefaultConfig() } // 读取配置文件 data, err := os.ReadFile(cm.configFile) if err != nil { return err } if err := json.Unmarshal(data, &cm.config); err != nil { return err } // 兼容旧配置:检查 FuzzyModeEnabled 字段是否存在 // 如果不存在,默认设为 true(新功能默认启用) needSaveDefaults := cm.applyConfigDefaults(data) // 兼容旧格式:检测是否需要迁移 needMigration := cm.migrateOldFormat() // 如果有默认值迁移或格式迁移,保存配置 if needSaveDefaults || needMigration { if err := cm.saveConfigLocked(cm.config); err != nil { log.Printf("[Config-Migration] 警告: 保存迁移后的配置失败: %v", err) return err } if needMigration { log.Printf("[Config-Migration] 配置迁移完成") } } // 自检:没有配置 key 的渠道自动暂停 if cm.validateChannelKeys() { if err := cm.saveConfigLocked(cm.config); err != nil { log.Printf("[Config-Validate] 警告: 保存自检后的配置失败: %v", err) return err } } return nil } // createDefaultConfig 创建默认配置 func (cm *ConfigManager) createDefaultConfig() error { defaultConfig := Config{ Upstream: []UpstreamConfig{}, CurrentUpstream: 0, LoadBalance: "failover", ResponsesUpstream: []UpstreamConfig{}, CurrentResponsesUpstream: 0, ResponsesLoadBalance: "failover", GeminiUpstream: []UpstreamConfig{}, GeminiLoadBalance: "failover", FuzzyModeEnabled: true, // 默认启用 Fuzzy 模式 } if err := os.MkdirAll(filepath.Dir(cm.configFile), 0755); err != nil { return err } return cm.saveConfigLocked(defaultConfig) } // applyConfigDefaults 应用配置默认值 // rawJSON: 原始 JSON 数据,用于检测字段是否存在 // 返回: 是否有字段需要迁移(需要保存配置) func (cm *ConfigManager) applyConfigDefaults(rawJSON []byte) bool { needSave := false if cm.config.LoadBalance == "" { cm.config.LoadBalance = "failover" } if cm.config.ResponsesLoadBalance == "" { cm.config.ResponsesLoadBalance = cm.config.LoadBalance } if cm.config.GeminiLoadBalance == "" { cm.config.GeminiLoadBalance = "failover" } // FuzzyModeEnabled 默认值处理: // 由于 bool 零值是 false,无法区分"用户设为 false"和"字段不存在" // 通过检查原始 JSON 是否包含该字段来判断 var rawMap map[string]json.RawMessage if err := json.Unmarshal(rawJSON, &rawMap); err == nil { if _, exists := rawMap["fuzzyModeEnabled"]; !exists { // 字段不存在,设为默认值 true cm.config.FuzzyModeEnabled = true needSave = true log.Printf("[Config-Migration] FuzzyModeEnabled 字段不存在,设为默认值 true") } } return needSave } // migrateOldFormat 迁移旧格式配置,返回是否有迁移 func (cm *ConfigManager) migrateOldFormat() bool { needMigration := false // 迁移 Messages 渠道 if cm.migrateUpstreams(cm.config.Upstream, cm.config.CurrentUpstream, "Messages") { needMigration = true } // 迁移 Responses 渠道 if cm.migrateUpstreams(cm.config.ResponsesUpstream, cm.config.CurrentResponsesUpstream, "Responses") { needMigration = true } if needMigration { log.Printf("[Config-Migration] 检测到旧格式配置,正在迁移到新格式...") } return needMigration } // migrateUpstreams 迁移单个渠道列表 func (cm *ConfigManager) migrateUpstreams(upstreams []UpstreamConfig, currentIdx int, name string) bool { if len(upstreams) == 0 { return false } // 检查是否已有 status 字段 for _, up := range upstreams { if up.Status != "" { return false } } // 需要迁移 if currentIdx < 0 || currentIdx >= len(upstreams) { currentIdx = 0 } for i := range upstreams { if i == currentIdx { upstreams[i].Status = "active" } else { upstreams[i].Status = "disabled" } } log.Printf("[Config-Migration] %s 渠道 [%d] %s 已设置为 active,其他 %d 个渠道已设为 disabled", name, currentIdx, upstreams[currentIdx].Name, len(upstreams)-1) return true } // validateChannelKeys 自检渠道密钥配置 // 没有配置 API key 的渠道,即使状态为 active 也应暂停 // 返回 true 表示有配置被修改,需要保存 func (cm *ConfigManager) validateChannelKeys() bool { modified := false // 检查 Messages 渠道 for i := range cm.config.Upstream { upstream := &cm.config.Upstream[i] status := upstream.Status if status == "" { status = "active" } // 如果是 active 状态但没有配置 key,自动设为 suspended if status == "active" && len(upstream.APIKeys) == 0 { upstream.Status = "suspended" modified = true log.Printf("[Config-Validate] 警告: Messages 渠道 [%d] %s 没有配置 API key,已自动暂停", i, upstream.Name) } } // 检查 Responses 渠道 for i := range cm.config.ResponsesUpstream { upstream := &cm.config.ResponsesUpstream[i] status := upstream.Status if status == "" { status = "active" } // 如果是 active 状态但没有配置 key,自动设为 suspended if status == "active" && len(upstream.APIKeys) == 0 { upstream.Status = "suspended" modified = true log.Printf("[Config-Validate] 警告: Responses 渠道 [%d] %s 没有配置 API key,已自动暂停", i, upstream.Name) } } // 检查 Gemini 渠道 for i := range cm.config.GeminiUpstream { upstream := &cm.config.GeminiUpstream[i] status := upstream.Status if status == "" { status = "active" } // 如果是 active 状态但没有配置 key,自动设为 suspended if status == "active" && len(upstream.APIKeys) == 0 { upstream.Status = "suspended" modified = true log.Printf("[Config-Validate] 警告: Gemini 渠道 [%d] %s 没有配置 API key,已自动暂停", i, upstream.Name) } } return modified } // saveConfigLocked 保存配置(已加锁) func (cm *ConfigManager) saveConfigLocked(config Config) error { // 备份当前配置 cm.backupConfig() // 清理已废弃字段,确保不会被序列化到 JSON config.CurrentUpstream = 0 config.CurrentResponsesUpstream = 0 data, err := json.MarshalIndent(config, "", " ") if err != nil { return err } cm.config = config return os.WriteFile(cm.configFile, data, 0644) } // SaveConfig 保存配置 func (cm *ConfigManager) SaveConfig() error { cm.mu.Lock() defer cm.mu.Unlock() return cm.saveConfigLocked(cm.config) } // backupConfig 备份配置 func (cm *ConfigManager) backupConfig() { if _, err := os.Stat(cm.configFile); os.IsNotExist(err) { return } backupDir := filepath.Join(filepath.Dir(cm.configFile), "backups") if err := os.MkdirAll(backupDir, 0755); err != nil { log.Printf("[Config-Backup] 警告: 创建备份目录失败: %v", err) return } // 读取当前配置 data, err := os.ReadFile(cm.configFile) if err != nil { log.Printf("[Config-Backup] 警告: 读取配置文件失败: %v", err) return } // 创建备份文件 timestamp := time.Now().Format("2006-01-02T15-04-05") backupFile := filepath.Join(backupDir, fmt.Sprintf("config-%s.json", timestamp)) if err := os.WriteFile(backupFile, data, 0644); err != nil { log.Printf("[Config-Backup] 警告: 写入备份文件失败: %v", err) return } // 清理旧备份 cm.cleanupOldBackups(backupDir) } // cleanupOldBackups 清理旧备份 func (cm *ConfigManager) cleanupOldBackups(backupDir string) { entries, err := os.ReadDir(backupDir) if err != nil { return } if len(entries) <= maxBackups { return } // 删除最旧的备份 for i := 0; i < len(entries)-maxBackups; i++ { os.Remove(filepath.Join(backupDir, entries[i].Name())) } } // startWatcher 启动文件监听 func (cm *ConfigManager) startWatcher() error { watcher, err := fsnotify.NewWatcher() if err != nil { return err } cm.watcher = watcher go func() { for { select { case <-cm.stopChan: return case event, ok := <-watcher.Events: if !ok { return } if event.Op&fsnotify.Write == fsnotify.Write { log.Printf("[Config-Watcher] 检测到配置文件变化,重载配置...") if err := cm.loadConfig(); err != nil { log.Printf("[Config-Watcher] 警告: 配置重载失败: %v", err) } else { log.Printf("[Config-Watcher] 配置已重载") } } case err, ok := <-watcher.Errors: if !ok { return } log.Printf("[Config-Watcher] 警告: 文件监听错误: %v", err) } } }() return watcher.Add(cm.configFile) } // Close 关闭 ConfigManager 并释放资源(幂等,可安全多次调用) func (cm *ConfigManager) Close() error { var closeErr error cm.closeOnce.Do(func() { // 通知所有 goroutine 停止 if cm.stopChan != nil { close(cm.stopChan) } // 关闭文件监听器 if cm.watcher != nil { closeErr = cm.watcher.Close() } }) return closeErr } ================================================ FILE: backend-go/internal/config/config_messages.go ================================================ package config import ( "fmt" "log" "strings" "time" "github.com/BenedictKing/claude-proxy/internal/utils" ) // ============== Messages 渠道方法 ============== // GetCurrentUpstream 获取当前上游配置 // 优先选择第一个 active 状态的渠道,若无则回退到第一个渠道 func (cm *ConfigManager) GetCurrentUpstream() (*UpstreamConfig, error) { cm.mu.RLock() defer cm.mu.RUnlock() if len(cm.config.Upstream) == 0 { return nil, fmt.Errorf("未配置任何上游渠道") } // 优先选择第一个 active 状态的渠道 for i := range cm.config.Upstream { status := cm.config.Upstream[i].Status if status == "" || status == "active" { return &cm.config.Upstream[i], nil } } // 没有 active 渠道,回退到第一个渠道 return &cm.config.Upstream[0], nil } // AddUpstream 添加上游 func (cm *ConfigManager) AddUpstream(upstream UpstreamConfig) error { cm.mu.Lock() defer cm.mu.Unlock() // 新建渠道默认设为 active if upstream.Status == "" { upstream.Status = "active" } // 去重 API Keys 和 Base URLs upstream.APIKeys = deduplicateStrings(upstream.APIKeys) upstream.BaseURLs = deduplicateBaseURLs(upstream.BaseURLs) cm.config.Upstream = append(cm.config.Upstream, upstream) if err := cm.saveConfigLocked(cm.config); err != nil { return err } log.Printf("[Config-Upstream] 已添加上游: %s", upstream.Name) return nil } // UpdateUpstream 更新上游 // 返回值:shouldResetMetrics 表示是否需要重置渠道指标(熔断状态) func (cm *ConfigManager) UpdateUpstream(index int, updates UpstreamUpdate) (shouldResetMetrics bool, err error) { cm.mu.Lock() defer cm.mu.Unlock() if index < 0 || index >= len(cm.config.Upstream) { return false, fmt.Errorf("无效的上游索引: %d", index) } upstream := &cm.config.Upstream[index] if updates.Name != nil { upstream.Name = *updates.Name } if updates.BaseURL != nil { upstream.BaseURL = *updates.BaseURL // 当 BaseURL 被更新且 BaseURLs 未被显式设置时,清空 BaseURLs 保持一致性 // 避免出现 baseUrl 和 baseUrls[0] 不一致的情况 if updates.BaseURLs == nil { upstream.BaseURLs = nil } } if updates.BaseURLs != nil { upstream.BaseURLs = deduplicateBaseURLs(updates.BaseURLs) } if updates.ServiceType != nil { upstream.ServiceType = *updates.ServiceType } if updates.Description != nil { upstream.Description = *updates.Description } if updates.Website != nil { upstream.Website = *updates.Website } if updates.APIKeys != nil { // 记录被移除的 Key 到历史列表(用于统计聚合) newKeys := make(map[string]bool) for _, key := range updates.APIKeys { newKeys[key] = true } // 找出被移除的 Key(在旧列表中但不在新列表中) for _, key := range upstream.APIKeys { if !newKeys[key] { // 检查是否已在历史列表中 alreadyInHistory := false for _, hk := range upstream.HistoricalAPIKeys { if hk == key { alreadyInHistory = true break } } if !alreadyInHistory { upstream.HistoricalAPIKeys = append(upstream.HistoricalAPIKeys, key) log.Printf("[Config-Upstream] 渠道 [%d] %s: Key %s 已移入历史列表", index, upstream.Name, utils.MaskAPIKey(key)) } } } // 如果新 Key 在历史列表中,从历史列表移除(换回来了) var newHistoricalKeys []string for _, hk := range upstream.HistoricalAPIKeys { if !newKeys[hk] { newHistoricalKeys = append(newHistoricalKeys, hk) } else { log.Printf("[Config-Upstream] 渠道 [%d] %s: Key %s 已从历史列表恢复", index, upstream.Name, utils.MaskAPIKey(hk)) } } upstream.HistoricalAPIKeys = newHistoricalKeys // 只有单 key 场景且 key 被更换时,才自动激活并重置熔断 if len(upstream.APIKeys) == 1 && len(updates.APIKeys) == 1 && upstream.APIKeys[0] != updates.APIKeys[0] { shouldResetMetrics = true if upstream.Status == "suspended" { upstream.Status = "active" log.Printf("[Config-Upstream] 渠道 [%d] %s 已从暂停状态自动激活(单 key 更换)", index, upstream.Name) } } upstream.APIKeys = deduplicateStrings(updates.APIKeys) } if updates.ModelMapping != nil { upstream.ModelMapping = updates.ModelMapping } if updates.InsecureSkipVerify != nil { upstream.InsecureSkipVerify = *updates.InsecureSkipVerify } if updates.Priority != nil { upstream.Priority = *updates.Priority } if updates.Status != nil { upstream.Status = *updates.Status } if updates.PromotionUntil != nil { upstream.PromotionUntil = updates.PromotionUntil } if updates.LowQuality != nil { upstream.LowQuality = *updates.LowQuality } if updates.InjectDummyThoughtSignature != nil { upstream.InjectDummyThoughtSignature = *updates.InjectDummyThoughtSignature } if err := cm.saveConfigLocked(cm.config); err != nil { return false, err } log.Printf("[Config-Upstream] 已更新上游: [%d] %s", index, cm.config.Upstream[index].Name) return shouldResetMetrics, nil } // RemoveUpstream 删除上游 func (cm *ConfigManager) RemoveUpstream(index int) (*UpstreamConfig, error) { cm.mu.Lock() defer cm.mu.Unlock() if index < 0 || index >= len(cm.config.Upstream) { return nil, fmt.Errorf("无效的上游索引: %d", index) } removed := cm.config.Upstream[index] cm.config.Upstream = append(cm.config.Upstream[:index], cm.config.Upstream[index+1:]...) // 清理被删除渠道的失败 key 冷却记录 cm.clearFailedKeysForUpstream(&removed, "Messages") if err := cm.saveConfigLocked(cm.config); err != nil { return nil, err } log.Printf("[Config-Upstream] 已删除上游: %s", removed.Name) return &removed, nil } // AddAPIKey 添加API密钥 func (cm *ConfigManager) AddAPIKey(index int, apiKey string) error { cm.mu.Lock() defer cm.mu.Unlock() if index < 0 || index >= len(cm.config.Upstream) { return fmt.Errorf("无效的上游索引: %d", index) } // 检查密钥是否已存在 for _, key := range cm.config.Upstream[index].APIKeys { if key == apiKey { return fmt.Errorf("API密钥已存在") } } cm.config.Upstream[index].APIKeys = append(cm.config.Upstream[index].APIKeys, apiKey) // 如果该 Key 在历史列表中,从历史列表移除(换回来了) var newHistoricalKeys []string for _, hk := range cm.config.Upstream[index].HistoricalAPIKeys { if hk != apiKey { newHistoricalKeys = append(newHistoricalKeys, hk) } else { log.Printf("[Messages-Key] 上游 [%d] %s: Key %s 已从历史列表恢复", index, cm.config.Upstream[index].Name, utils.MaskAPIKey(hk)) } } cm.config.Upstream[index].HistoricalAPIKeys = newHistoricalKeys if err := cm.saveConfigLocked(cm.config); err != nil { return err } log.Printf("[Messages-Key] 已添加API密钥到上游 [%d] %s", index, cm.config.Upstream[index].Name) return nil } // RemoveAPIKey 删除API密钥 func (cm *ConfigManager) RemoveAPIKey(index int, apiKey string) error { cm.mu.Lock() defer cm.mu.Unlock() if index < 0 || index >= len(cm.config.Upstream) { return fmt.Errorf("无效的上游索引: %d", index) } // 查找并删除密钥 keys := cm.config.Upstream[index].APIKeys found := false for i, key := range keys { if key == apiKey { cm.config.Upstream[index].APIKeys = append(keys[:i], keys[i+1:]...) found = true break } } if !found { return fmt.Errorf("API密钥不存在") } // 将被移除的 Key 添加到历史列表(用于统计聚合) alreadyInHistory := false for _, hk := range cm.config.Upstream[index].HistoricalAPIKeys { if hk == apiKey { alreadyInHistory = true break } } if !alreadyInHistory { cm.config.Upstream[index].HistoricalAPIKeys = append(cm.config.Upstream[index].HistoricalAPIKeys, apiKey) log.Printf("[Messages-Key] 上游 [%d] %s: Key %s 已移入历史列表", index, cm.config.Upstream[index].Name, utils.MaskAPIKey(apiKey)) } if err := cm.saveConfigLocked(cm.config); err != nil { return err } log.Printf("[Messages-Key] 已从上游 [%d] %s 删除API密钥", index, cm.config.Upstream[index].Name) return nil } // SetLoadBalance 设置 Messages 负载均衡策略 func (cm *ConfigManager) SetLoadBalance(strategy string) error { cm.mu.Lock() defer cm.mu.Unlock() if err := validateLoadBalanceStrategy(strategy); err != nil { return err } cm.config.LoadBalance = strategy if err := cm.saveConfigLocked(cm.config); err != nil { return err } log.Printf("[Config-LoadBalance] 已设置负载均衡策略: %s", strategy) return nil } // MoveAPIKeyToTop 将指定渠道的 API 密钥移到最前面 func (cm *ConfigManager) MoveAPIKeyToTop(upstreamIndex int, apiKey string) error { cm.mu.Lock() defer cm.mu.Unlock() if upstreamIndex < 0 || upstreamIndex >= len(cm.config.Upstream) { return fmt.Errorf("无效的上游索引: %d", upstreamIndex) } upstream := &cm.config.Upstream[upstreamIndex] index := -1 for i, key := range upstream.APIKeys { if key == apiKey { index = i break } } if index <= 0 { return nil // 已经在最前面或未找到 } // 移动到开头 upstream.APIKeys = append([]string{apiKey}, append(upstream.APIKeys[:index], upstream.APIKeys[index+1:]...)...) return cm.saveConfigLocked(cm.config) } // MoveAPIKeyToBottom 将指定渠道的 API 密钥移到最后面 func (cm *ConfigManager) MoveAPIKeyToBottom(upstreamIndex int, apiKey string) error { cm.mu.Lock() defer cm.mu.Unlock() if upstreamIndex < 0 || upstreamIndex >= len(cm.config.Upstream) { return fmt.Errorf("无效的上游索引: %d", upstreamIndex) } upstream := &cm.config.Upstream[upstreamIndex] index := -1 for i, key := range upstream.APIKeys { if key == apiKey { index = i break } } if index == -1 || index == len(upstream.APIKeys)-1 { return nil // 已经在最后面或未找到 } // 移动到末尾 upstream.APIKeys = append(upstream.APIKeys[:index], upstream.APIKeys[index+1:]...) upstream.APIKeys = append(upstream.APIKeys, apiKey) return cm.saveConfigLocked(cm.config) } // ReorderUpstreams 重新排序 Messages 渠道优先级 // order 是渠道索引数组,按新的优先级顺序排列(只更新传入的渠道,支持部分排序) func (cm *ConfigManager) ReorderUpstreams(order []int) error { cm.mu.Lock() defer cm.mu.Unlock() if len(order) == 0 { return fmt.Errorf("排序数组不能为空") } // 验证所有索引都有效且不重复 seen := make(map[int]bool) for _, idx := range order { if idx < 0 || idx >= len(cm.config.Upstream) { return fmt.Errorf("无效的渠道索引: %d", idx) } if seen[idx] { return fmt.Errorf("重复的渠道索引: %d", idx) } seen[idx] = true } // 更新传入渠道的优先级(未传入的渠道保持原优先级不变) // 注意:priority 从 1 开始,避免 omitempty 吞掉 0 值 for i, idx := range order { cm.config.Upstream[idx].Priority = i + 1 } if err := cm.saveConfigLocked(cm.config); err != nil { return err } log.Printf("[Config-Reorder] 已更新 Messages 渠道优先级顺序 (%d 个渠道)", len(order)) return nil } // SetChannelStatus 设置 Messages 渠道状态 func (cm *ConfigManager) SetChannelStatus(index int, status string) error { cm.mu.Lock() defer cm.mu.Unlock() if index < 0 || index >= len(cm.config.Upstream) { return fmt.Errorf("无效的上游索引: %d", index) } // 状态值转为小写,支持大小写不敏感 status = strings.ToLower(status) if status != "active" && status != "suspended" && status != "disabled" { return fmt.Errorf("无效的状态: %s (允许值: active, suspended, disabled)", status) } cm.config.Upstream[index].Status = status // 暂停时清除促销期 if status == "suspended" && cm.config.Upstream[index].PromotionUntil != nil { cm.config.Upstream[index].PromotionUntil = nil log.Printf("[Config-Status] 已清除渠道 [%d] %s 的促销期", index, cm.config.Upstream[index].Name) } if err := cm.saveConfigLocked(cm.config); err != nil { return err } log.Printf("[Config-Status] 已设置渠道 [%d] %s 状态为: %s", index, cm.config.Upstream[index].Name, status) return nil } // SetChannelPromotion 设置渠道促销期 // duration 为促销持续时间,传入 0 表示清除促销期 func (cm *ConfigManager) SetChannelPromotion(index int, duration time.Duration) error { cm.mu.Lock() defer cm.mu.Unlock() if index < 0 || index >= len(cm.config.Upstream) { return fmt.Errorf("无效的上游索引: %d", index) } if duration <= 0 { cm.config.Upstream[index].PromotionUntil = nil log.Printf("[Config-Promotion] 已清除渠道 [%d] %s 的促销期", index, cm.config.Upstream[index].Name) } else { // 清除其他渠道的促销期(同一时间只允许一个促销渠道) for i := range cm.config.Upstream { if i != index && cm.config.Upstream[i].PromotionUntil != nil { cm.config.Upstream[i].PromotionUntil = nil } } promotionEnd := time.Now().Add(duration) cm.config.Upstream[index].PromotionUntil = &promotionEnd log.Printf("[Config-Promotion] 已设置渠道 [%d] %s 进入促销期,截止: %s", index, cm.config.Upstream[index].Name, promotionEnd.Format(time.RFC3339)) } return cm.saveConfigLocked(cm.config) } // GetPromotedChannel 获取当前处于促销期的渠道索引(返回优先级最高的) func (cm *ConfigManager) GetPromotedChannel() (int, bool) { cm.mu.RLock() defer cm.mu.RUnlock() for i, upstream := range cm.config.Upstream { if IsChannelInPromotion(&upstream) && GetChannelStatus(&upstream) == "active" { return i, true } } return -1, false } // DeprioritizeAPIKey 降低API密钥优先级(在所有渠道中查找) func (cm *ConfigManager) DeprioritizeAPIKey(apiKey string) error { cm.mu.Lock() defer cm.mu.Unlock() // 遍历所有渠道查找该 API 密钥 for upstreamIdx := range cm.config.Upstream { upstream := &cm.config.Upstream[upstreamIdx] index := -1 for i, key := range upstream.APIKeys { if key == apiKey { index = i break } } if index != -1 && index != len(upstream.APIKeys)-1 { // 移动到末尾 upstream.APIKeys = append(upstream.APIKeys[:index], upstream.APIKeys[index+1:]...) upstream.APIKeys = append(upstream.APIKeys, apiKey) log.Printf("[Messages-Key] 已将API密钥移动到末尾以降低优先级: %s (渠道: %s)", utils.MaskAPIKey(apiKey), upstream.Name) return cm.saveConfigLocked(cm.config) } } // 同样遍历 Responses 渠道 for upstreamIdx := range cm.config.ResponsesUpstream { upstream := &cm.config.ResponsesUpstream[upstreamIdx] index := -1 for i, key := range upstream.APIKeys { if key == apiKey { index = i break } } if index != -1 && index != len(upstream.APIKeys)-1 { // 移动到末尾 upstream.APIKeys = append(upstream.APIKeys[:index], upstream.APIKeys[index+1:]...) upstream.APIKeys = append(upstream.APIKeys, apiKey) log.Printf("[Responses-Key] 已将API密钥移动到末尾以降低优先级: %s (Responses渠道: %s)", utils.MaskAPIKey(apiKey), upstream.Name) return cm.saveConfigLocked(cm.config) } } return nil } ================================================ FILE: backend-go/internal/config/config_responses.go ================================================ package config import ( "fmt" "log" "strings" "time" "github.com/BenedictKing/claude-proxy/internal/utils" ) // ============== Responses 渠道方法 ============== // GetCurrentResponsesUpstream 获取当前 Responses 上游配置 // 优先选择第一个 active 状态的渠道,若无则回退到第一个渠道 func (cm *ConfigManager) GetCurrentResponsesUpstream() (*UpstreamConfig, error) { cm.mu.RLock() defer cm.mu.RUnlock() if len(cm.config.ResponsesUpstream) == 0 { return nil, fmt.Errorf("未配置任何 Responses 渠道") } // 优先选择第一个 active 状态的渠道 for i := range cm.config.ResponsesUpstream { status := cm.config.ResponsesUpstream[i].Status if status == "" || status == "active" { return &cm.config.ResponsesUpstream[i], nil } } // 没有 active 渠道,回退到第一个渠道 return &cm.config.ResponsesUpstream[0], nil } // AddResponsesUpstream 添加 Responses 上游 func (cm *ConfigManager) AddResponsesUpstream(upstream UpstreamConfig) error { cm.mu.Lock() defer cm.mu.Unlock() // 新建渠道默认设为 active if upstream.Status == "" { upstream.Status = "active" } // 去重 API Keys 和 Base URLs upstream.APIKeys = deduplicateStrings(upstream.APIKeys) upstream.BaseURLs = deduplicateBaseURLs(upstream.BaseURLs) cm.config.ResponsesUpstream = append(cm.config.ResponsesUpstream, upstream) if err := cm.saveConfigLocked(cm.config); err != nil { return err } log.Printf("[Config-Upstream] 已添加 Responses 上游: %s", upstream.Name) return nil } // UpdateResponsesUpstream 更新 Responses 上游 // 返回值:shouldResetMetrics 表示是否需要重置渠道指标(熔断状态) func (cm *ConfigManager) UpdateResponsesUpstream(index int, updates UpstreamUpdate) (shouldResetMetrics bool, err error) { cm.mu.Lock() defer cm.mu.Unlock() if index < 0 || index >= len(cm.config.ResponsesUpstream) { return false, fmt.Errorf("无效的 Responses 上游索引: %d", index) } upstream := &cm.config.ResponsesUpstream[index] if updates.Name != nil { upstream.Name = *updates.Name } if updates.BaseURL != nil { upstream.BaseURL = *updates.BaseURL // 当 BaseURL 被更新且 BaseURLs 未被显式设置时,清空 BaseURLs 保持一致性 // 避免出现 baseUrl 和 baseUrls[0] 不一致的情况 if updates.BaseURLs == nil { upstream.BaseURLs = nil } } if updates.BaseURLs != nil { upstream.BaseURLs = deduplicateBaseURLs(updates.BaseURLs) } if updates.ServiceType != nil { upstream.ServiceType = *updates.ServiceType } if updates.Description != nil { upstream.Description = *updates.Description } if updates.Website != nil { upstream.Website = *updates.Website } if updates.APIKeys != nil { // 记录被移除的 Key 到历史列表(用于统计聚合) newKeys := make(map[string]bool) for _, key := range updates.APIKeys { newKeys[key] = true } // 找出被移除的 Key(在旧列表中但不在新列表中) for _, key := range upstream.APIKeys { if !newKeys[key] { // 检查是否已在历史列表中 alreadyInHistory := false for _, hk := range upstream.HistoricalAPIKeys { if hk == key { alreadyInHistory = true break } } if !alreadyInHistory { upstream.HistoricalAPIKeys = append(upstream.HistoricalAPIKeys, key) log.Printf("[Config-Upstream] Responses 渠道 [%d] %s: Key %s 已移入历史列表", index, upstream.Name, utils.MaskAPIKey(key)) } } } // 如果新 Key 在历史列表中,从历史列表移除(换回来了) var newHistoricalKeys []string for _, hk := range upstream.HistoricalAPIKeys { if !newKeys[hk] { newHistoricalKeys = append(newHistoricalKeys, hk) } else { log.Printf("[Config-Upstream] Responses 渠道 [%d] %s: Key %s 已从历史列表恢复", index, upstream.Name, utils.MaskAPIKey(hk)) } } upstream.HistoricalAPIKeys = newHistoricalKeys // 只有单 key 场景且 key 被更换时,才自动激活并重置熔断 if len(upstream.APIKeys) == 1 && len(updates.APIKeys) == 1 && upstream.APIKeys[0] != updates.APIKeys[0] { shouldResetMetrics = true if upstream.Status == "suspended" { upstream.Status = "active" log.Printf("[Config-Upstream] Responses 渠道 [%d] %s 已从暂停状态自动激活(单 key 更换)", index, upstream.Name) } } upstream.APIKeys = deduplicateStrings(updates.APIKeys) } if updates.ModelMapping != nil { upstream.ModelMapping = updates.ModelMapping } if updates.InsecureSkipVerify != nil { upstream.InsecureSkipVerify = *updates.InsecureSkipVerify } if updates.Priority != nil { upstream.Priority = *updates.Priority } if updates.Status != nil { upstream.Status = *updates.Status } if updates.PromotionUntil != nil { upstream.PromotionUntil = updates.PromotionUntil } if updates.LowQuality != nil { upstream.LowQuality = *updates.LowQuality } if err := cm.saveConfigLocked(cm.config); err != nil { return false, err } log.Printf("[Config-Upstream] 已更新 Responses 上游: [%d] %s", index, cm.config.ResponsesUpstream[index].Name) return shouldResetMetrics, nil } // RemoveResponsesUpstream 删除 Responses 上游 func (cm *ConfigManager) RemoveResponsesUpstream(index int) (*UpstreamConfig, error) { cm.mu.Lock() defer cm.mu.Unlock() if index < 0 || index >= len(cm.config.ResponsesUpstream) { return nil, fmt.Errorf("无效的 Responses 上游索引: %d", index) } removed := cm.config.ResponsesUpstream[index] cm.config.ResponsesUpstream = append(cm.config.ResponsesUpstream[:index], cm.config.ResponsesUpstream[index+1:]...) // 清理被删除渠道的失败 key 冷却记录 cm.clearFailedKeysForUpstream(&removed, "Responses") if err := cm.saveConfigLocked(cm.config); err != nil { return nil, err } log.Printf("[Config-Upstream] 已删除 Responses 上游: %s", removed.Name) return &removed, nil } // AddResponsesAPIKey 添加 Responses 上游的 API 密钥 func (cm *ConfigManager) AddResponsesAPIKey(index int, apiKey string) error { cm.mu.Lock() defer cm.mu.Unlock() if index < 0 || index >= len(cm.config.ResponsesUpstream) { return fmt.Errorf("无效的上游索引: %d", index) } // 检查密钥是否已存在 for _, key := range cm.config.ResponsesUpstream[index].APIKeys { if key == apiKey { return fmt.Errorf("API密钥已存在") } } cm.config.ResponsesUpstream[index].APIKeys = append(cm.config.ResponsesUpstream[index].APIKeys, apiKey) // 如果该 Key 在历史列表中,从历史列表移除(换回来了) var newHistoricalKeys []string for _, hk := range cm.config.ResponsesUpstream[index].HistoricalAPIKeys { if hk != apiKey { newHistoricalKeys = append(newHistoricalKeys, hk) } else { log.Printf("[Responses-Key] 上游 [%d] %s: Key %s 已从历史列表恢复", index, cm.config.ResponsesUpstream[index].Name, utils.MaskAPIKey(hk)) } } cm.config.ResponsesUpstream[index].HistoricalAPIKeys = newHistoricalKeys if err := cm.saveConfigLocked(cm.config); err != nil { return err } log.Printf("[Responses-Key] 已添加API密钥到 Responses 上游 [%d] %s", index, cm.config.ResponsesUpstream[index].Name) return nil } // RemoveResponsesAPIKey 删除 Responses 上游的 API 密钥 func (cm *ConfigManager) RemoveResponsesAPIKey(index int, apiKey string) error { cm.mu.Lock() defer cm.mu.Unlock() if index < 0 || index >= len(cm.config.ResponsesUpstream) { return fmt.Errorf("无效的上游索引: %d", index) } // 查找并删除密钥 keys := cm.config.ResponsesUpstream[index].APIKeys found := false for i, key := range keys { if key == apiKey { cm.config.ResponsesUpstream[index].APIKeys = append(keys[:i], keys[i+1:]...) found = true break } } if !found { return fmt.Errorf("API密钥不存在") } // 将被移除的 Key 添加到历史列表(用于统计聚合) alreadyInHistory := false for _, hk := range cm.config.ResponsesUpstream[index].HistoricalAPIKeys { if hk == apiKey { alreadyInHistory = true break } } if !alreadyInHistory { cm.config.ResponsesUpstream[index].HistoricalAPIKeys = append(cm.config.ResponsesUpstream[index].HistoricalAPIKeys, apiKey) log.Printf("[Responses-Key] 上游 [%d] %s: Key %s 已移入历史列表", index, cm.config.ResponsesUpstream[index].Name, utils.MaskAPIKey(apiKey)) } if err := cm.saveConfigLocked(cm.config); err != nil { return err } log.Printf("[Responses-Key] 已从 Responses 上游 [%d] %s 删除API密钥", index, cm.config.ResponsesUpstream[index].Name) return nil } // GetNextResponsesAPIKey 获取下一个 API 密钥(Responses 负载均衡 - 纯 failover 模式) func (cm *ConfigManager) GetNextResponsesAPIKey(upstream *UpstreamConfig, failedKeys map[string]bool) (string, error) { return cm.GetNextAPIKey(upstream, failedKeys, "Responses") } // SetResponsesLoadBalance 设置 Responses 负载均衡策略 func (cm *ConfigManager) SetResponsesLoadBalance(strategy string) error { cm.mu.Lock() defer cm.mu.Unlock() if err := validateLoadBalanceStrategy(strategy); err != nil { return err } cm.config.ResponsesLoadBalance = strategy if err := cm.saveConfigLocked(cm.config); err != nil { return err } log.Printf("[Config-LoadBalance] 已设置 Responses 负载均衡策略: %s", strategy) return nil } // MoveResponsesAPIKeyToTop 将指定 Responses 渠道的 API 密钥移到最前面 func (cm *ConfigManager) MoveResponsesAPIKeyToTop(upstreamIndex int, apiKey string) error { cm.mu.Lock() defer cm.mu.Unlock() if upstreamIndex < 0 || upstreamIndex >= len(cm.config.ResponsesUpstream) { return fmt.Errorf("无效的上游索引: %d", upstreamIndex) } upstream := &cm.config.ResponsesUpstream[upstreamIndex] index := -1 for i, key := range upstream.APIKeys { if key == apiKey { index = i break } } if index <= 0 { return nil } upstream.APIKeys = append([]string{apiKey}, append(upstream.APIKeys[:index], upstream.APIKeys[index+1:]...)...) return cm.saveConfigLocked(cm.config) } // MoveResponsesAPIKeyToBottom 将指定 Responses 渠道的 API 密钥移到最后面 func (cm *ConfigManager) MoveResponsesAPIKeyToBottom(upstreamIndex int, apiKey string) error { cm.mu.Lock() defer cm.mu.Unlock() if upstreamIndex < 0 || upstreamIndex >= len(cm.config.ResponsesUpstream) { return fmt.Errorf("无效的上游索引: %d", upstreamIndex) } upstream := &cm.config.ResponsesUpstream[upstreamIndex] index := -1 for i, key := range upstream.APIKeys { if key == apiKey { index = i break } } if index == -1 || index == len(upstream.APIKeys)-1 { return nil } upstream.APIKeys = append(upstream.APIKeys[:index], upstream.APIKeys[index+1:]...) upstream.APIKeys = append(upstream.APIKeys, apiKey) return cm.saveConfigLocked(cm.config) } // ReorderResponsesUpstreams 重新排序 Responses 渠道优先级 // order 是渠道索引数组,按新的优先级顺序排列(只更新传入的渠道,支持部分排序) func (cm *ConfigManager) ReorderResponsesUpstreams(order []int) error { cm.mu.Lock() defer cm.mu.Unlock() if len(order) == 0 { return fmt.Errorf("排序数组不能为空") } seen := make(map[int]bool) for _, idx := range order { if idx < 0 || idx >= len(cm.config.ResponsesUpstream) { return fmt.Errorf("无效的渠道索引: %d", idx) } if seen[idx] { return fmt.Errorf("重复的渠道索引: %d", idx) } seen[idx] = true } // 更新传入渠道的优先级(未传入的渠道保持原优先级不变) // 注意:priority 从 1 开始,避免 omitempty 吞掉 0 值 for i, idx := range order { cm.config.ResponsesUpstream[idx].Priority = i + 1 } if err := cm.saveConfigLocked(cm.config); err != nil { return err } log.Printf("[Config-Reorder] 已更新 Responses 渠道优先级顺序 (%d 个渠道)", len(order)) return nil } // SetResponsesChannelStatus 设置 Responses 渠道状态 func (cm *ConfigManager) SetResponsesChannelStatus(index int, status string) error { cm.mu.Lock() defer cm.mu.Unlock() if index < 0 || index >= len(cm.config.ResponsesUpstream) { return fmt.Errorf("无效的上游索引: %d", index) } // 状态值转为小写,支持大小写不敏感 status = strings.ToLower(status) if status != "active" && status != "suspended" && status != "disabled" { return fmt.Errorf("无效的状态: %s (允许值: active, suspended, disabled)", status) } cm.config.ResponsesUpstream[index].Status = status // 暂停时清除促销期 if status == "suspended" && cm.config.ResponsesUpstream[index].PromotionUntil != nil { cm.config.ResponsesUpstream[index].PromotionUntil = nil log.Printf("[Config-Status] 已清除 Responses 渠道 [%d] %s 的促销期", index, cm.config.ResponsesUpstream[index].Name) } if err := cm.saveConfigLocked(cm.config); err != nil { return err } log.Printf("[Config-Status] 已设置 Responses 渠道 [%d] %s 状态为: %s", index, cm.config.ResponsesUpstream[index].Name, status) return nil } // SetResponsesChannelPromotion 设置 Responses 渠道促销期 func (cm *ConfigManager) SetResponsesChannelPromotion(index int, duration time.Duration) error { cm.mu.Lock() defer cm.mu.Unlock() if index < 0 || index >= len(cm.config.ResponsesUpstream) { return fmt.Errorf("无效的 Responses 上游索引: %d", index) } if duration <= 0 { cm.config.ResponsesUpstream[index].PromotionUntil = nil log.Printf("[Config-Promotion] 已清除 Responses 渠道 [%d] %s 的促销期", index, cm.config.ResponsesUpstream[index].Name) } else { // 清除其他渠道的促销期(同一时间只允许一个促销渠道) for i := range cm.config.ResponsesUpstream { if i != index && cm.config.ResponsesUpstream[i].PromotionUntil != nil { cm.config.ResponsesUpstream[i].PromotionUntil = nil } } promotionEnd := time.Now().Add(duration) cm.config.ResponsesUpstream[index].PromotionUntil = &promotionEnd log.Printf("[Config-Promotion] 已设置 Responses 渠道 [%d] %s 进入促销期,截止: %s", index, cm.config.ResponsesUpstream[index].Name, promotionEnd.Format(time.RFC3339)) } return cm.saveConfigLocked(cm.config) } // GetPromotedResponsesChannel 获取当前处于促销期的 Responses 渠道索引 func (cm *ConfigManager) GetPromotedResponsesChannel() (int, bool) { cm.mu.RLock() defer cm.mu.RUnlock() for i, upstream := range cm.config.ResponsesUpstream { if IsChannelInPromotion(&upstream) && GetChannelStatus(&upstream) == "active" { return i, true } } return -1, false } ================================================ FILE: backend-go/internal/config/config_utils.go ================================================ package config import ( "sort" "strings" "time" ) // ============== 工具函数 ============== // deduplicateStrings 去重字符串切片,保持原始顺序 func deduplicateStrings(items []string) []string { if len(items) <= 1 { return items } seen := make(map[string]struct{}, len(items)) result := make([]string, 0, len(items)) for _, item := range items { if _, exists := seen[item]; !exists { seen[item] = struct{}{} result = append(result, item) } } return result } // deduplicateBaseURLs 去重 BaseURLs,忽略末尾 / 和 # 差异 func deduplicateBaseURLs(urls []string) []string { if len(urls) <= 1 { return urls } seen := make(map[string]struct{}, len(urls)) result := make([]string, 0, len(urls)) for _, url := range urls { normalized := strings.TrimRight(url, "/#") if _, exists := seen[normalized]; !exists { seen[normalized] = struct{}{} result = append(result, url) } } return result } // validateLoadBalanceStrategy 验证负载均衡策略 func validateLoadBalanceStrategy(strategy string) error { // 只接受 failover 策略(round-robin 和 random 已移除) // 为兼容旧配置,仍允许旧值但静默忽略 if strategy != "failover" && strategy != "round-robin" && strategy != "random" { return &ConfigError{Message: "无效的负载均衡策略: " + strategy} } return nil } // ConfigError 配置错误 type ConfigError struct { Message string } func (e *ConfigError) Error() string { return e.Message } // ============== 模型重定向 ============== // RedirectModel 模型重定向 func RedirectModel(model string, upstream *UpstreamConfig) string { if upstream.ModelMapping == nil || len(upstream.ModelMapping) == 0 { return model } // 直接匹配(精确匹配优先) if mapped, ok := upstream.ModelMapping[model]; ok { return mapped } // 模糊匹配:按源模型长度从长到短排序,确保最长匹配优先 // 例如:同时配置 "codex" 和 "gpt-5.1-codex" 时,"gpt-5.1-codex" 应该先匹配 type mapping struct { source string target string } mappings := make([]mapping, 0, len(upstream.ModelMapping)) for source, target := range upstream.ModelMapping { mappings = append(mappings, mapping{source, target}) } // 按源模型长度降序排序 sort.Slice(mappings, func(i, j int) bool { return len(mappings[i].source) > len(mappings[j].source) }) // 按排序后的顺序进行模糊匹配 for _, m := range mappings { if strings.Contains(model, m.source) || strings.Contains(m.source, model) { return m.target } } return model } // ============== 渠道状态与优先级辅助函数 ============== // GetChannelStatus 获取渠道状态(带默认值处理) func GetChannelStatus(upstream *UpstreamConfig) string { if upstream.Status == "" { return "active" } return upstream.Status } // GetChannelPriority 获取渠道优先级(带默认值处理) func GetChannelPriority(upstream *UpstreamConfig, index int) int { if upstream.Priority == 0 { return index } return upstream.Priority } // IsChannelInPromotion 检查渠道是否处于促销期 func IsChannelInPromotion(upstream *UpstreamConfig) bool { if upstream.PromotionUntil == nil { return false } return time.Now().Before(*upstream.PromotionUntil) } // ============== UpstreamConfig 方法 ============== // Clone 深拷贝 UpstreamConfig(用于避免并发修改问题) // 在多 BaseURL failover 场景下,需要临时修改 BaseURL 字段, // 使用深拷贝可避免并发请求之间的竞态条件 func (u *UpstreamConfig) Clone() *UpstreamConfig { cloned := *u // 浅拷贝 // 深拷贝切片字段 if u.BaseURLs != nil { cloned.BaseURLs = make([]string, len(u.BaseURLs)) copy(cloned.BaseURLs, u.BaseURLs) } if u.APIKeys != nil { cloned.APIKeys = make([]string, len(u.APIKeys)) copy(cloned.APIKeys, u.APIKeys) } if u.HistoricalAPIKeys != nil { cloned.HistoricalAPIKeys = make([]string, len(u.HistoricalAPIKeys)) copy(cloned.HistoricalAPIKeys, u.HistoricalAPIKeys) } if u.ModelMapping != nil { cloned.ModelMapping = make(map[string]string, len(u.ModelMapping)) for k, v := range u.ModelMapping { cloned.ModelMapping[k] = v } } if u.PromotionUntil != nil { t := *u.PromotionUntil cloned.PromotionUntil = &t } return &cloned } // GetEffectiveBaseURL 获取当前应使用的 BaseURL(纯 failover 模式) // 优先使用 BaseURL 字段(支持调用方临时覆盖),否则从 BaseURLs 数组获取 func (u *UpstreamConfig) GetEffectiveBaseURL() string { // 优先使用 BaseURL(可能被调用方临时设置用于指定本次请求的 URL) if u.BaseURL != "" { return u.BaseURL } // 回退到 BaseURLs 数组 if len(u.BaseURLs) > 0 { return u.BaseURLs[0] } return "" } // GetAllBaseURLs 获取所有 BaseURL(用于延迟测试) func (u *UpstreamConfig) GetAllBaseURLs() []string { if len(u.BaseURLs) > 0 { return u.BaseURLs } if u.BaseURL != "" { return []string{u.BaseURL} } return nil } ================================================ FILE: backend-go/internal/config/env.go ================================================ package config import ( "os" "strconv" ) type EnvConfig struct { Port int Env string EnableWebUI bool ProxyAccessKey string LogLevel string EnableRequestLogs bool EnableResponseLogs bool QuietPollingLogs bool // 静默轮询端点日志 RawLogOutput bool // 原始日志输出(不缩进、不截断、不重排序) SSEDebugLevel string // SSE 调试级别: off, summary, full RewriteResponseModel bool // 是否改写响应中的 model 字段为请求的 model(默认 false) RequestTimeout int MaxRequestBodySize int64 // 请求体最大大小 (字节),由 MB 配置转换 EnableCORS bool CORSOrigin string // 指标配置 MetricsWindowSize int // 滑动窗口大小 MetricsFailureThreshold float64 // 失败率阈值 // 指标持久化配置 MetricsPersistenceEnabled bool // 是否启用 SQLite 持久化 MetricsRetentionDays int // 数据保留天数(3-30) // HTTP 客户端配置 ResponseHeaderTimeout int // 等待响应头超时时间(秒) // 日志文件相关配置 LogDir string LogFile string LogMaxSize int // 单个日志文件最大大小 (MB) LogMaxBackups int // 保留的旧日志文件最大数量 LogMaxAge int // 保留的旧日志文件最大天数 LogCompress bool // 是否压缩旧日志文件 LogToConsole bool // 是否同时输出到控制台 } // NewEnvConfig 创建环境配置 func NewEnvConfig() *EnvConfig { // 支持 ENV 和 NODE_ENV(向后兼容) env := getEnv("ENV", "") if env == "" { env = getEnv("NODE_ENV", "development") } return &EnvConfig{ Port: getEnvAsInt("PORT", 3000), Env: env, EnableWebUI: getEnv("ENABLE_WEB_UI", "true") != "false", ProxyAccessKey: getEnv("PROXY_ACCESS_KEY", "your-proxy-access-key"), LogLevel: getEnv("LOG_LEVEL", "info"), EnableRequestLogs: getEnv("ENABLE_REQUEST_LOGS", "true") != "false", EnableResponseLogs: getEnv("ENABLE_RESPONSE_LOGS", "true") != "false", QuietPollingLogs: getEnv("QUIET_POLLING_LOGS", "true") != "false", RawLogOutput: getEnv("RAW_LOG_OUTPUT", "false") == "true", SSEDebugLevel: getEnv("SSE_DEBUG_LEVEL", "off"), RewriteResponseModel: getEnv("REWRITE_RESPONSE_MODEL", "false") == "true", RequestTimeout: getEnvAsInt("REQUEST_TIMEOUT", 300000), MaxRequestBodySize: getEnvAsInt64("MAX_REQUEST_BODY_SIZE_MB", 50) * 1024 * 1024, // MB 转换为字节 EnableCORS: getEnv("ENABLE_CORS", "true") != "false", CORSOrigin: getEnv("CORS_ORIGIN", "*"), // 指标配置 MetricsWindowSize: getEnvAsInt("METRICS_WINDOW_SIZE", 10), MetricsFailureThreshold: getEnvAsFloat("METRICS_FAILURE_THRESHOLD", 0.5), // 指标持久化配置 MetricsPersistenceEnabled: getEnv("METRICS_PERSISTENCE_ENABLED", "true") != "false", MetricsRetentionDays: clampInt(getEnvAsInt("METRICS_RETENTION_DAYS", 7), 3, 30), // HTTP 客户端配置 ResponseHeaderTimeout: clampInt(getEnvAsInt("RESPONSE_HEADER_TIMEOUT", 60), 30, 120), // 30-120 秒 // 日志文件配置 LogDir: getEnv("LOG_DIR", "logs"), LogFile: getEnv("LOG_FILE", "app.log"), LogMaxSize: getEnvAsInt("LOG_MAX_SIZE", 100), // 默认 100MB LogMaxBackups: getEnvAsInt("LOG_MAX_BACKUPS", 10), // 默认保留 10 个 LogMaxAge: getEnvAsInt("LOG_MAX_AGE", 30), // 默认保留 30 天 LogCompress: getEnv("LOG_COMPRESS", "true") != "false", LogToConsole: getEnv("LOG_TO_CONSOLE", "true") != "false", } } // IsDevelopment 是否为开发环境 func (c *EnvConfig) IsDevelopment() bool { return c.Env == "development" } // IsProduction 是否为生产环境 func (c *EnvConfig) IsProduction() bool { return c.Env == "production" } // ShouldLog 是否应该记录日志 func (c *EnvConfig) ShouldLog(level string) bool { levels := map[string]int{ "error": 0, "warn": 1, "info": 2, "debug": 3, } currentLevel, ok := levels[c.LogLevel] if !ok { currentLevel = 2 // 默认 info } requestLevel, ok := levels[level] if !ok { return false } return requestLevel <= currentLevel } // getEnv 获取环境变量,如果不存在则返回默认值 func getEnv(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value } return defaultValue } // getEnvAsInt 获取环境变量并转换为整数 func getEnvAsInt(key string, defaultValue int) int { if value := os.Getenv(key); value != "" { if intValue, err := strconv.Atoi(value); err == nil { return intValue } } return defaultValue } // getEnvAsInt64 获取环境变量并转换为 int64 func getEnvAsInt64(key string, defaultValue int64) int64 { if value := os.Getenv(key); value != "" { if intValue, err := strconv.ParseInt(value, 10, 64); err == nil { return intValue } } return defaultValue } // getEnvAsFloat 获取环境变量并转换为浮点数 func getEnvAsFloat(key string, defaultValue float64) float64 { if value := os.Getenv(key); value != "" { if floatValue, err := strconv.ParseFloat(value, 64); err == nil { return floatValue } } return defaultValue } // clampInt 将整数限制在指定范围内 func clampInt(value, minVal, maxVal int) int { if value < minVal { return minVal } if value > maxVal { return maxVal } return value } ================================================ FILE: backend-go/internal/converters/chat_to_responses.go ================================================ package converters import ( "bytes" "context" "fmt" "strings" "time" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) // chatToResponsesState 流式转换状态 type chatToResponsesState struct { Seq int ResponseID string CreatedAt int64 CurrentMsgID string CurrentFCID string InTextBlock bool InFuncBlock bool FuncArgsBuf map[int]*strings.Builder // index -> args FuncNames map[int]string // index -> function name FuncCallIDs map[int]string // index -> call id TextBuf strings.Builder // reasoning state ReasoningActive bool ReasoningItemID string ReasoningBuf strings.Builder ReasoningPartAdded bool ReasoningIndex int // usage(完整支持详细字段,参考 claude-code-hub) InputTokens int64 OutputTokens int64 CachedTokens int64 // input_tokens_details.cached_tokens / cache_read_input_tokens ReasoningTokens int64 // output_tokens_details.reasoning_tokens UsageSeen bool // Claude 缓存 TTL 细分 CacheCreationTokens int64 // cache_creation_input_tokens CacheCreation5mTokens int64 // cache_creation_5m_input_tokens CacheCreation1hTokens int64 // cache_creation_1h_input_tokens CacheTTL string // "5m" | "1h" | "mixed" // 首次消息标记 FirstChunk bool } var chatDataTag = []byte("data:") func emitResponsesEvent(event string, payload string) string { return fmt.Sprintf("event: %s\ndata: %s\n\n", event, payload) } // ConvertOpenAIChatToResponses 将 OpenAI Chat Completions SSE 转换为 Responses SSE 事件 // ctx: 上下文 // modelName: 模型名称 // originalRequestRawJSON: 原始的 Responses API 请求 JSON(用于回显字段) // requestRawJSON: 转换后的 Chat Completions 请求 JSON // rawJSON: OpenAI Chat Completions SSE 行 // param: 状态指针(在多次调用间保持状态) func ConvertOpenAIChatToResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string { if *param == nil { *param = &chatToResponsesState{ FuncArgsBuf: make(map[int]*strings.Builder), FuncNames: make(map[int]string), FuncCallIDs: make(map[int]string), FirstChunk: true, } } st := (*param).(*chatToResponsesState) // 期望 `data: {..}` 格式 if !bytes.HasPrefix(rawJSON, chatDataTag) { return []string{} } rawJSON = bytes.TrimSpace(rawJSON[5:]) // 检查 [DONE] 标记 if string(rawJSON) == "[DONE]" { // 生成完成事件 return st.generateCompletedEvents(originalRequestRawJSON) } root := gjson.ParseBytes(rawJSON) var out []string nextSeq := func() int { st.Seq++; return st.Seq } // 处理首次 chunk - 初始化并生成 response.created 和 response.in_progress if st.FirstChunk { st.FirstChunk = false // 从 chunk 中提取 id if id := root.Get("id"); id.Exists() { st.ResponseID = id.String() } else { st.ResponseID = fmt.Sprintf("resp_%d", time.Now().UnixNano()) } st.CreatedAt = time.Now().Unix() // 重置状态 st.TextBuf.Reset() st.ReasoningBuf.Reset() st.ReasoningActive = false st.InTextBlock = false st.InFuncBlock = false st.CurrentMsgID = "" st.CurrentFCID = "" st.ReasoningItemID = "" st.ReasoningIndex = 0 st.ReasoningPartAdded = false st.FuncArgsBuf = make(map[int]*strings.Builder) st.FuncNames = make(map[int]string) st.FuncCallIDs = make(map[int]string) st.InputTokens = 0 st.OutputTokens = 0 st.CachedTokens = 0 st.ReasoningTokens = 0 st.CacheCreationTokens = 0 st.CacheCreation5mTokens = 0 st.CacheCreation1hTokens = 0 st.CacheTTL = "" st.UsageSeen = false // 发送 response.created created := `{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null,"instructions":""}}` created, _ = sjson.Set(created, "sequence_number", nextSeq()) created, _ = sjson.Set(created, "response.id", st.ResponseID) created, _ = sjson.Set(created, "response.created_at", st.CreatedAt) out = append(out, emitResponsesEvent("response.created", created)) // 发送 response.in_progress inprog := `{"type":"response.in_progress","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress"}}` inprog, _ = sjson.Set(inprog, "sequence_number", nextSeq()) inprog, _ = sjson.Set(inprog, "response.id", st.ResponseID) inprog, _ = sjson.Set(inprog, "response.created_at", st.CreatedAt) out = append(out, emitResponsesEvent("response.in_progress", inprog)) } // 解析 choices choices := root.Get("choices") if !choices.Exists() || !choices.IsArray() { return out } for _, choice := range choices.Array() { delta := choice.Get("delta") if !delta.Exists() { continue } finishReason := choice.Get("finish_reason").String() // 处理 reasoning_content(OpenAI o1 模型的 reasoning) if reasoning := delta.Get("reasoning_content"); reasoning.Exists() && reasoning.String() != "" { reasoningText := reasoning.String() // 开始 reasoning block if !st.ReasoningActive { st.ReasoningActive = true st.ReasoningIndex = 0 st.ReasoningBuf.Reset() st.ReasoningItemID = fmt.Sprintf("rs_%s_0", st.ResponseID) // response.output_item.added for reasoning item := `{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","summary":[]}}` item, _ = sjson.Set(item, "sequence_number", nextSeq()) item, _ = sjson.Set(item, "output_index", st.ReasoningIndex) item, _ = sjson.Set(item, "item.id", st.ReasoningItemID) out = append(out, emitResponsesEvent("response.output_item.added", item)) // response.reasoning_summary_part.added part := `{"type":"response.reasoning_summary_part.added","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}` part, _ = sjson.Set(part, "sequence_number", nextSeq()) part, _ = sjson.Set(part, "item_id", st.ReasoningItemID) part, _ = sjson.Set(part, "output_index", st.ReasoningIndex) out = append(out, emitResponsesEvent("response.reasoning_summary_part.added", part)) st.ReasoningPartAdded = true } // 发送 reasoning delta st.ReasoningBuf.WriteString(reasoningText) msg := `{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"text":""}` msg, _ = sjson.Set(msg, "sequence_number", nextSeq()) msg, _ = sjson.Set(msg, "item_id", st.ReasoningItemID) msg, _ = sjson.Set(msg, "output_index", st.ReasoningIndex) msg, _ = sjson.Set(msg, "text", reasoningText) out = append(out, emitResponsesEvent("response.reasoning_summary_text.delta", msg)) } // 处理 content(文本内容) if content := delta.Get("content"); content.Exists() && content.String() != "" { contentText := content.String() // 如果 reasoning 还在活跃状态,先关闭它 if st.ReasoningActive { out = append(out, st.closeReasoningBlock(nextSeq)...) } // 开始 text block if !st.InTextBlock { st.InTextBlock = true // 计算 output_index:如果有 reasoning 则为 1,否则为 0 outputIndex := 0 if st.ReasoningPartAdded { outputIndex = 1 } st.CurrentMsgID = fmt.Sprintf("msg_%s_%d", st.ResponseID, outputIndex) // response.output_item.added for message item := `{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}` item, _ = sjson.Set(item, "sequence_number", nextSeq()) item, _ = sjson.Set(item, "output_index", outputIndex) item, _ = sjson.Set(item, "item.id", st.CurrentMsgID) out = append(out, emitResponsesEvent("response.output_item.added", item)) // response.content_part.added part := `{"type":"response.content_part.added","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}` part, _ = sjson.Set(part, "sequence_number", nextSeq()) part, _ = sjson.Set(part, "item_id", st.CurrentMsgID) part, _ = sjson.Set(part, "output_index", outputIndex) out = append(out, emitResponsesEvent("response.content_part.added", part)) } // 发送 text delta st.TextBuf.WriteString(contentText) outputIndex := 0 if st.ReasoningPartAdded { outputIndex = 1 } msg := `{"type":"response.output_text.delta","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"delta":"","logprobs":[]}` msg, _ = sjson.Set(msg, "sequence_number", nextSeq()) msg, _ = sjson.Set(msg, "item_id", st.CurrentMsgID) msg, _ = sjson.Set(msg, "output_index", outputIndex) msg, _ = sjson.Set(msg, "delta", contentText) out = append(out, emitResponsesEvent("response.output_text.delta", msg)) } // 处理 tool_calls if toolCalls := delta.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() { for _, tc := range toolCalls.Array() { idx := int(tc.Get("index").Int()) // 如果 reasoning 还在活跃状态,先关闭它 if st.ReasoningActive { out = append(out, st.closeReasoningBlock(nextSeq)...) } // 如果 text block 还在活跃状态,先关闭它 if st.InTextBlock { out = append(out, st.closeTextBlock(nextSeq)...) } // 初始化 tool call 状态 if st.FuncArgsBuf[idx] == nil { st.FuncArgsBuf[idx] = &strings.Builder{} } // 处理 tool call ID if tcID := tc.Get("id"); tcID.Exists() && tcID.String() != "" { st.FuncCallIDs[idx] = tcID.String() st.CurrentFCID = tcID.String() // 开始新的 function_call item st.InFuncBlock = true // 计算 output_index outputIndex := idx if st.ReasoningPartAdded { outputIndex += 1 } if st.CurrentMsgID != "" { outputIndex += 1 } item := `{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}` item, _ = sjson.Set(item, "sequence_number", nextSeq()) item, _ = sjson.Set(item, "output_index", outputIndex) item, _ = sjson.Set(item, "item.id", fmt.Sprintf("fc_%s", st.CurrentFCID)) item, _ = sjson.Set(item, "item.call_id", st.CurrentFCID) out = append(out, emitResponsesEvent("response.output_item.added", item)) } // 处理 function if function := tc.Get("function"); function.Exists() { // 处理函数名 if name := function.Get("name"); name.Exists() && name.String() != "" { st.FuncNames[idx] = name.String() } // 处理参数 if args := function.Get("arguments"); args.Exists() && args.String() != "" { st.FuncArgsBuf[idx].WriteString(args.String()) // 计算 output_index outputIndex := idx if st.ReasoningPartAdded { outputIndex += 1 } if st.CurrentMsgID != "" { outputIndex += 1 } msg := `{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}` msg, _ = sjson.Set(msg, "sequence_number", nextSeq()) msg, _ = sjson.Set(msg, "item_id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx])) msg, _ = sjson.Set(msg, "output_index", outputIndex) msg, _ = sjson.Set(msg, "delta", args.String()) out = append(out, emitResponsesEvent("response.function_call_arguments.delta", msg)) } } } } // 处理 finish_reason if finishReason != "" && finishReason != "null" { // 关闭所有打开的 blocks if st.ReasoningActive { out = append(out, st.closeReasoningBlock(nextSeq)...) } if st.InTextBlock { out = append(out, st.closeTextBlock(nextSeq)...) } if st.InFuncBlock { out = append(out, st.closeFuncBlocks(nextSeq)...) } } } // 处理 usage(完整支持多格式详细字段,参考 claude-code-hub) if usage := root.Get("usage"); usage.Exists() { st.UsageSeen = true // OpenAI 格式基础字段 if v := usage.Get("prompt_tokens"); v.Exists() { st.InputTokens = v.Int() } if v := usage.Get("completion_tokens"); v.Exists() { st.OutputTokens = v.Int() } // OpenAI 格式详细字段 if v := usage.Get("prompt_tokens_details.cached_tokens"); v.Exists() { st.CachedTokens = v.Int() } if v := usage.Get("completion_tokens_details.reasoning_tokens"); v.Exists() { st.ReasoningTokens = v.Int() } // Claude 格式基础字段(优先级高于 OpenAI) if v := usage.Get("input_tokens"); v.Exists() { st.InputTokens = v.Int() } if v := usage.Get("output_tokens"); v.Exists() { st.OutputTokens = v.Int() } // Claude 格式缓存字段 if v := usage.Get("cache_read_input_tokens"); v.Exists() { st.CachedTokens = v.Int() } if v := usage.Get("cache_creation_input_tokens"); v.Exists() { st.CacheCreationTokens = v.Int() } if v := usage.Get("cache_creation_5m_input_tokens"); v.Exists() { st.CacheCreation5mTokens = v.Int() } if v := usage.Get("cache_creation_1h_input_tokens"); v.Exists() { st.CacheCreation1hTokens = v.Int() } // 设置缓存 TTL 标识 has5m := st.CacheCreation5mTokens > 0 has1h := st.CacheCreation1hTokens > 0 if has5m && has1h { st.CacheTTL = "mixed" } else if has1h { st.CacheTTL = "1h" } else if has5m { st.CacheTTL = "5m" } // Gemini 格式(自动去重) if v := usage.Get("promptTokenCount"); v.Exists() { promptTokens := v.Int() cachedTokens := usage.Get("cachedContentTokenCount").Int() // Gemini 的 promptTokenCount 已包含 cachedContentTokenCount,需要扣除 actualInput := promptTokens - cachedTokens if actualInput < 0 { actualInput = 0 } st.InputTokens = actualInput st.CachedTokens = cachedTokens } if v := usage.Get("candidatesTokenCount"); v.Exists() { st.OutputTokens = v.Int() } } return out } // closeReasoningBlock 关闭 reasoning block func (st *chatToResponsesState) closeReasoningBlock(nextSeq func() int) []string { if !st.ReasoningActive { return nil } var out []string full := st.ReasoningBuf.String() // response.reasoning_summary_text.done textDone := `{"type":"response.reasoning_summary_text.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"text":""}` textDone, _ = sjson.Set(textDone, "sequence_number", nextSeq()) textDone, _ = sjson.Set(textDone, "item_id", st.ReasoningItemID) textDone, _ = sjson.Set(textDone, "output_index", st.ReasoningIndex) textDone, _ = sjson.Set(textDone, "text", full) out = append(out, emitResponsesEvent("response.reasoning_summary_text.done", textDone)) // response.reasoning_summary_part.done partDone := `{"type":"response.reasoning_summary_part.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}` partDone, _ = sjson.Set(partDone, "sequence_number", nextSeq()) partDone, _ = sjson.Set(partDone, "item_id", st.ReasoningItemID) partDone, _ = sjson.Set(partDone, "output_index", st.ReasoningIndex) partDone, _ = sjson.Set(partDone, "part.text", full) out = append(out, emitResponsesEvent("response.reasoning_summary_part.done", partDone)) // response.output_item.done for reasoning itemDone := `{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"completed","summary":[]}}` itemDone, _ = sjson.Set(itemDone, "sequence_number", nextSeq()) itemDone, _ = sjson.Set(itemDone, "output_index", st.ReasoningIndex) itemDone, _ = sjson.Set(itemDone, "item.id", st.ReasoningItemID) itemDone, _ = sjson.Set(itemDone, "item.summary", []interface{}{map[string]interface{}{"type": "summary_text", "text": full}}) out = append(out, emitResponsesEvent("response.output_item.done", itemDone)) st.ReasoningActive = false return out } // closeTextBlock 关闭 text block func (st *chatToResponsesState) closeTextBlock(nextSeq func() int) []string { if !st.InTextBlock { return nil } var out []string outputIndex := 0 if st.ReasoningPartAdded { outputIndex = 1 } // response.output_text.done done := `{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}` done, _ = sjson.Set(done, "sequence_number", nextSeq()) done, _ = sjson.Set(done, "item_id", st.CurrentMsgID) done, _ = sjson.Set(done, "output_index", outputIndex) done, _ = sjson.Set(done, "text", st.TextBuf.String()) out = append(out, emitResponsesEvent("response.output_text.done", done)) // response.content_part.done partDone := `{"type":"response.content_part.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}` partDone, _ = sjson.Set(partDone, "sequence_number", nextSeq()) partDone, _ = sjson.Set(partDone, "item_id", st.CurrentMsgID) partDone, _ = sjson.Set(partDone, "output_index", outputIndex) partDone, _ = sjson.Set(partDone, "part.text", st.TextBuf.String()) out = append(out, emitResponsesEvent("response.content_part.done", partDone)) // response.output_item.done for message final := `{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}}` final, _ = sjson.Set(final, "sequence_number", nextSeq()) final, _ = sjson.Set(final, "output_index", outputIndex) final, _ = sjson.Set(final, "item.id", st.CurrentMsgID) final, _ = sjson.Set(final, "item.content.0.text", st.TextBuf.String()) out = append(out, emitResponsesEvent("response.output_item.done", final)) st.InTextBlock = false return out } // closeFuncBlocks 关闭所有 function call blocks func (st *chatToResponsesState) closeFuncBlocks(nextSeq func() int) []string { if !st.InFuncBlock || len(st.FuncArgsBuf) == 0 { return nil } var out []string // 收集并排序索引 idxs := make([]int, 0, len(st.FuncArgsBuf)) for idx := range st.FuncArgsBuf { idxs = append(idxs, idx) } // 简单排序 for i := 0; i < len(idxs); i++ { for j := i + 1; j < len(idxs); j++ { if idxs[j] < idxs[i] { idxs[i], idxs[j] = idxs[j], idxs[i] } } } for _, idx := range idxs { args := "{}" if buf := st.FuncArgsBuf[idx]; buf != nil && buf.Len() > 0 { args = buf.String() } callID := st.FuncCallIDs[idx] name := st.FuncNames[idx] // 计算 output_index outputIndex := idx if st.ReasoningPartAdded { outputIndex += 1 } if st.CurrentMsgID != "" { outputIndex += 1 } // response.function_call_arguments.done fcDone := `{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}` fcDone, _ = sjson.Set(fcDone, "sequence_number", nextSeq()) fcDone, _ = sjson.Set(fcDone, "item_id", fmt.Sprintf("fc_%s", callID)) fcDone, _ = sjson.Set(fcDone, "output_index", outputIndex) fcDone, _ = sjson.Set(fcDone, "arguments", args) out = append(out, emitResponsesEvent("response.function_call_arguments.done", fcDone)) // response.output_item.done for function_call itemDone := `{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}` itemDone, _ = sjson.Set(itemDone, "sequence_number", nextSeq()) itemDone, _ = sjson.Set(itemDone, "output_index", outputIndex) itemDone, _ = sjson.Set(itemDone, "item.id", fmt.Sprintf("fc_%s", callID)) itemDone, _ = sjson.Set(itemDone, "item.arguments", args) itemDone, _ = sjson.Set(itemDone, "item.call_id", callID) itemDone, _ = sjson.Set(itemDone, "item.name", name) out = append(out, emitResponsesEvent("response.output_item.done", itemDone)) } st.InFuncBlock = false return out } // generateCompletedEvents 生成完成事件 func (st *chatToResponsesState) generateCompletedEvents(originalRequestRawJSON []byte) []string { var out []string nextSeq := func() int { st.Seq++; return st.Seq } // 先关闭所有打开的 blocks if st.ReasoningActive { out = append(out, st.closeReasoningBlock(nextSeq)...) } if st.InTextBlock { out = append(out, st.closeTextBlock(nextSeq)...) } if st.InFuncBlock { out = append(out, st.closeFuncBlocks(nextSeq)...) } // 构建 response.completed completed := `{"type":"response.completed","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null}}` completed, _ = sjson.Set(completed, "sequence_number", nextSeq()) completed, _ = sjson.Set(completed, "response.id", st.ResponseID) completed, _ = sjson.Set(completed, "response.created_at", st.CreatedAt) // 注入原始请求字段 if originalRequestRawJSON != nil { req := gjson.ParseBytes(originalRequestRawJSON) if v := req.Get("instructions"); v.Exists() { completed, _ = sjson.Set(completed, "response.instructions", v.String()) } if v := req.Get("max_output_tokens"); v.Exists() { completed, _ = sjson.Set(completed, "response.max_output_tokens", v.Int()) } if v := req.Get("model"); v.Exists() { completed, _ = sjson.Set(completed, "response.model", v.String()) } if v := req.Get("parallel_tool_calls"); v.Exists() { completed, _ = sjson.Set(completed, "response.parallel_tool_calls", v.Bool()) } if v := req.Get("previous_response_id"); v.Exists() { completed, _ = sjson.Set(completed, "response.previous_response_id", v.String()) } if v := req.Get("reasoning"); v.Exists() { completed, _ = sjson.Set(completed, "response.reasoning", v.Value()) } if v := req.Get("temperature"); v.Exists() { completed, _ = sjson.Set(completed, "response.temperature", v.Float()) } if v := req.Get("tool_choice"); v.Exists() { completed, _ = sjson.Set(completed, "response.tool_choice", v.Value()) } if v := req.Get("tools"); v.Exists() { completed, _ = sjson.Set(completed, "response.tools", v.Value()) } if v := req.Get("top_p"); v.Exists() { completed, _ = sjson.Set(completed, "response.top_p", v.Float()) } if v := req.Get("metadata"); v.Exists() { completed, _ = sjson.Set(completed, "response.metadata", v.Value()) } } // 构建 output 数组 var outputs []interface{} // reasoning item(如果有) if st.ReasoningBuf.Len() > 0 || st.ReasoningPartAdded { r := map[string]interface{}{ "id": st.ReasoningItemID, "type": "reasoning", "status": "completed", "summary": []interface{}{map[string]interface{}{ "type": "summary_text", "text": st.ReasoningBuf.String(), }}, } outputs = append(outputs, r) } // message item(如果有文本) if st.TextBuf.Len() > 0 || st.CurrentMsgID != "" { m := map[string]interface{}{ "id": st.CurrentMsgID, "type": "message", "status": "completed", "content": []interface{}{map[string]interface{}{ "type": "output_text", "annotations": []interface{}{}, "logprobs": []interface{}{}, "text": st.TextBuf.String(), }}, "role": "assistant", } outputs = append(outputs, m) } // function_call items if len(st.FuncArgsBuf) > 0 { idxs := make([]int, 0, len(st.FuncArgsBuf)) for idx := range st.FuncArgsBuf { idxs = append(idxs, idx) } for i := 0; i < len(idxs); i++ { for j := i + 1; j < len(idxs); j++ { if idxs[j] < idxs[i] { idxs[i], idxs[j] = idxs[j], idxs[i] } } } for _, idx := range idxs { args := "" if b := st.FuncArgsBuf[idx]; b != nil { args = b.String() } if args == "" { args = "{}" } callID := st.FuncCallIDs[idx] name := st.FuncNames[idx] item := map[string]interface{}{ "id": fmt.Sprintf("fc_%s", callID), "type": "function_call", "status": "completed", "arguments": args, "call_id": callID, "name": name, } outputs = append(outputs, item) } } if len(outputs) > 0 { completed, _ = sjson.Set(completed, "response.output", outputs) } // 添加 usage(完整支持多格式详细字段,参考 claude-code-hub) reasoningTokens := st.ReasoningTokens if reasoningTokens == 0 && st.ReasoningBuf.Len() > 0 { reasoningTokens = int64(st.ReasoningBuf.Len() / 4) } // 始终添加基础 usage 字段,即使值为 0 // 这样 handler 可以检测到 usage 存在,并在需要时用本地估算值替换 0 值 // 参见 handler.go 中的 patchResponsesCompletedEventUsage 和 injectResponsesUsageToCompletedEvent completed, _ = sjson.Set(completed, "response.usage.input_tokens", st.InputTokens) completed, _ = sjson.Set(completed, "response.usage.output_tokens", st.OutputTokens) total := st.InputTokens + st.OutputTokens completed, _ = sjson.Set(completed, "response.usage.total_tokens", total) // 可选的详情字段,仅在有值时添加 // input_tokens_details if st.CachedTokens > 0 { completed, _ = sjson.Set(completed, "response.usage.input_tokens_details.cached_tokens", st.CachedTokens) } // output_tokens_details if reasoningTokens > 0 { completed, _ = sjson.Set(completed, "response.usage.output_tokens_details.reasoning_tokens", reasoningTokens) } // Claude 缓存 TTL 细分字段 if st.CacheCreationTokens > 0 { completed, _ = sjson.Set(completed, "response.usage.cache_creation_input_tokens", st.CacheCreationTokens) } if st.CacheCreation5mTokens > 0 { completed, _ = sjson.Set(completed, "response.usage.cache_creation_5m_input_tokens", st.CacheCreation5mTokens) } if st.CacheCreation1hTokens > 0 { completed, _ = sjson.Set(completed, "response.usage.cache_creation_1h_input_tokens", st.CacheCreation1hTokens) } if st.CachedTokens > 0 { completed, _ = sjson.Set(completed, "response.usage.cache_read_input_tokens", st.CachedTokens) } if st.CacheTTL != "" { completed, _ = sjson.Set(completed, "response.usage.cache_ttl", st.CacheTTL) } out = append(out, emitResponsesEvent("response.completed", completed)) return out } // ConvertOpenAIChatToResponsesNonStream 将 OpenAI Chat Completions 响应转换为 Responses 格式(非流式) func ConvertOpenAIChatToResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string { root := gjson.ParseBytes(rawJSON) // 基础响应模板 out := `{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null,"incomplete_details":null,"output":[],"usage":{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{},"total_tokens":0}}` // 提取基本字段 responseID := root.Get("id").String() if responseID == "" { responseID = fmt.Sprintf("resp_%d", time.Now().UnixNano()) } createdAt := root.Get("created").Int() if createdAt == 0 { createdAt = time.Now().Unix() } out, _ = sjson.Set(out, "id", responseID) out, _ = sjson.Set(out, "created_at", createdAt) // 注入原始请求字段 if originalRequestRawJSON != nil { req := gjson.ParseBytes(originalRequestRawJSON) if v := req.Get("instructions"); v.Exists() { out, _ = sjson.Set(out, "instructions", v.String()) } if v := req.Get("max_output_tokens"); v.Exists() { out, _ = sjson.Set(out, "max_output_tokens", v.Int()) } if v := req.Get("model"); v.Exists() { out, _ = sjson.Set(out, "model", v.String()) } if v := req.Get("parallel_tool_calls"); v.Exists() { out, _ = sjson.Set(out, "parallel_tool_calls", v.Bool()) } if v := req.Get("previous_response_id"); v.Exists() { out, _ = sjson.Set(out, "previous_response_id", v.String()) } if v := req.Get("reasoning"); v.Exists() { out, _ = sjson.Set(out, "reasoning", v.Value()) } if v := req.Get("temperature"); v.Exists() { out, _ = sjson.Set(out, "temperature", v.Float()) } if v := req.Get("tool_choice"); v.Exists() { out, _ = sjson.Set(out, "tool_choice", v.Value()) } if v := req.Get("tools"); v.Exists() { out, _ = sjson.Set(out, "tools", v.Value()) } if v := req.Get("top_p"); v.Exists() { out, _ = sjson.Set(out, "top_p", v.Float()) } if v := req.Get("metadata"); v.Exists() { out, _ = sjson.Set(out, "metadata", v.Value()) } } // 解析 choices choices := root.Get("choices") if !choices.Exists() || !choices.IsArray() || len(choices.Array()) == 0 { return out } var outputs []interface{} var textBuf strings.Builder var reasoningBuf strings.Builder currentMsgID := fmt.Sprintf("msg_%s_0", responseID) for _, choice := range choices.Array() { message := choice.Get("message") if !message.Exists() { continue } // 处理 reasoning_content if reasoning := message.Get("reasoning_content"); reasoning.Exists() && reasoning.String() != "" { reasoningBuf.WriteString(reasoning.String()) } // 处理 content if content := message.Get("content"); content.Exists() && content.String() != "" { textBuf.WriteString(content.String()) } // 处理 tool_calls if toolCalls := message.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() { for _, tc := range toolCalls.Array() { callID := tc.Get("id").String() funcName := tc.Get("function.name").String() funcArgs := tc.Get("function.arguments").String() if funcArgs == "" { funcArgs = "{}" } item := map[string]interface{}{ "id": fmt.Sprintf("fc_%s", callID), "type": "function_call", "status": "completed", "arguments": funcArgs, "call_id": callID, "name": funcName, } outputs = append(outputs, item) } } } // 构建 output 数组 outputIndex := 0 // reasoning item(如果有) if reasoningBuf.Len() > 0 { reasoningItemID := fmt.Sprintf("rs_%s_0", responseID) r := map[string]interface{}{ "id": reasoningItemID, "type": "reasoning", "status": "completed", "summary": []interface{}{map[string]interface{}{ "type": "summary_text", "text": reasoningBuf.String(), }}, } // 在开头插入 outputs = append([]interface{}{r}, outputs...) outputIndex = 1 currentMsgID = fmt.Sprintf("msg_%s_%d", responseID, outputIndex) } // message item(如果有文本) if textBuf.Len() > 0 { m := map[string]interface{}{ "id": currentMsgID, "type": "message", "status": "completed", "content": []interface{}{map[string]interface{}{ "type": "output_text", "annotations": []interface{}{}, "logprobs": []interface{}{}, "text": textBuf.String(), }}, "role": "assistant", } // 在 reasoning 之后,tool_calls 之前插入 if outputIndex > 0 { // 有 reasoning,插入到位置 1 newOutputs := make([]interface{}, 0, len(outputs)+1) newOutputs = append(newOutputs, outputs[0]) // reasoning newOutputs = append(newOutputs, m) // message newOutputs = append(newOutputs, outputs[1:]...) outputs = newOutputs } else { // 没有 reasoning,插入到开头 outputs = append([]interface{}{m}, outputs...) } } if len(outputs) > 0 { out, _ = sjson.Set(out, "output", outputs) } // 处理 usage(完整支持多格式详细字段,参考 claude-code-hub) if usage := root.Get("usage"); usage.Exists() { var inputTokens, outputTokens, totalTokens, cachedTokens int64 var cacheCreation, cacheCreation5m, cacheCreation1h int64 var cacheTTL string // OpenAI 格式 if v := usage.Get("prompt_tokens"); v.Exists() { inputTokens = v.Int() } if v := usage.Get("completion_tokens"); v.Exists() { outputTokens = v.Int() } if v := usage.Get("total_tokens"); v.Exists() { totalTokens = v.Int() } if v := usage.Get("prompt_tokens_details.cached_tokens"); v.Exists() { cachedTokens = v.Int() } reasoningTokensFromUsage := usage.Get("completion_tokens_details.reasoning_tokens").Int() // Claude 格式(优先级高于 OpenAI) if v := usage.Get("input_tokens"); v.Exists() { inputTokens = v.Int() } if v := usage.Get("output_tokens"); v.Exists() { outputTokens = v.Int() } if v := usage.Get("cache_read_input_tokens"); v.Exists() { cachedTokens = v.Int() } if v := usage.Get("cache_creation_input_tokens"); v.Exists() { cacheCreation = v.Int() } if v := usage.Get("cache_creation_5m_input_tokens"); v.Exists() { cacheCreation5m = v.Int() } if v := usage.Get("cache_creation_1h_input_tokens"); v.Exists() { cacheCreation1h = v.Int() } // 设置缓存 TTL 标识 if cacheCreation5m > 0 && cacheCreation1h > 0 { cacheTTL = "mixed" } else if cacheCreation1h > 0 { cacheTTL = "1h" } else if cacheCreation5m > 0 { cacheTTL = "5m" } // Gemini 格式(自动去重) if v := usage.Get("promptTokenCount"); v.Exists() { promptTokens := v.Int() geminiCached := usage.Get("cachedContentTokenCount").Int() // Gemini 的 promptTokenCount 已包含 cachedContentTokenCount,需要扣除 actualInput := promptTokens - geminiCached if actualInput < 0 { actualInput = 0 } inputTokens = actualInput cachedTokens = geminiCached } if v := usage.Get("candidatesTokenCount"); v.Exists() { outputTokens = v.Int() } // 计算总量 if totalTokens == 0 { totalTokens = inputTokens + outputTokens } // 写入基础字段 out, _ = sjson.Set(out, "usage.input_tokens", inputTokens) out, _ = sjson.Set(out, "usage.output_tokens", outputTokens) out, _ = sjson.Set(out, "usage.total_tokens", totalTokens) // input_tokens_details if cachedTokens > 0 { out, _ = sjson.Set(out, "usage.input_tokens_details.cached_tokens", cachedTokens) } // output_tokens_details reasoningTokens := reasoningTokensFromUsage if reasoningTokens == 0 && reasoningBuf.Len() > 0 { reasoningTokens = int64(reasoningBuf.Len() / 4) } if reasoningTokens > 0 { out, _ = sjson.Set(out, "usage.output_tokens_details.reasoning_tokens", reasoningTokens) } // Claude 缓存 TTL 细分字段 if cacheCreation > 0 { out, _ = sjson.Set(out, "usage.cache_creation_input_tokens", cacheCreation) } if cacheCreation5m > 0 { out, _ = sjson.Set(out, "usage.cache_creation_5m_input_tokens", cacheCreation5m) } if cacheCreation1h > 0 { out, _ = sjson.Set(out, "usage.cache_creation_1h_input_tokens", cacheCreation1h) } if cachedTokens > 0 { out, _ = sjson.Set(out, "usage.cache_read_input_tokens", cachedTokens) } if cacheTTL != "" { out, _ = sjson.Set(out, "usage.cache_ttl", cacheTTL) } } return out } ================================================ FILE: backend-go/internal/converters/chat_to_responses_test.go ================================================ package converters import ( "context" "encoding/json" "strings" "testing" "github.com/tidwall/gjson" ) func TestConvertResponsesToOpenAIChatRequest(t *testing.T) { tests := []struct { name string input string model string stream bool validate func(t *testing.T, result []byte) }{ { name: "基本文本输入", input: `{ "model": "gpt-4", "input": "Hello, world!", "instructions": "You are a helpful assistant." }`, model: "gpt-4o", stream: false, validate: func(t *testing.T, result []byte) { root := gjson.ParseBytes(result) if root.Get("model").String() != "gpt-4o" { t.Errorf("model should be gpt-4o, got %s", root.Get("model").String()) } if root.Get("stream").Bool() != false { t.Error("stream should be false") } messages := root.Get("messages").Array() if len(messages) != 2 { t.Errorf("should have 2 messages (system + user), got %d", len(messages)) } if messages[0].Get("role").String() != "system" { t.Error("first message should be system") } if messages[1].Get("role").String() != "user" { t.Error("second message should be user") } }, }, { name: "带 tools 的请求", input: `{ "model": "gpt-4", "input": [{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "What's the weather?"}]}], "tools": [ { "name": "get_weather", "description": "Get weather info", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}} } ] }`, model: "gpt-4o", stream: true, validate: func(t *testing.T, result []byte) { root := gjson.ParseBytes(result) if root.Get("stream").Bool() != true { t.Error("stream should be true") } tools := root.Get("tools").Array() if len(tools) != 1 { t.Errorf("should have 1 tool, got %d", len(tools)) } if tools[0].Get("function.name").String() != "get_weather" { t.Error("tool name should be get_weather") } }, }, { name: "function_call 和 function_call_output", input: `{ "model": "gpt-4", "input": [ {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "What's the weather in NYC?"}]}, {"type": "function_call", "call_id": "call_123", "name": "get_weather", "arguments": "{\"location\": \"NYC\"}"}, {"type": "function_call_output", "call_id": "call_123", "output": "Sunny, 72°F"} ] }`, model: "gpt-4o", stream: false, validate: func(t *testing.T, result []byte) { root := gjson.ParseBytes(result) messages := root.Get("messages").Array() if len(messages) != 3 { t.Errorf("should have 3 messages, got %d", len(messages)) } // 第二条消息应该是 assistant with tool_calls if messages[1].Get("role").String() != "assistant" { t.Error("second message should be assistant") } if !messages[1].Get("tool_calls").Exists() { t.Error("assistant message should have tool_calls") } // 第三条消息应该是 tool if messages[2].Get("role").String() != "tool" { t.Error("third message should be tool") } }, }, { name: "reasoning effort 转换", input: `{ "model": "o1-mini", "input": "Think about this", "reasoning": {"effort": "high"} }`, model: "o1-mini", stream: false, validate: func(t *testing.T, result []byte) { root := gjson.ParseBytes(result) if root.Get("reasoning_effort").String() != "high" { t.Errorf("reasoning_effort should be high, got %s", root.Get("reasoning_effort").String()) } }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := ConvertResponsesToOpenAIChatRequest(tt.model, []byte(tt.input), tt.stream) tt.validate(t, result) }) } } func TestConvertOpenAIChatToResponses_Stream(t *testing.T) { ctx := context.Background() // 模拟 OpenAI Chat Completions SSE 流 sseLines := []string{ `data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1234567890,"model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}`, `data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1234567890,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}`, `data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1234567890,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":" world!"},"finish_reason":null}]}`, `data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1234567890,"model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}`, `data: [DONE]`, } originalReq := []byte(`{"model":"gpt-4o","input":"Hi"}`) var state any var allEvents []string for _, line := range sseLines { events := ConvertOpenAIChatToResponses(ctx, "gpt-4o", originalReq, nil, []byte(line), &state) allEvents = append(allEvents, events...) } // 验证事件序列 if len(allEvents) == 0 { t.Fatal("should produce events") } // 检查是否有 response.created 事件 hasCreated := false hasInProgress := false hasCompleted := false hasTextDelta := false for _, ev := range allEvents { if strings.Contains(ev, "response.created") { hasCreated = true } if strings.Contains(ev, "response.in_progress") { hasInProgress = true } if strings.Contains(ev, "response.completed") { hasCompleted = true } if strings.Contains(ev, "response.output_text.delta") { hasTextDelta = true } } if !hasCreated { t.Error("should have response.created event") } if !hasInProgress { t.Error("should have response.in_progress event") } if !hasCompleted { t.Error("should have response.completed event") } if !hasTextDelta { t.Error("should have response.output_text.delta event") } } func TestConvertOpenAIChatToResponses_ToolCall(t *testing.T) { ctx := context.Background() // 模拟带 tool_call 的 SSE 流 sseLines := []string{ `data: {"id":"chatcmpl-456","object":"chat.completion.chunk","created":1234567890,"model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}`, `data: {"id":"chatcmpl-456","object":"chat.completion.chunk","created":1234567890,"model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"loc"}}]},"finish_reason":null}]}`, `data: {"id":"chatcmpl-456","object":"chat.completion.chunk","created":1234567890,"model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ation\": \"NYC\"}"}}]},"finish_reason":null}]}`, `data: {"id":"chatcmpl-456","object":"chat.completion.chunk","created":1234567890,"model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`, `data: [DONE]`, } originalReq := []byte(`{"model":"gpt-4o","input":"What's the weather?","tools":[{"name":"get_weather"}]}`) var state any var allEvents []string for _, line := range sseLines { events := ConvertOpenAIChatToResponses(ctx, "gpt-4o", originalReq, nil, []byte(line), &state) allEvents = append(allEvents, events...) } // 验证是否有 function_call 相关事件 hasFuncAdded := false hasFuncDelta := false hasFuncDone := false for _, ev := range allEvents { if strings.Contains(ev, "response.output_item.added") && strings.Contains(ev, "function_call") { hasFuncAdded = true } if strings.Contains(ev, "response.function_call_arguments.delta") { hasFuncDelta = true } if strings.Contains(ev, "response.function_call_arguments.done") { hasFuncDone = true } } if !hasFuncAdded { t.Error("should have function_call output_item.added event") } if !hasFuncDelta { t.Error("should have function_call_arguments.delta event") } if !hasFuncDone { t.Error("should have function_call_arguments.done event") } } func TestConvertOpenAIChatToResponsesNonStream(t *testing.T) { ctx := context.Background() // 模拟 OpenAI Chat Completions 非流式响应 chatResponse := `{ "id": "chatcmpl-789", "object": "chat.completion", "created": 1234567890, "model": "gpt-4o", "choices": [{ "index": 0, "message": { "role": "assistant", "content": "Hello! How can I help you today?" }, "finish_reason": "stop" }], "usage": { "prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18 } }` originalReq := []byte(`{"model":"gpt-4o","input":"Hi","instructions":"Be helpful"}`) result := ConvertOpenAIChatToResponsesNonStream(ctx, "gpt-4o", originalReq, nil, []byte(chatResponse), nil) // 解析结果 var resp map[string]interface{} if err := json.Unmarshal([]byte(result), &resp); err != nil { t.Fatalf("failed to parse result: %v", err) } // 验证基本字段 if resp["object"] != "response" { t.Errorf("object should be response, got %v", resp["object"]) } if resp["status"] != "completed" { t.Errorf("status should be completed, got %v", resp["status"]) } // 验证 output output, ok := resp["output"].([]interface{}) if !ok || len(output) == 0 { t.Fatal("output should have items") } msgItem := output[0].(map[string]interface{}) if msgItem["type"] != "message" { t.Errorf("first output item should be message, got %v", msgItem["type"]) } // 验证 usage usage, ok := resp["usage"].(map[string]interface{}) if !ok { t.Fatal("usage should exist") } if usage["input_tokens"].(float64) != 10 { t.Errorf("input_tokens should be 10, got %v", usage["input_tokens"]) } if usage["output_tokens"].(float64) != 8 { t.Errorf("output_tokens should be 8, got %v", usage["output_tokens"]) } } func TestConvertOpenAIChatToResponsesNonStream_ToolCalls(t *testing.T) { ctx := context.Background() // 模拟带 tool_calls 的响应 chatResponse := `{ "id": "chatcmpl-tool", "object": "chat.completion", "created": 1234567890, "model": "gpt-4o", "choices": [{ "index": 0, "message": { "role": "assistant", "content": null, "tool_calls": [ { "id": "call_xyz", "type": "function", "function": { "name": "search", "arguments": "{\"query\": \"test\"}" } } ] }, "finish_reason": "tool_calls" }], "usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15} }` originalReq := []byte(`{"model":"gpt-4o","input":"Search for test"}`) result := ConvertOpenAIChatToResponsesNonStream(ctx, "gpt-4o", originalReq, nil, []byte(chatResponse), nil) var resp map[string]interface{} if err := json.Unmarshal([]byte(result), &resp); err != nil { t.Fatalf("failed to parse result: %v", err) } output, ok := resp["output"].([]interface{}) if !ok || len(output) == 0 { t.Fatal("output should have items") } // 查找 function_call item var funcItem map[string]interface{} for _, item := range output { itemMap := item.(map[string]interface{}) if itemMap["type"] == "function_call" { funcItem = itemMap break } } if funcItem == nil { t.Fatal("should have function_call item") } if funcItem["name"] != "search" { t.Errorf("function name should be search, got %v", funcItem["name"]) } if funcItem["call_id"] != "call_xyz" { t.Errorf("call_id should be call_xyz, got %v", funcItem["call_id"]) } } ================================================ FILE: backend-go/internal/converters/claude_converter.go ================================================ package converters import ( "github.com/BenedictKing/claude-proxy/internal/session" "github.com/BenedictKing/claude-proxy/internal/types" ) // ============== Claude Messages API 转换器 ============== // ClaudeConverter 实现 Responses → Claude Messages API 转换 type ClaudeConverter struct{} // ToProviderRequest 将 Responses 请求转换为 Claude Messages 格式 func (c *ClaudeConverter) ToProviderRequest(sess *session.Session, req *types.ResponsesRequest) (interface{}, error) { // 转换 messages 和 system messages, system, err := ResponsesToClaudeMessages(sess, req.Input, req.Instructions) if err != nil { return nil, err } // 构建 Claude 请求 claudeReq := map[string]interface{}{ "model": req.Model, "messages": messages, "stream": req.Stream, } // Claude 使用独立的 system 参数(不在 messages 中) if system != "" { claudeReq["system"] = system } // 复制其他参数 if req.MaxTokens > 0 { claudeReq["max_tokens"] = req.MaxTokens } if req.Temperature > 0 { claudeReq["temperature"] = req.Temperature } if req.TopP > 0 { claudeReq["top_p"] = req.TopP } if req.Stop != nil { claudeReq["stop_sequences"] = req.Stop // Claude 使用 stop_sequences } return claudeReq, nil } // FromProviderResponse 将 Claude 响应转换为 Responses 格式 func (c *ClaudeConverter) FromProviderResponse(resp map[string]interface{}, sessionID string) (*types.ResponsesResponse, error) { return ClaudeResponseToResponses(resp, sessionID) } // GetProviderName 获取上游服务名称 func (c *ClaudeConverter) GetProviderName() string { return "Claude Messages API" } ================================================ FILE: backend-go/internal/converters/converter.go ================================================ package converters import ( "github.com/BenedictKing/claude-proxy/internal/session" "github.com/BenedictKing/claude-proxy/internal/types" ) // ============== FinishReason 映射 ============== // OpenAIFinishReasonToAnthropic 将 OpenAI finish_reason 转换为 Anthropic stop_reason // 未知原因保持原值透传,避免隐藏上游状态 func OpenAIFinishReasonToAnthropic(reason string) string { switch reason { case "stop": return "end_turn" case "length": return "max_tokens" case "tool_calls", "function_call": return "tool_use" case "content_filter": return "refusal" case "", "empty": return "end_turn" default: return reason // 未知原因透传 } } // AnthropicStopReasonToOpenAI 将 Anthropic stop_reason 转换为 OpenAI finish_reason // 未知原因保持原值透传,避免隐藏上游状态 func AnthropicStopReasonToOpenAI(reason string) string { switch reason { case "end_turn": return "stop" case "max_tokens": return "length" case "stop_sequence", "pause_turn": return "stop" case "tool_use": return "tool_calls" case "refusal": return "content_filter" case "", "empty": return "stop" default: return reason // 未知原因透传 } } // OpenAIFinishReasonToResponses 将 OpenAI finish_reason 转换为 Responses API status // 未知原因映射为 incomplete,避免将潜在错误误报为成功 func OpenAIFinishReasonToResponses(reason string) string { switch reason { case "stop", "tool_calls", "function_call": return "completed" case "length": return "incomplete" case "content_filter": return "failed" case "", "empty": return "completed" default: return "incomplete" // 未知原因视为未完成,避免误报成功 } } // ResponsesConverter 定义 Responses API 转换器接口 // 用于将 Responses 格式转换为不同上游服务的格式 type ResponsesConverter interface { // ToProviderRequest 将 Responses 请求转换为上游服务的请求格式 // 返回:请求体(map 或其他类型)、错误 ToProviderRequest(sess *session.Session, req *types.ResponsesRequest) (interface{}, error) // FromProviderResponse 将上游服务的响应转换为 Responses 格式 // 返回:Responses 响应、错误 FromProviderResponse(resp map[string]interface{}, sessionID string) (*types.ResponsesResponse, error) // GetProviderName 获取上游服务名称(用于日志和调试) GetProviderName() string } ================================================ FILE: backend-go/internal/converters/converter_test.go ================================================ package converters import ( "testing" "github.com/BenedictKing/claude-proxy/internal/session" "github.com/BenedictKing/claude-proxy/internal/types" ) // ============== extractTextFromContent 测试 ============== func TestExtractTextFromContent_String(t *testing.T) { content := "Hello, world!" result := extractTextFromContent(content) if result != "Hello, world!" { t.Errorf("期望 'Hello, world!',实际得到 '%s'", result) } } func TestExtractTextFromContent_ContentBlockArray(t *testing.T) { content := []interface{}{ map[string]interface{}{ "type": "input_text", "text": "First message", }, map[string]interface{}{ "type": "input_text", "text": "Second message", }, } result := extractTextFromContent(content) expected := "First message\nSecond message" if result != expected { t.Errorf("期望 '%s',实际得到 '%s'", expected, result) } } func TestExtractTextFromContent_MixedTypes(t *testing.T) { content := []interface{}{ map[string]interface{}{ "type": "input_text", "text": "User message", }, map[string]interface{}{ "type": "output_text", "text": "Assistant message", }, map[string]interface{}{ "type": "unknown", "text": "Should be ignored", }, } result := extractTextFromContent(content) expected := "User message\nAssistant message" if result != expected { t.Errorf("期望 '%s',实际得到 '%s'", expected, result) } } func TestExtractTextFromContent_EmptyArray(t *testing.T) { content := []interface{}{} result := extractTextFromContent(content) if result != "" { t.Errorf("期望空字符串,实际得到 '%s'", result) } } // ============== OpenAI 转换器测试 ============== func TestOpenAIChatConverter_WithInstructions(t *testing.T) { converter := &OpenAIChatConverter{} sess := &session.Session{ ID: "sess_test", Messages: []types.ResponsesItem{}, } req := &types.ResponsesRequest{ Model: "gpt-4", Instructions: "You are a helpful assistant.", Input: "Hello!", MaxTokens: 100, Temperature: 0.7, } result, err := converter.ToProviderRequest(sess, req) if err != nil { t.Fatalf("转换失败: %v", err) } resultMap, ok := result.(map[string]interface{}) if !ok { t.Fatal("结果不是 map[string]interface{}") } // 检查 model if resultMap["model"] != "gpt-4" { t.Errorf("期望 model 为 'gpt-4',实际为 '%v'", resultMap["model"]) } // 检查 messages messages, ok := resultMap["messages"].([]map[string]interface{}) if !ok { t.Fatal("messages 不是正确的类型") } if len(messages) != 2 { t.Fatalf("期望 2 条消息(system + user),实际为 %d", len(messages)) } // 检查第一条是 system if messages[0]["role"] != "system" { t.Errorf("第一条消息应该是 system,实际为 '%v'", messages[0]["role"]) } if messages[0]["content"] != "You are a helpful assistant." { t.Errorf("system 内容不匹配") } // 检查第二条是 user if messages[1]["role"] != "user" { t.Errorf("第二条消息应该是 user,实际为 '%v'", messages[1]["role"]) } if messages[1]["content"] != "Hello!" { t.Errorf("user 内容不匹配") } // 检查其他参数 if resultMap["max_tokens"] != 100 { t.Errorf("max_tokens 不匹配") } if resultMap["temperature"] != 0.7 { t.Errorf("temperature 不匹配") } } func TestOpenAIChatConverter_WithMessageType(t *testing.T) { converter := &OpenAIChatConverter{} sess := &session.Session{ ID: "sess_test", Messages: []types.ResponsesItem{}, } req := &types.ResponsesRequest{ Model: "gpt-4", Input: []interface{}{ map[string]interface{}{ "type": "message", "role": "user", "content": []interface{}{ map[string]interface{}{ "type": "input_text", "text": "Hello from message type!", }, }, }, }, } result, err := converter.ToProviderRequest(sess, req) if err != nil { t.Fatalf("转换失败: %v", err) } resultMap := result.(map[string]interface{}) messages := resultMap["messages"].([]map[string]interface{}) if len(messages) != 1 { t.Fatalf("期望 1 条消息,实际为 %d", len(messages)) } if messages[0]["role"] != "user" { t.Errorf("角色应该是 user") } if messages[0]["content"] != "Hello from message type!" { t.Errorf("内容不匹配,实际为 '%v'", messages[0]["content"]) } } // ============== Claude 转换器测试 ============== func TestClaudeConverter_WithInstructions(t *testing.T) { converter := &ClaudeConverter{} sess := &session.Session{ ID: "sess_test", Messages: []types.ResponsesItem{}, } req := &types.ResponsesRequest{ Model: "claude-3-opus", Instructions: "You are Claude.", Input: "Hello!", MaxTokens: 1000, } result, err := converter.ToProviderRequest(sess, req) if err != nil { t.Fatalf("转换失败: %v", err) } resultMap := result.(map[string]interface{}) // 检查 system 参数(Claude 使用独立的 system 字段) if resultMap["system"] != "You are Claude." { t.Errorf("system 参数不匹配") } // 检查 messages messages, ok := resultMap["messages"].([]types.ClaudeMessage) if !ok { t.Fatal("messages 不是正确的类型") } if len(messages) != 1 { t.Fatalf("期望 1 条消息,实际为 %d", len(messages)) } if messages[0].Role != "user" { t.Errorf("角色应该是 user") } } // ============== 工厂模式测试 ============== func TestConverterFactory(t *testing.T) { tests := []struct { serviceType string expectedType string }{ {"openai", "*converters.OpenAIChatConverter"}, {"claude", "*converters.ClaudeConverter"}, {"responses", "*converters.ResponsesPassthroughConverter"}, {"unknown", "*converters.OpenAIChatConverter"}, // 默认 } for _, tt := range tests { t.Run(tt.serviceType, func(t *testing.T) { converter := NewConverter(tt.serviceType) if converter == nil { t.Errorf("工厂返回 nil") } // 检查类型(简单验证) if converter.GetProviderName() == "" { t.Errorf("GetProviderName 返回空字符串") } }) } } // ============== 会话历史测试 ============== func TestOpenAIChatConverter_WithSessionHistory(t *testing.T) { converter := &OpenAIChatConverter{} sess := &session.Session{ ID: "sess_test", Messages: []types.ResponsesItem{ { Type: "message", Role: "user", Content: "Previous user message", }, { Type: "message", Role: "assistant", Content: "Previous assistant message", }, }, } req := &types.ResponsesRequest{ Model: "gpt-4", Input: "New user message", } result, err := converter.ToProviderRequest(sess, req) if err != nil { t.Fatalf("转换失败: %v", err) } resultMap := result.(map[string]interface{}) messages := resultMap["messages"].([]map[string]interface{}) // 应该有 3 条消息:2 条历史 + 1 条新消息 if len(messages) != 3 { t.Fatalf("期望 3 条消息,实际为 %d", len(messages)) } // 检查顺序 if messages[0]["content"] != "Previous user message" { t.Errorf("第一条消息内容不匹配") } if messages[1]["content"] != "Previous assistant message" { t.Errorf("第二条消息内容不匹配") } if messages[2]["content"] != "New user message" { t.Errorf("第三条消息内容不匹配") } } // ============== FinishReason 映射测试 ============== func TestOpenAIFinishReasonToAnthropic(t *testing.T) { tests := []struct { input string expected string }{ {"stop", "end_turn"}, {"length", "max_tokens"}, {"tool_calls", "tool_use"}, {"function_call", "tool_use"}, {"content_filter", "refusal"}, {"empty", "end_turn"}, {"unknown_reason", "unknown_reason"}, // 未知原因透传 } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { result := OpenAIFinishReasonToAnthropic(tt.input) if result != tt.expected { t.Errorf("OpenAIFinishReasonToAnthropic(%q) = %q, want %q", tt.input, result, tt.expected) } }) } } func TestAnthropicStopReasonToOpenAI(t *testing.T) { tests := []struct { input string expected string }{ {"end_turn", "stop"}, {"max_tokens", "length"}, {"stop_sequence", "stop"}, {"pause_turn", "stop"}, {"tool_use", "tool_calls"}, {"refusal", "content_filter"}, {"empty", "stop"}, {"unknown_reason", "unknown_reason"}, // 未知原因透传 } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { result := AnthropicStopReasonToOpenAI(tt.input) if result != tt.expected { t.Errorf("AnthropicStopReasonToOpenAI(%q) = %q, want %q", tt.input, result, tt.expected) } }) } } func TestOpenAIFinishReasonToResponses(t *testing.T) { tests := []struct { input string expected string }{ {"stop", "completed"}, {"tool_calls", "completed"}, {"function_call", "completed"}, {"length", "incomplete"}, {"content_filter", "failed"}, {"empty", "completed"}, {"unknown_reason", "incomplete"}, // 未知原因视为未完成 } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { result := OpenAIFinishReasonToResponses(tt.input) if result != tt.expected { t.Errorf("OpenAIFinishReasonToResponses(%q) = %q, want %q", tt.input, result, tt.expected) } }) } } ================================================ FILE: backend-go/internal/converters/factory.go ================================================ package converters // ConverterFactory 转换器工厂 // 根据上游服务类型返回对应的转换器实例 // NewConverter 创建转换器实例 // serviceType: "openai", "claude", "responses" func NewConverter(serviceType string) ResponsesConverter { switch serviceType { case "openai": return &OpenAIChatConverter{} case "claude": return &ClaudeConverter{} case "responses": return &ResponsesPassthroughConverter{} default: // 默认使用 OpenAI Chat 转换器 return &OpenAIChatConverter{} } } ================================================ FILE: backend-go/internal/converters/gemini_converter.go ================================================ package converters import ( "encoding/json" "fmt" "strings" "github.com/BenedictKing/claude-proxy/internal/types" ) // ============== Gemini -> Claude/OpenAI 转换器 ============== // GeminiToClaudeRequest 将 Gemini 请求转换为 Claude Messages API 格式 func GeminiToClaudeRequest(geminiReq *types.GeminiRequest, model string) (map[string]interface{}, error) { claudeReq := map[string]interface{}{ "model": model, } // 1. 转换 systemInstruction -> system if geminiReq.SystemInstruction != nil && len(geminiReq.SystemInstruction.Parts) > 0 { systemText := extractTextFromGeminiParts(geminiReq.SystemInstruction.Parts) if systemText != "" { claudeReq["system"] = systemText } } // 2. 转换 contents -> messages messages := []map[string]interface{}{} for _, content := range geminiReq.Contents { msg, err := geminiContentToClaudeMessage(&content) if err != nil { return nil, err } if msg != nil { messages = append(messages, msg) } } claudeReq["messages"] = messages // 3. 转换 generationConfig if geminiReq.GenerationConfig != nil { cfg := geminiReq.GenerationConfig if cfg.MaxOutputTokens > 0 { claudeReq["max_tokens"] = cfg.MaxOutputTokens } if cfg.Temperature != nil { claudeReq["temperature"] = *cfg.Temperature } if cfg.TopP != nil { claudeReq["top_p"] = *cfg.TopP } if cfg.TopK != nil { claudeReq["top_k"] = *cfg.TopK } if len(cfg.StopSequences) > 0 { claudeReq["stop_sequences"] = cfg.StopSequences } } // 4. 转换 tools -> tools if len(geminiReq.Tools) > 0 { claudeTools := []map[string]interface{}{} for _, tool := range geminiReq.Tools { for _, fn := range tool.FunctionDeclarations { claudeTool := map[string]interface{}{ "name": fn.Name, } if fn.Description != "" { claudeTool["description"] = fn.Description } if fn.Parameters != nil { claudeTool["input_schema"] = fn.Parameters } else { // Claude 需要 input_schema,提供空 schema claudeTool["input_schema"] = map[string]interface{}{ "type": "object", "properties": map[string]interface{}{}, } } claudeTools = append(claudeTools, claudeTool) } } if len(claudeTools) > 0 { claudeReq["tools"] = claudeTools } } return claudeReq, nil } // GeminiToOpenAIRequest 将 Gemini 请求转换为 OpenAI Chat Completions 格式 func GeminiToOpenAIRequest(geminiReq *types.GeminiRequest, model string) (map[string]interface{}, error) { openaiReq := map[string]interface{}{ "model": model, } messages := []map[string]interface{}{} // 1. 转换 systemInstruction -> system message if geminiReq.SystemInstruction != nil && len(geminiReq.SystemInstruction.Parts) > 0 { systemText := extractTextFromGeminiParts(geminiReq.SystemInstruction.Parts) if systemText != "" { messages = append(messages, map[string]interface{}{ "role": "system", "content": systemText, }) } } // 2. 转换 contents -> messages for _, content := range geminiReq.Contents { msg, err := geminiContentToOpenAIMessage(&content) if err != nil { return nil, err } if msg != nil { messages = append(messages, msg) } } openaiReq["messages"] = messages // 3. 转换 generationConfig if geminiReq.GenerationConfig != nil { cfg := geminiReq.GenerationConfig if cfg.MaxOutputTokens > 0 { openaiReq["max_tokens"] = cfg.MaxOutputTokens } if cfg.Temperature != nil { openaiReq["temperature"] = *cfg.Temperature } if cfg.TopP != nil { openaiReq["top_p"] = *cfg.TopP } if len(cfg.StopSequences) > 0 { openaiReq["stop"] = cfg.StopSequences } } // 4. 转换 tools -> tools if len(geminiReq.Tools) > 0 { openaiTools := []map[string]interface{}{} for _, tool := range geminiReq.Tools { for _, fn := range tool.FunctionDeclarations { openaiTool := map[string]interface{}{ "type": "function", "function": map[string]interface{}{ "name": fn.Name, "description": fn.Description, "parameters": fn.Parameters, }, } openaiTools = append(openaiTools, openaiTool) } } if len(openaiTools) > 0 { openaiReq["tools"] = openaiTools } } return openaiReq, nil } // ============== Claude/OpenAI -> Gemini 响应转换 ============== // ClaudeResponseToGemini 将 Claude 响应转换为 Gemini 格式 func ClaudeResponseToGemini(claudeResp map[string]interface{}) (*types.GeminiResponse, error) { geminiResp := &types.GeminiResponse{ Candidates: []types.GeminiCandidate{}, } // 1. 转换 content -> candidates[0].content.parts content, ok := claudeResp["content"].([]interface{}) if !ok { return geminiResp, nil } parts := []types.GeminiPart{} for _, c := range content { contentBlock, ok := c.(map[string]interface{}) if !ok { continue } blockType, _ := contentBlock["type"].(string) switch blockType { case "text": text, _ := contentBlock["text"].(string) parts = append(parts, types.GeminiPart{ Text: text, }) case "tool_use": name, _ := contentBlock["name"].(string) args, _ := contentBlock["input"].(map[string]interface{}) functionCall := &types.GeminiFunctionCall{ Name: name, Args: args, } // 处理 thought_signature: // 1. 如果 Claude 响应中包含 signature,保留原值 // 2. 否则使用 dummy signature 跳过 Gemini 验证 if signature, ok := contentBlock["signature"].(string); ok && signature != "" { functionCall.ThoughtSignature = signature } else { functionCall.ThoughtSignature = types.DummyThoughtSignature } parts = append(parts, types.GeminiPart{ FunctionCall: functionCall, }) } } // 2. 转换 stop_reason -> finishReason finishReason := "STOP" if stopReason, ok := claudeResp["stop_reason"].(string); ok { finishReason = claudeStopReasonToGemini(stopReason) } candidate := types.GeminiCandidate{ Content: &types.GeminiContent{ Parts: parts, Role: "model", }, FinishReason: finishReason, Index: 0, } geminiResp.Candidates = append(geminiResp.Candidates, candidate) // 3. 转换 usage -> usageMetadata if usageRaw, ok := claudeResp["usage"].(map[string]interface{}); ok { inputTokens, _ := getIntFromMap(usageRaw, "input_tokens") outputTokens, _ := getIntFromMap(usageRaw, "output_tokens") cacheRead, _ := getIntFromMap(usageRaw, "cache_read_input_tokens") geminiResp.UsageMetadata = &types.GeminiUsageMetadata{ PromptTokenCount: inputTokens + cacheRead, // Gemini 格式包含缓存 CandidatesTokenCount: outputTokens, TotalTokenCount: inputTokens + cacheRead + outputTokens, CachedContentTokenCount: cacheRead, } } return geminiResp, nil } // OpenAIResponseToGemini 将 OpenAI 响应转换为 Gemini 格式 func OpenAIResponseToGemini(openaiResp map[string]interface{}) (*types.GeminiResponse, error) { geminiResp := &types.GeminiResponse{ Candidates: []types.GeminiCandidate{}, } // 1. 转换 choices[0].message -> candidates[0].content choices, ok := openaiResp["choices"].([]interface{}) if !ok || len(choices) == 0 { return geminiResp, nil } choice, ok := choices[0].(map[string]interface{}) if !ok { return geminiResp, nil } parts := []types.GeminiPart{} finishReason := "STOP" // 处理 message if message, ok := choice["message"].(map[string]interface{}); ok { // 文本内容 if content, ok := message["content"].(string); ok && content != "" { parts = append(parts, types.GeminiPart{ Text: content, }) } // 工具调用 if toolCalls, ok := message["tool_calls"].([]interface{}); ok { for _, tc := range toolCalls { toolCall, ok := tc.(map[string]interface{}) if !ok { continue } function, ok := toolCall["function"].(map[string]interface{}) if !ok { continue } name, _ := function["name"].(string) argsStr, _ := function["arguments"].(string) var args map[string]interface{} if argsStr != "" { _ = JSONUnmarshal([]byte(argsStr), &args) } // OpenAI 响应不包含 signature,统一使用 dummy signature parts = append(parts, types.GeminiPart{ FunctionCall: &types.GeminiFunctionCall{ Name: name, Args: args, ThoughtSignature: types.DummyThoughtSignature, }, }) } } } // 转换 finish_reason if fr, ok := choice["finish_reason"].(string); ok { finishReason = openaiFinishReasonToGemini(fr) } candidate := types.GeminiCandidate{ Content: &types.GeminiContent{ Parts: parts, Role: "model", }, FinishReason: finishReason, Index: 0, } geminiResp.Candidates = append(geminiResp.Candidates, candidate) // 2. 转换 usage -> usageMetadata if usageRaw, ok := openaiResp["usage"].(map[string]interface{}); ok { promptTokens, _ := getIntFromMap(usageRaw, "prompt_tokens") completionTokens, _ := getIntFromMap(usageRaw, "completion_tokens") geminiResp.UsageMetadata = &types.GeminiUsageMetadata{ PromptTokenCount: promptTokens, CandidatesTokenCount: completionTokens, TotalTokenCount: promptTokens + completionTokens, } } return geminiResp, nil } // ============== 辅助函数 ============== // geminiContentToClaudeMessage 将 Gemini Content 转换为 Claude Message func geminiContentToClaudeMessage(content *types.GeminiContent) (map[string]interface{}, error) { if content == nil || len(content.Parts) == 0 { return nil, nil } // 角色转换: model -> assistant, user -> user role := content.Role if role == "model" { role = "assistant" } if role == "" { role = "user" } claudeContent := []map[string]interface{}{} for i, part := range content.Parts { if part.Text != "" { claudeContent = append(claudeContent, map[string]interface{}{ "type": "text", "text": part.Text, }) } if part.InlineData != nil { // 图片转换 claudeContent = append(claudeContent, map[string]interface{}{ "type": "image", "source": map[string]interface{}{ "type": "base64", "media_type": part.InlineData.MimeType, "data": part.InlineData.Data, }, }) } if part.FunctionCall != nil { // 工具调用 claudeContent = append(claudeContent, map[string]interface{}{ "type": "tool_use", "id": fmt.Sprintf("toolu_%d", i), "name": part.FunctionCall.Name, "input": part.FunctionCall.Args, }) } if part.FunctionResponse != nil { // 工具结果 - Claude 需要单独的 tool_result 消息 // 这里简化处理,将其作为 tool_result 内容块 claudeContent = append(claudeContent, map[string]interface{}{ "type": "tool_result", "tool_use_id": part.FunctionResponse.Name, "content": part.FunctionResponse.Response, }) } } if len(claudeContent) == 0 { return nil, nil } return map[string]interface{}{ "role": role, "content": claudeContent, }, nil } // geminiContentToOpenAIMessage 将 Gemini Content 转换为 OpenAI Message func geminiContentToOpenAIMessage(content *types.GeminiContent) (map[string]interface{}, error) { if content == nil || len(content.Parts) == 0 { return nil, nil } // 角色转换: model -> assistant, user -> user role := content.Role if role == "model" { role = "assistant" } if role == "" { role = "user" } // 检查是否有工具调用 var toolCalls []map[string]interface{} var textParts []string var hasToolResponse bool var toolResponseName string var toolResponseContent interface{} for i, part := range content.Parts { if part.Text != "" { textParts = append(textParts, part.Text) } if part.FunctionCall != nil { argsJSON, _ := JSONMarshal(part.FunctionCall.Args) toolCalls = append(toolCalls, map[string]interface{}{ "id": fmt.Sprintf("call_%d", i), "type": "function", "function": map[string]interface{}{ "name": part.FunctionCall.Name, "arguments": string(argsJSON), }, }) } if part.FunctionResponse != nil { hasToolResponse = true toolResponseName = part.FunctionResponse.Name toolResponseContent = part.FunctionResponse.Response } } // 如果是工具响应,返回 tool role 的消息 if hasToolResponse { contentStr := "" if str, ok := toolResponseContent.(string); ok { contentStr = str } else { contentBytes, _ := JSONMarshal(toolResponseContent) contentStr = string(contentBytes) } return map[string]interface{}{ "role": "tool", "tool_call_id": toolResponseName, "content": contentStr, }, nil } msg := map[string]interface{}{ "role": role, } // 设置内容 if len(toolCalls) > 0 { // 助手消息带工具调用 if len(textParts) > 0 { msg["content"] = strings.Join(textParts, "\n") } else { msg["content"] = nil } msg["tool_calls"] = toolCalls } else { // 普通消息 msg["content"] = strings.Join(textParts, "\n") } return msg, nil } // extractTextFromGeminiParts 从 Gemini Parts 中提取文本 func extractTextFromGeminiParts(parts []types.GeminiPart) string { texts := []string{} for _, part := range parts { if part.Text != "" { texts = append(texts, part.Text) } } return strings.Join(texts, "\n") } // claudeStopReasonToGemini 将 Claude 停止原因转换为 Gemini 格式 func claudeStopReasonToGemini(stopReason string) string { switch stopReason { case "end_turn", "stop_sequence": return "STOP" case "max_tokens": return "MAX_TOKENS" case "tool_use": return "STOP" // Gemini 使用相同的 STOP 表示工具调用 default: return "STOP" } } // openaiFinishReasonToGemini 将 OpenAI 停止原因转换为 Gemini 格式 func openaiFinishReasonToGemini(finishReason string) string { switch finishReason { case "stop": return "STOP" case "length": return "MAX_TOKENS" case "tool_calls": return "STOP" case "content_filter": return "SAFETY" default: return "STOP" } } // geminiFinishReasonToClaude 将 Gemini 停止原因转换为 Claude 格式 func geminiFinishReasonToClaude(finishReason string) string { switch finishReason { case "STOP": return "end_turn" case "MAX_TOKENS": return "max_tokens" case "SAFETY", "RECITATION": return "end_turn" default: return "end_turn" } } // geminiFinishReasonToOpenAI 将 Gemini 停止原因转换为 OpenAI 格式 func geminiFinishReasonToOpenAI(finishReason string) string { switch finishReason { case "STOP": return "stop" case "MAX_TOKENS": return "length" case "SAFETY": return "content_filter" default: return "stop" } } // JSONMarshal JSON 序列化包装函数 func JSONMarshal(v interface{}) ([]byte, error) { return json.Marshal(v) } // JSONUnmarshal JSON 反序列化包装函数 func JSONUnmarshal(data []byte, v interface{}) error { return json.Unmarshal(data, v) } ================================================ FILE: backend-go/internal/converters/gemini_converter_test.go ================================================ package converters import ( "testing" "github.com/stretchr/testify/assert" ) // TestClaudeResponseToGemini_WithThoughtSignature 测试 Claude 响应转换时 thought_signature 的处理 func TestClaudeResponseToGemini_WithThoughtSignature(t *testing.T) { t.Run("保留原有 signature", func(t *testing.T) { // 测试场景 1: Claude 响应包含 signature claudeResp := map[string]interface{}{ "content": []interface{}{ map[string]interface{}{ "type": "tool_use", "name": "test_function", "input": map[string]interface{}{"arg": "value"}, "signature": "original_signature_from_claude", }, }, } geminiResp, err := ClaudeResponseToGemini(claudeResp) assert.NoError(t, err) assert.NotNil(t, geminiResp) assert.Len(t, geminiResp.Candidates, 1) assert.NotNil(t, geminiResp.Candidates[0].Content) assert.Len(t, geminiResp.Candidates[0].Content.Parts, 1) assert.NotNil(t, geminiResp.Candidates[0].Content.Parts[0].FunctionCall) assert.Equal(t, "original_signature_from_claude", geminiResp.Candidates[0].Content.Parts[0].FunctionCall.ThoughtSignature) }) t.Run("使用 dummy signature", func(t *testing.T) { // 测试场景 2: Claude 响应不包含 signature claudeResp := map[string]interface{}{ "content": []interface{}{ map[string]interface{}{ "type": "tool_use", "name": "test_function", "input": map[string]interface{}{"arg": "value"}, }, }, } geminiResp, err := ClaudeResponseToGemini(claudeResp) assert.NoError(t, err) assert.NotNil(t, geminiResp) assert.Len(t, geminiResp.Candidates, 1) assert.NotNil(t, geminiResp.Candidates[0].Content) assert.Len(t, geminiResp.Candidates[0].Content.Parts, 1) assert.NotNil(t, geminiResp.Candidates[0].Content.Parts[0].FunctionCall) assert.Equal(t, "skip_thought_signature_validator", geminiResp.Candidates[0].Content.Parts[0].FunctionCall.ThoughtSignature) }) t.Run("空 signature 使用 dummy", func(t *testing.T) { // 测试场景 3: Claude 响应包含空 signature claudeResp := map[string]interface{}{ "content": []interface{}{ map[string]interface{}{ "type": "tool_use", "name": "test_function", "input": map[string]interface{}{"arg": "value"}, "signature": "", }, }, } geminiResp, err := ClaudeResponseToGemini(claudeResp) assert.NoError(t, err) assert.Equal(t, "skip_thought_signature_validator", geminiResp.Candidates[0].Content.Parts[0].FunctionCall.ThoughtSignature) }) } // TestOpenAIResponseToGemini_WithThoughtSignature 测试 OpenAI 响应转换时 thought_signature 的处理 func TestOpenAIResponseToGemini_WithThoughtSignature(t *testing.T) { t.Run("统一使用 dummy signature", func(t *testing.T) { openaiResp := map[string]interface{}{ "choices": []interface{}{ map[string]interface{}{ "message": map[string]interface{}{ "tool_calls": []interface{}{ map[string]interface{}{ "function": map[string]interface{}{ "name": "test_function", "arguments": `{"arg":"value"}`, }, }, }, }, }, }, } geminiResp, err := OpenAIResponseToGemini(openaiResp) assert.NoError(t, err) assert.NotNil(t, geminiResp) assert.Len(t, geminiResp.Candidates, 1) assert.NotNil(t, geminiResp.Candidates[0].Content) assert.Len(t, geminiResp.Candidates[0].Content.Parts, 1) assert.NotNil(t, geminiResp.Candidates[0].Content.Parts[0].FunctionCall) assert.Equal(t, "skip_thought_signature_validator", geminiResp.Candidates[0].Content.Parts[0].FunctionCall.ThoughtSignature) }) t.Run("多个工具调用都包含 signature", func(t *testing.T) { openaiResp := map[string]interface{}{ "choices": []interface{}{ map[string]interface{}{ "message": map[string]interface{}{ "tool_calls": []interface{}{ map[string]interface{}{ "function": map[string]interface{}{ "name": "function1", "arguments": `{"arg1":"value1"}`, }, }, map[string]interface{}{ "function": map[string]interface{}{ "name": "function2", "arguments": `{"arg2":"value2"}`, }, }, }, }, }, }, } geminiResp, err := OpenAIResponseToGemini(openaiResp) assert.NoError(t, err) assert.Len(t, geminiResp.Candidates[0].Content.Parts, 2) // 验证所有工具调用都包含 dummy signature for _, part := range geminiResp.Candidates[0].Content.Parts { assert.NotNil(t, part.FunctionCall) assert.Equal(t, "skip_thought_signature_validator", part.FunctionCall.ThoughtSignature) } }) } ================================================ FILE: backend-go/internal/converters/openai_converter.go ================================================ package converters import ( "github.com/BenedictKing/claude-proxy/internal/session" "github.com/BenedictKing/claude-proxy/internal/types" ) // ============== OpenAI Chat Completions 转换器 ============== // OpenAIChatConverter 实现 Responses → OpenAI Chat Completions 转换 type OpenAIChatConverter struct{} // ToProviderRequest 将 Responses 请求转换为 OpenAI Chat Completions 格式 func (c *OpenAIChatConverter) ToProviderRequest(sess *session.Session, req *types.ResponsesRequest) (interface{}, error) { // 转换 messages messages, err := ResponsesToOpenAIChatMessages(sess, req.Input, req.Instructions) if err != nil { return nil, err } // 构建 OpenAI 请求 openaiReq := map[string]interface{}{ "model": req.Model, "messages": messages, "stream": req.Stream, } // 复制其他参数 if req.MaxTokens > 0 { openaiReq["max_tokens"] = req.MaxTokens } if req.Temperature > 0 { openaiReq["temperature"] = req.Temperature } if req.TopP > 0 { openaiReq["top_p"] = req.TopP } if req.FrequencyPenalty != 0 { openaiReq["frequency_penalty"] = req.FrequencyPenalty } if req.PresencePenalty != 0 { openaiReq["presence_penalty"] = req.PresencePenalty } if req.Stop != nil { openaiReq["stop"] = req.Stop } if req.User != "" { openaiReq["user"] = req.User } if req.StreamOptions != nil { openaiReq["stream_options"] = req.StreamOptions } return openaiReq, nil } // FromProviderResponse 将 OpenAI Chat 响应转换为 Responses 格式 func (c *OpenAIChatConverter) FromProviderResponse(resp map[string]interface{}, sessionID string) (*types.ResponsesResponse, error) { return OpenAIChatResponseToResponses(resp, sessionID) } // GetProviderName 获取上游服务名称 func (c *OpenAIChatConverter) GetProviderName() string { return "OpenAI Chat Completions" } // ============== OpenAI Completions 转换器 ============== // OpenAICompletionsConverter 实现 Responses → OpenAI Completions 转换 type OpenAICompletionsConverter struct{} // ToProviderRequest 将 Responses 请求转换为 OpenAI Completions 格式 func (c *OpenAICompletionsConverter) ToProviderRequest(sess *session.Session, req *types.ResponsesRequest) (interface{}, error) { // 提取纯文本(Completions API 不支持 messages) prompt, err := ExtractTextFromResponses(sess, req.Input) if err != nil { return nil, err } // 如果有 instructions,添加到 prompt 前面 if req.Instructions != "" { prompt = req.Instructions + "\n\n" + prompt } // 构建 OpenAI Completions 请求 completionsReq := map[string]interface{}{ "model": req.Model, "prompt": prompt, "stream": req.Stream, } // 复制其他参数 if req.MaxTokens > 0 { completionsReq["max_tokens"] = req.MaxTokens } if req.Temperature > 0 { completionsReq["temperature"] = req.Temperature } if req.TopP > 0 { completionsReq["top_p"] = req.TopP } if req.FrequencyPenalty != 0 { completionsReq["frequency_penalty"] = req.FrequencyPenalty } if req.PresencePenalty != 0 { completionsReq["presence_penalty"] = req.PresencePenalty } if req.Stop != nil { completionsReq["stop"] = req.Stop } if req.User != "" { completionsReq["user"] = req.User } return completionsReq, nil } // FromProviderResponse 将 OpenAI Completions 响应转换为 Responses 格式 func (c *OpenAICompletionsConverter) FromProviderResponse(resp map[string]interface{}, sessionID string) (*types.ResponsesResponse, error) { return OpenAICompletionsResponseToResponses(resp, sessionID) } // GetProviderName 获取上游服务名称 func (c *OpenAICompletionsConverter) GetProviderName() string { return "OpenAI Completions" } ================================================ FILE: backend-go/internal/converters/responses_converter.go ================================================ package converters import ( "encoding/json" "fmt" "strings" "github.com/BenedictKing/claude-proxy/internal/session" "github.com/BenedictKing/claude-proxy/internal/types" ) // ============== Responses → Claude Messages ============== // ResponsesToClaudeMessages 将 Responses 格式转换为 Claude Messages 格式 // instructions 参数会被转换为 Claude API 的 system 参数(不在 messages 中) func ResponsesToClaudeMessages(sess *session.Session, newInput interface{}, instructions string) ([]types.ClaudeMessage, string, error) { messages := []types.ClaudeMessage{} // 1. 处理历史消息 for _, item := range sess.Messages { msg, err := responsesItemToClaudeMessage(item) if err != nil { return nil, "", fmt.Errorf("转换历史消息失败: %w", err) } if msg != nil { messages = append(messages, *msg) } } // 2. 处理新输入 newItems, err := parseResponsesInput(newInput) if err != nil { return nil, "", err } for _, item := range newItems { msg, err := responsesItemToClaudeMessage(item) if err != nil { return nil, "", fmt.Errorf("转换新消息失败: %w", err) } if msg != nil { messages = append(messages, *msg) } } return messages, instructions, nil } // responsesItemToClaudeMessage 单个 ResponsesItem 转换为 Claude Message func responsesItemToClaudeMessage(item types.ResponsesItem) (*types.ClaudeMessage, error) { switch item.Type { case "message": // 新格式:嵌套结构(type=message, role=user/assistant, content=[]ContentBlock) role := item.Role if role == "" { role = "user" // 默认为 user } contentText := extractTextFromContent(item.Content) if contentText == "" { return nil, nil // 空内容,跳过 } return &types.ClaudeMessage{ Role: role, Content: []types.ClaudeContent{ { Type: "text", Text: contentText, }, }, }, nil case "text": // 旧格式:简单 string(向后兼容) contentStr := extractTextFromContent(item.Content) if contentStr == "" { return nil, fmt.Errorf("text 类型的 content 不能为空") } // 使用 item.Role(如果存在),否则默认为 user role := "user" if item.Role != "" { role = item.Role } return &types.ClaudeMessage{ Role: role, Content: []types.ClaudeContent{ { Type: "text", Text: contentStr, }, }, }, nil case "tool_call": // 工具调用(暂时简化处理) return nil, nil case "tool_result": // 工具结果(暂时简化处理) return nil, nil default: return nil, fmt.Errorf("未知的 item type: %s", item.Type) } } // ============== Claude Response → Responses ============== // ClaudeResponseToResponses 将 Claude 响应转换为 Responses 格式 func ClaudeResponseToResponses(claudeResp map[string]interface{}, sessionID string) (*types.ResponsesResponse, error) { // 提取字段 model, _ := claudeResp["model"].(string) content, _ := claudeResp["content"].([]interface{}) // 转换 output output := []types.ResponsesItem{} for _, c := range content { contentBlock, ok := c.(map[string]interface{}) if !ok { continue } blockType, _ := contentBlock["type"].(string) if blockType == "text" { text, _ := contentBlock["text"].(string) output = append(output, types.ResponsesItem{ Type: "text", Content: text, }) } } // 提取 usage(使用统一入口自动检测格式) usage := ExtractUsageMetrics(claudeResp["usage"]) // 生成 response ID responseID := generateResponseID() return &types.ResponsesResponse{ ID: responseID, Model: model, Output: output, Status: "completed", PreviousID: "", // 将在外部设置 Usage: usage, }, nil } // ============== Responses → OpenAI Chat ============== // ResponsesToOpenAIChatMessages 将 Responses 格式转换为 OpenAI Chat 格式 func ResponsesToOpenAIChatMessages(sess *session.Session, newInput interface{}, instructions string) ([]map[string]interface{}, error) { messages := []map[string]interface{}{} // 1. 处理 instructions(如果存在) if instructions != "" { messages = append(messages, map[string]interface{}{ "role": "system", "content": instructions, }) } // 2. 处理历史消息 for _, item := range sess.Messages { msg := responsesItemToOpenAIMessage(item) if msg != nil { messages = append(messages, msg) } } // 3. 处理新输入 newItems, err := parseResponsesInput(newInput) if err != nil { return nil, err } for _, item := range newItems { msg := responsesItemToOpenAIMessage(item) if msg != nil { messages = append(messages, msg) } } return messages, nil } // responsesItemToOpenAIMessage 单个 ResponsesItem 转换为 OpenAI Message func responsesItemToOpenAIMessage(item types.ResponsesItem) map[string]interface{} { switch item.Type { case "message": // 新格式:嵌套结构 role := item.Role if role == "" { role = "user" } contentText := extractTextFromContent(item.Content) if contentText == "" { return nil } return map[string]interface{}{ "role": role, "content": contentText, } case "text": // 旧格式:简单 string contentStr := extractTextFromContent(item.Content) if contentStr == "" { return nil } role := "user" if item.Role != "" { role = item.Role } return map[string]interface{}{ "role": role, "content": contentStr, } } return nil } // ============== OpenAI Chat Response → Responses ============== // OpenAIChatResponseToResponses 将 OpenAI Chat 响应转换为 Responses 格式 func OpenAIChatResponseToResponses(openaiResp map[string]interface{}, sessionID string) (*types.ResponsesResponse, error) { // 提取字段 model, _ := openaiResp["model"].(string) choices, _ := openaiResp["choices"].([]interface{}) // 提取第一个 choice 的 message output := []types.ResponsesItem{} if len(choices) > 0 { choice, ok := choices[0].(map[string]interface{}) if ok { message, _ := choice["message"].(map[string]interface{}) content, _ := message["content"].(string) output = append(output, types.ResponsesItem{ Type: "text", Content: content, }) } } // 提取 usage(使用统一入口自动检测格式) usage := ExtractUsageMetrics(openaiResp["usage"]) // 生成 response ID responseID := generateResponseID() return &types.ResponsesResponse{ ID: responseID, Model: model, Output: output, Status: "completed", PreviousID: "", Usage: usage, }, nil } // ============== 工具函数 ============== // extractTextFromContent 从 content 中提取文本内容 // 支持三种格式: // 1. string - 直接返回 // 2. []ContentBlock - 提取 input_text/output_text 类型的 text 字段 // 3. []interface{} - 动态解析为 ContentBlock func extractTextFromContent(content interface{}) string { // 1. 如果是 string,直接返回 if str, ok := content.(string); ok { return str } // 2. 如果是 []ContentBlock(已解析类型) if blocks, ok := content.([]types.ContentBlock); ok { texts := []string{} for _, block := range blocks { if block.Type == "input_text" || block.Type == "output_text" { texts = append(texts, block.Text) } } return strings.Join(texts, "\n") } // 3. 如果是 []interface{}(未解析类型) if arr, ok := content.([]interface{}); ok { texts := []string{} for _, c := range arr { if block, ok := c.(map[string]interface{}); ok { blockType, _ := block["type"].(string) if blockType == "input_text" || blockType == "output_text" { if text, ok := block["text"].(string); ok { texts = append(texts, text) } } } } return strings.Join(texts, "\n") } return "" } // parseResponsesInput 解析 input 字段(可能是 string 或 []ResponsesItem) func parseResponsesInput(input interface{}) ([]types.ResponsesItem, error) { switch v := input.(type) { case string: // 简单文本输入 return []types.ResponsesItem{ { Type: "text", Content: v, }, }, nil case []interface{}: // 数组输入 items := []types.ResponsesItem{} for _, item := range v { itemMap, ok := item.(map[string]interface{}) if !ok { continue } itemType, _ := itemMap["type"].(string) content := itemMap["content"] items = append(items, types.ResponsesItem{ Type: itemType, Content: content, }) } return items, nil case []types.ResponsesItem: // 已经是正确类型 return v, nil default: return nil, fmt.Errorf("不支持的 input 类型: %T", input) } } // generateResponseID 生成响应ID func generateResponseID() string { return fmt.Sprintf("resp_%d", getCurrentTimestamp()) } // getCurrentTimestamp 获取当前时间戳(毫秒) func getCurrentTimestamp() int64 { return 0 // 占位符,实际应使用 time.Now().UnixNano() / 1e6 } // ExtractTextFromResponses 从 Responses 消息中提取纯文本(用于 OpenAI Completions) func ExtractTextFromResponses(sess *session.Session, newInput interface{}) (string, error) { texts := []string{} // 历史消息 for _, item := range sess.Messages { if item.Type == "text" { if text, ok := item.Content.(string); ok { texts = append(texts, text) } } } // 新输入 newItems, err := parseResponsesInput(newInput) if err != nil { return "", err } for _, item := range newItems { if item.Type == "text" { if text, ok := item.Content.(string); ok { texts = append(texts, text) } } } return strings.Join(texts, "\n"), nil } // OpenAICompletionsResponseToResponses OpenAI Completions 响应转 Responses func OpenAICompletionsResponseToResponses(completionsResp map[string]interface{}, sessionID string) (*types.ResponsesResponse, error) { model, _ := completionsResp["model"].(string) choices, _ := completionsResp["choices"].([]interface{}) output := []types.ResponsesItem{} if len(choices) > 0 { choice, ok := choices[0].(map[string]interface{}) if ok { text, _ := choice["text"].(string) output = append(output, types.ResponsesItem{ Type: "text", Content: text, }) } } // 提取 usage(使用统一入口自动检测格式) usage := ExtractUsageMetrics(completionsResp["usage"]) responseID := generateResponseID() return &types.ResponsesResponse{ ID: responseID, Model: model, Output: output, Status: "completed", PreviousID: "", Usage: usage, }, nil } // JSONToMap 将 JSON 字节转为 map func JSONToMap(data []byte) (map[string]interface{}, error) { var result map[string]interface{} err := json.Unmarshal(data, &result) return result, err } // getIntFromMap 从 map 中安全提取整数值 // 支持 float64(JSON 反序列化)和 int/int64(内部构造)两种类型 func getIntFromMap(m map[string]interface{}, key string) (int, bool) { v, exists := m[key] if !exists { return 0, false } switch val := v.(type) { case float64: return int(val), true case int: return val, true case int64: return int(val), true case int32: return int(val), true default: return 0, false } } // parseResponsesUsage 解析 Responses API 的 usage 字段 // 完整支持 OpenAI Responses API 的详细 usage 结构 func parseResponsesUsage(usageRaw interface{}) types.ResponsesUsage { usage := types.ResponsesUsage{} usageMap, ok := usageRaw.(map[string]interface{}) if !ok { return usage } // 解析基础字段(兼容两种命名风格) // OpenAI Responses API: input_tokens / output_tokens // OpenAI Chat API: prompt_tokens / completion_tokens if v, ok := getIntFromMap(usageMap, "input_tokens"); ok { usage.InputTokens = v } else if v, ok := getIntFromMap(usageMap, "prompt_tokens"); ok { usage.InputTokens = v } if v, ok := getIntFromMap(usageMap, "output_tokens"); ok { usage.OutputTokens = v } else if v, ok := getIntFromMap(usageMap, "completion_tokens"); ok { usage.OutputTokens = v } if v, ok := getIntFromMap(usageMap, "total_tokens"); ok { usage.TotalTokens = v } else { usage.TotalTokens = usage.InputTokens + usage.OutputTokens } // 解析 input_tokens_details(兼容 prompt_tokens_details) inputDetailsRaw := usageMap["input_tokens_details"] if inputDetailsRaw == nil { inputDetailsRaw = usageMap["prompt_tokens_details"] } if detailsMap, ok := inputDetailsRaw.(map[string]interface{}); ok { usage.InputTokensDetails = &types.InputTokensDetails{} if v, ok := getIntFromMap(detailsMap, "cached_tokens"); ok { usage.InputTokensDetails.CachedTokens = v } } // 解析 output_tokens_details(兼容 completion_tokens_details) outputDetailsRaw := usageMap["output_tokens_details"] if outputDetailsRaw == nil { outputDetailsRaw = usageMap["completion_tokens_details"] } if detailsMap, ok := outputDetailsRaw.(map[string]interface{}); ok { usage.OutputTokensDetails = &types.OutputTokensDetails{} if v, ok := getIntFromMap(detailsMap, "reasoning_tokens"); ok { usage.OutputTokensDetails.ReasoningTokens = v } } return usage } // parseClaudeUsage 解析 Claude API 的 usage 字段 // 完整支持 Claude 的缓存统计,包括 TTL 细分 (5m/1h) // 参考 claude-code-hub 的 extractUsageMetrics 实现 func parseClaudeUsage(usageRaw interface{}) types.ResponsesUsage { usage := types.ResponsesUsage{} usageMap, ok := usageRaw.(map[string]interface{}) if !ok { return usage } // 基础字段 if v, ok := getIntFromMap(usageMap, "input_tokens"); ok { usage.InputTokens = v } if v, ok := getIntFromMap(usageMap, "output_tokens"); ok { usage.OutputTokens = v } usage.TotalTokens = usage.InputTokens + usage.OutputTokens // Claude 缓存创建统计(区分 TTL) var cacheCreation, cacheCreation5m, cacheCreation1h int var has5m, has1h bool // 总缓存创建量 if v, ok := getIntFromMap(usageMap, "cache_creation_input_tokens"); ok { cacheCreation = v usage.CacheCreationInputTokens = cacheCreation } // 5分钟 TTL 缓存创建 if v, ok := getIntFromMap(usageMap, "cache_creation_5m_input_tokens"); ok { cacheCreation5m = v usage.CacheCreation5mInputTokens = cacheCreation5m has5m = cacheCreation5m > 0 } // 1小时 TTL 缓存创建 if v, ok := getIntFromMap(usageMap, "cache_creation_1h_input_tokens"); ok { cacheCreation1h = v usage.CacheCreation1hInputTokens = cacheCreation1h has1h = cacheCreation1h > 0 } // 缓存读取 var cacheRead int if v, ok := getIntFromMap(usageMap, "cache_read_input_tokens"); ok { cacheRead = v usage.CacheReadInputTokens = cacheRead } // 设置缓存 TTL 标识 if has5m && has1h { usage.CacheTTL = "mixed" } else if has1h { usage.CacheTTL = "1h" } else if has5m { usage.CacheTTL = "5m" } // 同时设置 InputTokensDetails(兼容 OpenAI 格式) // CachedTokens = cache_read(仅缓存读取,不包含缓存创建) // 注意:cache_creation 是新创建的缓存,不是"已缓存的 token" if cacheRead > 0 { usage.InputTokensDetails = &types.InputTokensDetails{ CachedTokens: cacheRead, } } return usage } // parseGeminiUsage 解析 Gemini API 的 usage 字段 // Gemini 使用 promptTokenCount/candidatesTokenCount,需要特殊处理缓存去重 // 参考 claude-code-hub: Gemini 的 promptTokenCount 已包含 cachedContentTokenCount,需要扣除避免重复计费 func parseGeminiUsage(usageRaw interface{}) types.ResponsesUsage { usage := types.ResponsesUsage{} usageMap, ok := usageRaw.(map[string]interface{}) if !ok { return usage } var promptTokens, cachedTokens, outputTokens int // Gemini 字段名 if v, ok := getIntFromMap(usageMap, "promptTokenCount"); ok { promptTokens = v } if v, ok := getIntFromMap(usageMap, "cachedContentTokenCount"); ok { cachedTokens = v } if v, ok := getIntFromMap(usageMap, "candidatesTokenCount"); ok { outputTokens = v } // 关键处理:Gemini 的 promptTokenCount 已包含 cachedContentTokenCount // 为避免重复计费,实际输入 token = promptTokenCount - cachedContentTokenCount actualInputTokens := promptTokens - cachedTokens if actualInputTokens < 0 { actualInputTokens = 0 } usage.InputTokens = actualInputTokens usage.OutputTokens = outputTokens usage.TotalTokens = actualInputTokens + outputTokens // 缓存读取统计 if cachedTokens > 0 { usage.CacheReadInputTokens = cachedTokens usage.InputTokensDetails = &types.InputTokensDetails{ CachedTokens: cachedTokens, } } return usage } // ExtractUsageMetrics 多格式 Token 提取统一入口 // 自动检测并解析 Claude/Gemini/OpenAI 三种格式的 usage // 参考 claude-code-hub 的 extractUsageMetrics 实现 func ExtractUsageMetrics(usageRaw interface{}) types.ResponsesUsage { usageMap, ok := usageRaw.(map[string]interface{}) if !ok { return types.ResponsesUsage{} } // 1. 检测 Claude 格式:有 cache_creation_input_tokens 或 cache_read_input_tokens if _, hasCacheCreation := usageMap["cache_creation_input_tokens"]; hasCacheCreation { return parseClaudeUsage(usageRaw) } if _, hasCacheRead := usageMap["cache_read_input_tokens"]; hasCacheRead { return parseClaudeUsage(usageRaw) } // 2. 检测 Gemini 格式:有 promptTokenCount if _, hasPromptTokenCount := usageMap["promptTokenCount"]; hasPromptTokenCount { return parseGeminiUsage(usageRaw) } // 3. 默认 OpenAI 格式 return parseResponsesUsage(usageRaw) } ================================================ FILE: backend-go/internal/converters/responses_passthrough.go ================================================ package converters import ( "github.com/BenedictKing/claude-proxy/internal/session" "github.com/BenedictKing/claude-proxy/internal/types" ) // ============== Responses 透传转换器 ============== // ResponsesPassthroughConverter 实现 Responses → Responses 透传 // 用于上游服务本身就是 Responses API 的场景 type ResponsesPassthroughConverter struct{} // ToProviderRequest 透传 Responses 请求(不做转换) func (c *ResponsesPassthroughConverter) ToProviderRequest(sess *session.Session, req *types.ResponsesRequest) (interface{}, error) { // 直接返回原始请求 return map[string]interface{}{ "model": req.Model, "instructions": req.Instructions, "input": req.Input, "previous_response_id": req.PreviousResponseID, "store": req.Store, "max_tokens": req.MaxTokens, "temperature": req.Temperature, "top_p": req.TopP, "frequency_penalty": req.FrequencyPenalty, "presence_penalty": req.PresencePenalty, "stream": req.Stream, "stop": req.Stop, "user": req.User, "stream_options": req.StreamOptions, }, nil } // FromProviderResponse 透传 Responses 响应(不做转换) func (c *ResponsesPassthroughConverter) FromProviderResponse(resp map[string]interface{}, sessionID string) (*types.ResponsesResponse, error) { // 直接解析为 ResponsesResponse // 注意:这里假设上游返回的就是标准 Responses 格式 id, _ := resp["id"].(string) model, _ := resp["model"].(string) status, _ := resp["status"].(string) previousID, _ := resp["previous_id"].(string) // 解析 output output := []types.ResponsesItem{} if outputArr, ok := resp["output"].([]interface{}); ok { for _, item := range outputArr { if itemMap, ok := item.(map[string]interface{}); ok { itemType, _ := itemMap["type"].(string) role, _ := itemMap["role"].(string) content := itemMap["content"] output = append(output, types.ResponsesItem{ Type: itemType, Role: role, Content: content, }) } } } // 解析 usage(使用统一入口自动检测格式:Claude/Gemini/OpenAI) usage := ExtractUsageMetrics(resp["usage"]) return &types.ResponsesResponse{ ID: id, Model: model, Output: output, Status: status, PreviousID: previousID, Usage: usage, }, nil } // GetProviderName 获取上游服务名称 func (c *ResponsesPassthroughConverter) GetProviderName() string { return "Responses API (Passthrough)" } ================================================ FILE: backend-go/internal/converters/responses_to_chat.go ================================================ package converters import ( "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) // ConvertResponsesToOpenAIChatRequest 将 OpenAI Responses 请求格式转换为 OpenAI Chat Completions 格式 // 转换内容包括: // 1. model 和 stream 配置 // 2. instructions → system message // 3. input 数组 → messages 数组 // 4. tools 定义转换 // 5. function_call 和 function_call_output 处理 // 6. 生成参数映射 (max_tokens, reasoning 等) // // 参数: // - modelName: 要使用的模型名称 // - inputRawJSON: Responses 格式的原始 JSON 请求 // - stream: 是否为流式请求 // // 返回: // - []byte: Chat Completions 格式的请求 JSON func ConvertResponsesToOpenAIChatRequest(modelName string, inputRawJSON []byte, stream bool) []byte { // 基础 Chat Completions 模板 out := `{"model":"","messages":[],"stream":false}` root := gjson.ParseBytes(inputRawJSON) // 设置 model out, _ = sjson.Set(out, "model", modelName) // 设置 stream out, _ = sjson.Set(out, "stream", stream) // 如果是流式请求,添加 stream_options 以获取 usage 信息 if stream { out, _ = sjson.Set(out, "stream_options.include_usage", true) } // 映射生成参数 if maxTokens := root.Get("max_output_tokens"); maxTokens.Exists() { out, _ = sjson.Set(out, "max_tokens", maxTokens.Int()) } if parallelToolCalls := root.Get("parallel_tool_calls"); parallelToolCalls.Exists() { out, _ = sjson.Set(out, "parallel_tool_calls", parallelToolCalls.Bool()) } if temperature := root.Get("temperature"); temperature.Exists() { out, _ = sjson.Set(out, "temperature", temperature.Float()) } if topP := root.Get("top_p"); topP.Exists() { out, _ = sjson.Set(out, "top_p", topP.Float()) } if user := root.Get("user"); user.Exists() { out, _ = sjson.Set(out, "user", user.String()) } // 转换 instructions → system message if instructions := root.Get("instructions"); instructions.Exists() && instructions.String() != "" { systemMessage := `{"role":"system","content":""}` systemMessage, _ = sjson.Set(systemMessage, "content", instructions.String()) out, _ = sjson.SetRaw(out, "messages.-1", systemMessage) } // 转换 input 数组 → messages if input := root.Get("input"); input.Exists() { if input.IsArray() { out = convertInputArrayToMessages(input, out) } else if input.Type == gjson.String { // 简单字符串输入 msg := `{"role":"user","content":""}` msg, _ = sjson.Set(msg, "content", input.String()) out, _ = sjson.SetRaw(out, "messages.-1", msg) } } // 转换 tools if tools := root.Get("tools"); tools.Exists() && tools.IsArray() { out = convertToolsToOpenAIFormat(tools, out) } // 转换 reasoning.effort → reasoning_effort if reasoningEffort := root.Get("reasoning.effort"); reasoningEffort.Exists() { effort := reasoningEffort.String() switch effort { case "none": out, _ = sjson.Set(out, "reasoning_effort", "none") case "auto": out, _ = sjson.Set(out, "reasoning_effort", "auto") case "minimal": out, _ = sjson.Set(out, "reasoning_effort", "low") case "low": out, _ = sjson.Set(out, "reasoning_effort", "low") case "medium": out, _ = sjson.Set(out, "reasoning_effort", "medium") case "high": out, _ = sjson.Set(out, "reasoning_effort", "high") case "xhigh": out, _ = sjson.Set(out, "reasoning_effort", "xhigh") default: out, _ = sjson.Set(out, "reasoning_effort", "auto") } } // 转换 tool_choice if toolChoice := root.Get("tool_choice"); toolChoice.Exists() { out, _ = sjson.Set(out, "tool_choice", toolChoice.String()) } return []byte(out) } // convertInputArrayToMessages 将 input 数组转换为 messages 数组 func convertInputArrayToMessages(input gjson.Result, out string) string { input.ForEach(func(_, item gjson.Result) bool { itemType := item.Get("type").String() // 如果没有 type 但有 role,则视为 message if itemType == "" && item.Get("role").String() != "" { itemType = "message" } switch itemType { case "message": out = convertMessageItem(item, out) case "function_call": out = convertFunctionCallItem(item, out) case "function_call_output": out = convertFunctionCallOutputItem(item, out) } return true }) return out } // convertMessageItem 转换 message 类型的 item func convertMessageItem(item gjson.Result, out string) string { role := item.Get("role").String() if role == "" { role = "user" } message := `{"role":"","content":""}` message, _ = sjson.Set(message, "role", role) content := item.Get("content") if content.Exists() { if content.IsArray() { // content 是数组,需要提取文本 var messageContent string var toolCalls []interface{} content.ForEach(func(_, contentItem gjson.Result) bool { contentType := contentItem.Get("type").String() if contentType == "" { contentType = "input_text" } switch contentType { case "input_text", "output_text", "text": text := contentItem.Get("text").String() if messageContent != "" { messageContent += "\n" + text } else { messageContent = text } } return true }) if messageContent != "" { message, _ = sjson.Set(message, "content", messageContent) } if len(toolCalls) > 0 { message, _ = sjson.Set(message, "tool_calls", toolCalls) } } else if content.Type == gjson.String { // content 是字符串 message, _ = sjson.Set(message, "content", content.String()) } } out, _ = sjson.SetRaw(out, "messages.-1", message) return out } // convertFunctionCallItem 转换 function_call 类型的 item func convertFunctionCallItem(item gjson.Result, out string) string { // function_call → assistant message with tool_calls assistantMessage := `{"role":"assistant","tool_calls":[]}` toolCall := `{"id":"","type":"function","function":{"name":"","arguments":""}}` if callID := item.Get("call_id"); callID.Exists() { toolCall, _ = sjson.Set(toolCall, "id", callID.String()) } if name := item.Get("name"); name.Exists() { toolCall, _ = sjson.Set(toolCall, "function.name", name.String()) } if arguments := item.Get("arguments"); arguments.Exists() { toolCall, _ = sjson.Set(toolCall, "function.arguments", arguments.String()) } assistantMessage, _ = sjson.SetRaw(assistantMessage, "tool_calls.0", toolCall) out, _ = sjson.SetRaw(out, "messages.-1", assistantMessage) return out } // convertFunctionCallOutputItem 转换 function_call_output 类型的 item func convertFunctionCallOutputItem(item gjson.Result, out string) string { // function_call_output → tool message toolMessage := `{"role":"tool","tool_call_id":"","content":""}` if callID := item.Get("call_id"); callID.Exists() { toolMessage, _ = sjson.Set(toolMessage, "tool_call_id", callID.String()) } if output := item.Get("output"); output.Exists() { toolMessage, _ = sjson.Set(toolMessage, "content", output.String()) } out, _ = sjson.SetRaw(out, "messages.-1", toolMessage) return out } // convertToolsToOpenAIFormat 将 Responses tools 转换为 OpenAI Chat Completions tools 格式 func convertToolsToOpenAIFormat(tools gjson.Result, out string) string { var chatCompletionsTools []interface{} tools.ForEach(func(_, tool gjson.Result) bool { chatTool := `{"type":"function","function":{}}` function := `{"name":"","description":"","parameters":{}}` if name := tool.Get("name"); name.Exists() { function, _ = sjson.Set(function, "name", name.String()) } if description := tool.Get("description"); description.Exists() { function, _ = sjson.Set(function, "description", description.String()) } if parameters := tool.Get("parameters"); parameters.Exists() { function, _ = sjson.SetRaw(function, "parameters", parameters.Raw) } chatTool, _ = sjson.SetRaw(chatTool, "function", function) chatCompletionsTools = append(chatCompletionsTools, gjson.Parse(chatTool).Value()) return true }) if len(chatCompletionsTools) > 0 { out, _ = sjson.Set(out, "tools", chatCompletionsTools) } return out } ================================================ FILE: backend-go/internal/handlers/channel_metrics_handler.go ================================================ package handlers import ( "strconv" "strings" "time" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/metrics" "github.com/BenedictKing/claude-proxy/internal/scheduler" "github.com/gin-gonic/gin" ) // GetChannelMetricsWithConfig 获取渠道指标(需要配置管理器来获取 baseURL 和 keys) func GetChannelMetricsWithConfig(metricsManager *metrics.MetricsManager, cfgManager *config.ConfigManager, isResponses bool) gin.HandlerFunc { return func(c *gin.Context) { cfg := cfgManager.GetConfig() var upstreams []config.UpstreamConfig if isResponses { upstreams = cfg.ResponsesUpstream } else { upstreams = cfg.Upstream } result := make([]gin.H, 0, len(upstreams)) for i, upstream := range upstreams { // 使用多 URL 聚合方法获取渠道指标(支持 failover 多端点场景) resp := metricsManager.ToResponseMultiURL(i, upstream.GetAllBaseURLs(), upstream.APIKeys, 0, upstream.HistoricalAPIKeys) item := gin.H{ "channelIndex": i, "channelName": upstream.Name, "requestCount": resp.RequestCount, "successCount": resp.SuccessCount, "failureCount": resp.FailureCount, "successRate": resp.SuccessRate, "errorRate": resp.ErrorRate, "consecutiveFailures": resp.ConsecutiveFailures, "latency": resp.Latency, "keyMetrics": resp.KeyMetrics, // 各 Key 的详细指标 "timeWindows": resp.TimeWindows, // 分时段统计 (15m, 1h, 6h, 24h) } if resp.LastSuccessAt != nil { item["lastSuccessAt"] = *resp.LastSuccessAt } if resp.LastFailureAt != nil { item["lastFailureAt"] = *resp.LastFailureAt } if resp.CircuitBrokenAt != nil { item["circuitBrokenAt"] = *resp.CircuitBrokenAt } result = append(result, item) } c.JSON(200, result) } } // GetAllKeyMetrics 获取所有 Key 的原始指标 func GetAllKeyMetrics(metricsManager *metrics.MetricsManager) gin.HandlerFunc { return func(c *gin.Context) { allMetrics := metricsManager.GetAllKeyMetrics() result := make([]gin.H, 0, len(allMetrics)) for _, m := range allMetrics { if m == nil { continue } successRate := float64(100) if m.RequestCount > 0 { successRate = float64(m.SuccessCount) / float64(m.RequestCount) * 100 } item := gin.H{ "metricsKey": m.MetricsKey, "baseUrl": m.BaseURL, "keyMask": m.KeyMask, "requestCount": m.RequestCount, "successCount": m.SuccessCount, "failureCount": m.FailureCount, "successRate": successRate, "consecutiveFailures": m.ConsecutiveFailures, } if m.LastSuccessAt != nil { item["lastSuccessAt"] = m.LastSuccessAt.Format("2006-01-02T15:04:05Z07:00") } if m.LastFailureAt != nil { item["lastFailureAt"] = m.LastFailureAt.Format("2006-01-02T15:04:05Z07:00") } if m.CircuitBrokenAt != nil { item["circuitBrokenAt"] = m.CircuitBrokenAt.Format("2006-01-02T15:04:05Z07:00") } result = append(result, item) } c.JSON(200, result) } } // GetChannelMetrics 获取渠道指标(兼容旧 API,返回空数据) // Deprecated: 使用 GetChannelMetricsWithConfig 代替 func GetChannelMetrics(metricsManager *metrics.MetricsManager) gin.HandlerFunc { return func(c *gin.Context) { // 返回所有 Key 的指标 allMetrics := metricsManager.GetAllKeyMetrics() result := make([]gin.H, 0, len(allMetrics)) for _, m := range allMetrics { if m == nil { continue } successRate := float64(100) if m.RequestCount > 0 { successRate = float64(m.SuccessCount) / float64(m.RequestCount) * 100 } item := gin.H{ "metricsKey": m.MetricsKey, "baseUrl": m.BaseURL, "keyMask": m.KeyMask, "requestCount": m.RequestCount, "successCount": m.SuccessCount, "failureCount": m.FailureCount, "successRate": successRate, "consecutiveFailures": m.ConsecutiveFailures, } if m.LastSuccessAt != nil { item["lastSuccessAt"] = m.LastSuccessAt.Format("2006-01-02T15:04:05Z07:00") } if m.LastFailureAt != nil { item["lastFailureAt"] = m.LastFailureAt.Format("2006-01-02T15:04:05Z07:00") } if m.CircuitBrokenAt != nil { item["circuitBrokenAt"] = m.CircuitBrokenAt.Format("2006-01-02T15:04:05Z07:00") } result = append(result, item) } c.JSON(200, result) } } // GetResponsesChannelMetrics 获取 Responses 渠道指标 // Deprecated: 使用 GetChannelMetricsWithConfig 代替 func GetResponsesChannelMetrics(metricsManager *metrics.MetricsManager) gin.HandlerFunc { return GetChannelMetrics(metricsManager) } // ResumeChannel 恢复熔断渠道(重置熔断状态,保留历史统计) // isResponses 参数指定是 Messages 渠道还是 Responses 渠道 func ResumeChannel(sch *scheduler.ChannelScheduler, isResponses bool) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid channel ID"}) return } // 重置渠道所有 Key 的熔断状态(保留历史统计) kind := scheduler.ChannelKindMessages if isResponses { kind = scheduler.ChannelKindResponses } sch.ResetChannelMetrics(id, kind) c.JSON(200, gin.H{ "success": true, "message": "渠道已恢复,熔断状态已重置(历史统计保留)", }) } } // GetSchedulerStats 获取调度器统计信息 func GetSchedulerStats(sch *scheduler.ChannelScheduler) gin.HandlerFunc { return func(c *gin.Context) { // 获取 isResponses 参数 isResponses := strings.ToLower(c.Query("type")) == "responses" kind := scheduler.ChannelKindMessages if isResponses { kind = scheduler.ChannelKindResponses } // 根据类型选择对应的指标管理器 var metricsManager *metrics.MetricsManager if isResponses { metricsManager = sch.GetResponsesMetricsManager() } else { metricsManager = sch.GetMessagesMetricsManager() } stats := gin.H{ "multiChannelMode": sch.IsMultiChannelMode(kind), "activeChannelCount": sch.GetActiveChannelCount(kind), "traceAffinityCount": sch.GetTraceAffinityManager().Size(), "traceAffinityTTL": sch.GetTraceAffinityManager().GetTTL().String(), "failureThreshold": metricsManager.GetFailureThreshold() * 100, "windowSize": metricsManager.GetWindowSize(), "circuitRecoveryTime": metricsManager.GetCircuitRecoveryTime().String(), } c.JSON(200, stats) } } // SetChannelPromotion 设置渠道促销期 // 促销期内的渠道会被优先选择,忽略 trace 亲和性 func SetChannelPromotion(cfgManager ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "无效的渠道 ID"}) return } var req struct { Duration int `json:"duration"` // 促销期时长(秒),0 表示清除 } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "无效的请求参数"}) return } // 调用配置管理器设置促销期 duration := time.Duration(req.Duration) * time.Second if err := cfgManager.SetChannelPromotion(id, duration); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } if req.Duration <= 0 { c.JSON(200, gin.H{ "success": true, "message": "渠道促销期已清除", }) } else { c.JSON(200, gin.H{ "success": true, "message": "渠道促销期已设置", "duration": req.Duration, }) } } } // SetResponsesChannelPromotion 设置 Responses 渠道促销期 func SetResponsesChannelPromotion(cfgManager ResponsesConfigManager) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "无效的渠道 ID"}) return } var req struct { Duration int `json:"duration"` // 促销期时长(秒),0 表示清除 } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "无效的请求参数"}) return } duration := time.Duration(req.Duration) * time.Second if err := cfgManager.SetResponsesChannelPromotion(id, duration); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } if req.Duration <= 0 { c.JSON(200, gin.H{ "success": true, "message": "Responses 渠道促销期已清除", }) } else { c.JSON(200, gin.H{ "success": true, "message": "Responses 渠道促销期已设置", "duration": req.Duration, }) } } } // ConfigManager 促销期配置管理接口 type ConfigManager interface { SetChannelPromotion(index int, duration time.Duration) error } // ResponsesConfigManager Responses 渠道促销期配置管理接口 type ResponsesConfigManager interface { SetResponsesChannelPromotion(index int, duration time.Duration) error } // MetricsHistoryResponse 历史指标响应 type MetricsHistoryResponse struct { ChannelIndex int `json:"channelIndex"` ChannelName string `json:"channelName"` DataPoints []metrics.HistoryDataPoint `json:"dataPoints"` } // GetChannelMetricsHistory 获取渠道指标历史数据(用于时间序列图表) // Query params: // - duration: 时间范围 (1h, 6h, 24h),默认 24h // - interval: 时间间隔 (5m, 15m, 1h),默认根据 duration 自动选择 func GetChannelMetricsHistory(metricsManager *metrics.MetricsManager, cfgManager *config.ConfigManager, isResponses bool) gin.HandlerFunc { return func(c *gin.Context) { // 解析 duration 参数 durationStr := c.DefaultQuery("duration", "24h") duration, err := time.ParseDuration(durationStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid duration parameter"}) return } // 限制最大查询范围为 24 小时 if duration > 24*time.Hour { duration = 24 * time.Hour } // 解析或自动选择 interval intervalStr := c.Query("interval") var interval time.Duration if intervalStr != "" { interval, err = time.ParseDuration(intervalStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid interval parameter"}) return } // 限制 interval 最小值为 1 分钟,防止生成过多 bucket if interval < time.Minute { interval = time.Minute } } else { // 根据 duration 自动选择合适的聚合粒度 // 目标:每个时间段约 60-100 个数据点,保持图表清晰 // 1h = 60 points (1m interval) // 6h = 72 points (5m interval) // 24h = 96 points (15m interval) switch { case duration <= time.Hour: interval = time.Minute case duration <= 6*time.Hour: interval = 5 * time.Minute default: interval = 15 * time.Minute } } cfg := cfgManager.GetConfig() var upstreams []config.UpstreamConfig if isResponses { upstreams = cfg.ResponsesUpstream } else { upstreams = cfg.Upstream } result := make([]MetricsHistoryResponse, 0, len(upstreams)) for i, upstream := range upstreams { // 使用多 URL 聚合方法获取历史数据(支持 failover 多端点场景) dataPoints := metricsManager.GetHistoricalStatsMultiURL(upstream.GetAllBaseURLs(), upstream.APIKeys, duration, interval) result = append(result, MetricsHistoryResponse{ ChannelIndex: i, ChannelName: upstream.Name, DataPoints: dataPoints, }) } c.JSON(200, result) } } // ChannelKeyMetricsHistoryResponse Key 级别历史指标响应 type ChannelKeyMetricsHistoryResponse struct { ChannelIndex int `json:"channelIndex"` ChannelName string `json:"channelName"` Keys []KeyMetricsHistoryResult `json:"keys"` } // KeyMetricsHistoryResult 单个 Key 的历史数据 type KeyMetricsHistoryResult struct { KeyMask string `json:"keyMask"` Color string `json:"color"` DataPoints []metrics.KeyHistoryDataPoint `json:"dataPoints"` } // Key 颜色配置(与前端一致) var keyColors = []string{ "#3b82f6", // Blue - Primary "#f97316", // Orange - Backup 1 "#10b981", // Emerald - Backup 2 "#8b5cf6", // Violet - Fallback "#ec4899", // Pink - Canary } // GetChannelKeyMetricsHistory 获取渠道下各 Key 的历史数据(用于 Key 趋势图表) // GET /api/channels/:id/keys/metrics/history?duration=6h func GetChannelKeyMetricsHistory(metricsManager *metrics.MetricsManager, cfgManager *config.ConfigManager, isResponses bool) gin.HandlerFunc { return func(c *gin.Context) { // 解析 duration 参数 durationStr := c.DefaultQuery("duration", "6h") var duration time.Duration var err error // 特殊处理 "today" 参数 if durationStr == "today" { duration = metrics.CalculateTodayDuration() // 如果刚过零点,duration 可能非常小,设置最小值 if duration < time.Minute { duration = time.Minute } } else { duration, err = time.ParseDuration(durationStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid duration parameter. Use: 1h, 6h, 24h, or today"}) return } } // 限制最大查询范围为 24 小时 if duration > 24*time.Hour { duration = 24 * time.Hour } // 解析或自动选择 interval intervalStr := c.Query("interval") var interval time.Duration if intervalStr != "" { interval, err = time.ParseDuration(intervalStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid interval parameter"}) return } // 限制 interval 最小值为 1 分钟,防止生成过多 bucket if interval < time.Minute { interval = time.Minute } } else { // 根据 duration 自动选择合适的聚合粒度 // 目标:每个时间段约 60-100 个数据点,保持图表清晰 // 1h = 60 points (1m interval) // 6h = 72 points (5m interval) // 24h = 96 points (15m interval) switch { case duration <= time.Hour: interval = time.Minute case duration <= 6*time.Hour: interval = 5 * time.Minute default: interval = 15 * time.Minute } } // 解析 channel ID channelIDStr := c.Param("id") channelID, err := strconv.Atoi(channelIDStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid channel ID"}) return } cfg := cfgManager.GetConfig() var upstreams []config.UpstreamConfig if isResponses { upstreams = cfg.ResponsesUpstream } else { upstreams = cfg.Upstream } // 检查 channel ID 是否有效 if channelID < 0 || channelID >= len(upstreams) { c.JSON(400, gin.H{"error": "Channel not found"}) return } upstream := upstreams[channelID] // 获取所有 Key 的使用信息并筛选(最多显示 10 个) const maxDisplayKeys = 10 // 使用多 URL 聚合方法获取 Key 使用信息(支持 failover 多端点场景) allKeyInfos := metricsManager.GetChannelKeyUsageInfoMultiURL(upstream.GetAllBaseURLs(), upstream.APIKeys) displayKeys := metrics.SelectTopKeys(allKeyInfos, maxDisplayKeys) // 构建响应 result := ChannelKeyMetricsHistoryResponse{ ChannelIndex: channelID, ChannelName: upstream.Name, Keys: make([]KeyMetricsHistoryResult, 0, len(displayKeys)), } // 为筛选后的 Key 获取历史数据 for i, keyInfo := range displayKeys { // 使用多 URL 聚合方法获取单个 Key 的历史数据(支持 failover 多端点场景) dataPoints := metricsManager.GetKeyHistoricalStatsMultiURL(upstream.GetAllBaseURLs(), keyInfo.APIKey, duration, interval) // 获取 Key 的颜色 color := keyColors[i%len(keyColors)] // 获取 Key 的脱敏显示(只取前 8 个字符) keyMask := truncateKeyMask(keyInfo.KeyMask, 8) result.Keys = append(result.Keys, KeyMetricsHistoryResult{ KeyMask: keyMask, Color: color, DataPoints: dataPoints, }) } c.JSON(200, result) } } // truncateKeyMask 截取 keyMask 的前 N 个字符 func truncateKeyMask(keyMask string, maxLen int) string { if len(keyMask) <= maxLen { return keyMask } return keyMask[:maxLen] } // GetChannelDashboard 获取渠道仪表盘数据(合并 channels + metrics + stats) // GET /api/channels/dashboard?type=messages|responses // 将原本需要 3 个请求的数据合并为 1 个请求,减少网络开销 func GetChannelDashboard(cfgManager *config.ConfigManager, sch *scheduler.ChannelScheduler) gin.HandlerFunc { return func(c *gin.Context) { // 获取 type 参数,默认为 messages isResponses := strings.ToLower(c.Query("type")) == "responses" kind := scheduler.ChannelKindMessages if isResponses { kind = scheduler.ChannelKindResponses } cfg := cfgManager.GetConfig() var upstreams []config.UpstreamConfig var loadBalance string var metricsManager *metrics.MetricsManager if isResponses { upstreams = cfg.ResponsesUpstream loadBalance = cfg.ResponsesLoadBalance metricsManager = sch.GetResponsesMetricsManager() } else { upstreams = cfg.Upstream loadBalance = cfg.LoadBalance metricsManager = sch.GetMessagesMetricsManager() } // 1. 构建 channels 数据 channels := make([]gin.H, len(upstreams)) for i, up := range upstreams { status := config.GetChannelStatus(&up) priority := config.GetChannelPriority(&up, i) channels[i] = gin.H{ "index": i, "name": up.Name, "serviceType": up.ServiceType, "baseUrl": up.BaseURL, "baseUrls": up.BaseURLs, "apiKeys": up.APIKeys, "description": up.Description, "website": up.Website, "insecureSkipVerify": up.InsecureSkipVerify, "modelMapping": up.ModelMapping, "latency": nil, "status": status, "priority": priority, "promotionUntil": up.PromotionUntil, "lowQuality": up.LowQuality, } } // 2. 构建 metrics 数据 metricsResult := make([]gin.H, 0, len(upstreams)) for i, upstream := range upstreams { resp := metricsManager.ToResponseMultiURL(i, upstream.GetAllBaseURLs(), upstream.APIKeys, 0, upstream.HistoricalAPIKeys) item := gin.H{ "channelIndex": i, "channelName": upstream.Name, "requestCount": resp.RequestCount, "successCount": resp.SuccessCount, "failureCount": resp.FailureCount, "successRate": resp.SuccessRate, "errorRate": resp.ErrorRate, "consecutiveFailures": resp.ConsecutiveFailures, "latency": resp.Latency, "keyMetrics": resp.KeyMetrics, "timeWindows": resp.TimeWindows, } if resp.LastSuccessAt != nil { item["lastSuccessAt"] = *resp.LastSuccessAt } if resp.LastFailureAt != nil { item["lastFailureAt"] = *resp.LastFailureAt } if resp.CircuitBrokenAt != nil { item["circuitBrokenAt"] = *resp.CircuitBrokenAt } metricsResult = append(metricsResult, item) } // 3. 构建 stats 数据 stats := gin.H{ "multiChannelMode": sch.IsMultiChannelMode(kind), "activeChannelCount": sch.GetActiveChannelCount(kind), "traceAffinityCount": sch.GetTraceAffinityManager().Size(), "traceAffinityTTL": sch.GetTraceAffinityManager().GetTTL().String(), "failureThreshold": metricsManager.GetFailureThreshold() * 100, "windowSize": metricsManager.GetWindowSize(), "circuitRecoveryTime": metricsManager.GetCircuitRecoveryTime().String(), } // 4. 构建 recentActivity 数据(最近 15 分钟分段活跃度) recentActivity := make([]*metrics.ChannelRecentActivity, len(upstreams)) for i, upstream := range upstreams { recentActivity[i] = metricsManager.GetRecentActivityMultiURL(i, upstream.GetAllBaseURLs(), upstream.APIKeys) } // 返回合并数据 c.JSON(200, gin.H{ "channels": channels, "loadBalance": loadBalance, "metrics": metricsResult, "stats": stats, "recentActivity": recentActivity, }) } } // GetGeminiChannelMetricsHistory 获取 Gemini 渠道指标历史数据(用于时间序列图表) // Query params: // - duration: 时间范围 (1h, 6h, 24h),默认 24h // - interval: 时间间隔 (5m, 15m, 1h),默认根据 duration 自动选择 func GetGeminiChannelMetricsHistory(metricsManager *metrics.MetricsManager, cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { // 解析 duration 参数 durationStr := c.DefaultQuery("duration", "24h") duration, err := time.ParseDuration(durationStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid duration parameter"}) return } // 限制最大查询范围为 24 小时 if duration > 24*time.Hour { duration = 24 * time.Hour } // 解析或自动选择 interval intervalStr := c.Query("interval") var interval time.Duration if intervalStr != "" { interval, err = time.ParseDuration(intervalStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid interval parameter"}) return } // 限制 interval 最小值为 1 分钟,防止生成过多 bucket if interval < time.Minute { interval = time.Minute } } else { // 根据 duration 自动选择合适的聚合粒度 switch { case duration <= time.Hour: interval = time.Minute case duration <= 6*time.Hour: interval = 5 * time.Minute default: interval = 15 * time.Minute } } cfg := cfgManager.GetConfig() upstreams := cfg.GeminiUpstream result := make([]MetricsHistoryResponse, 0, len(upstreams)) for i, upstream := range upstreams { // 使用多 URL 聚合方法获取历史数据(支持 failover 多端点场景) dataPoints := metricsManager.GetHistoricalStatsMultiURL(upstream.GetAllBaseURLs(), upstream.APIKeys, duration, interval) result = append(result, MetricsHistoryResponse{ ChannelIndex: i, ChannelName: upstream.Name, DataPoints: dataPoints, }) } c.JSON(200, result) } } // GetGeminiChannelKeyMetricsHistory 获取 Gemini 渠道下各 Key 的历史数据(用于 Key 趋势图表) // GET /api/gemini/channels/:id/keys/metrics/history?duration=6h func GetGeminiChannelKeyMetricsHistory(metricsManager *metrics.MetricsManager, cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { // 解析 duration 参数 durationStr := c.DefaultQuery("duration", "6h") var duration time.Duration var err error // 特殊处理 "today" 参数 if durationStr == "today" { duration = metrics.CalculateTodayDuration() // 如果刚过零点,duration 可能非常小,设置最小值 if duration < time.Minute { duration = time.Minute } } else { duration, err = time.ParseDuration(durationStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid duration parameter. Use: 1h, 6h, 24h, or today"}) return } } // 限制最大查询范围为 24 小时 if duration > 24*time.Hour { duration = 24 * time.Hour } // 解析或自动选择 interval intervalStr := c.Query("interval") var interval time.Duration if intervalStr != "" { interval, err = time.ParseDuration(intervalStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid interval parameter"}) return } // 限制 interval 最小值为 1 分钟,防止生成过多 bucket if interval < time.Minute { interval = time.Minute } } else { // 根据 duration 自动选择合适的聚合粒度 switch { case duration <= time.Hour: interval = time.Minute case duration <= 6*time.Hour: interval = 5 * time.Minute default: interval = 15 * time.Minute } } // 解析 channel ID channelIDStr := c.Param("id") channelID, err := strconv.Atoi(channelIDStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid channel ID"}) return } cfg := cfgManager.GetConfig() upstreams := cfg.GeminiUpstream // 检查 channel ID 是否有效 if channelID < 0 || channelID >= len(upstreams) { c.JSON(400, gin.H{"error": "Channel not found"}) return } upstream := upstreams[channelID] // 获取所有 Key 的使用信息并筛选(最多显示 10 个) const maxDisplayKeys = 10 // 使用多 URL 聚合方法获取 Key 使用信息(支持 failover 多端点场景) allKeyInfos := metricsManager.GetChannelKeyUsageInfoMultiURL(upstream.GetAllBaseURLs(), upstream.APIKeys) displayKeys := metrics.SelectTopKeys(allKeyInfos, maxDisplayKeys) // 构建响应 result := ChannelKeyMetricsHistoryResponse{ ChannelIndex: channelID, ChannelName: upstream.Name, Keys: make([]KeyMetricsHistoryResult, 0, len(displayKeys)), } // 为筛选后的 Key 获取历史数据 for i, keyInfo := range displayKeys { // 使用多 URL 聚合方法获取单个 Key 的历史数据(支持 failover 多端点场景) dataPoints := metricsManager.GetKeyHistoricalStatsMultiURL(upstream.GetAllBaseURLs(), keyInfo.APIKey, duration, interval) // 获取 Key 的颜色 color := keyColors[i%len(keyColors)] // 获取 Key 的脱敏显示(只取前 8 个字符) keyMask := truncateKeyMask(keyInfo.KeyMask, 8) result.Keys = append(result.Keys, KeyMetricsHistoryResult{ KeyMask: keyMask, Color: color, DataPoints: dataPoints, }) } c.JSON(200, result) } } // GetGeminiChannelMetrics 获取 Gemini 渠道指标 func GetGeminiChannelMetrics(metricsManager *metrics.MetricsManager, cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { cfg := cfgManager.GetConfig() upstreams := cfg.GeminiUpstream result := make([]gin.H, 0, len(upstreams)) for i, upstream := range upstreams { // 使用多 URL 聚合方法获取渠道指标(支持 failover 多端点场景) resp := metricsManager.ToResponseMultiURL(i, upstream.GetAllBaseURLs(), upstream.APIKeys, 0, upstream.HistoricalAPIKeys) item := gin.H{ "channelIndex": i, "channelName": upstream.Name, "requestCount": resp.RequestCount, "successCount": resp.SuccessCount, "failureCount": resp.FailureCount, "successRate": resp.SuccessRate, "errorRate": resp.ErrorRate, "consecutiveFailures": resp.ConsecutiveFailures, "latency": resp.Latency, "keyMetrics": resp.KeyMetrics, // 各 Key 的详细指标 "timeWindows": resp.TimeWindows, // 分时段统计 (15m, 1h, 6h, 24h) } if resp.LastSuccessAt != nil { item["lastSuccessAt"] = *resp.LastSuccessAt } if resp.LastFailureAt != nil { item["lastFailureAt"] = *resp.LastFailureAt } if resp.CircuitBrokenAt != nil { item["circuitBrokenAt"] = *resp.CircuitBrokenAt } result = append(result, item) } c.JSON(200, result) } } ================================================ FILE: backend-go/internal/handlers/common/client_error_test.go ================================================ package common import ( "context" "errors" "fmt" "testing" ) func TestIsClientSideError(t *testing.T) { tests := []struct { name string err error expected bool }{ { name: "nil error", err: nil, expected: false, }, { name: "context.Canceled", err: context.Canceled, expected: true, }, { name: "wrapped context.Canceled", err: fmt.Errorf("request failed: %w", context.Canceled), expected: true, }, { name: "deeply wrapped context.Canceled", err: fmt.Errorf("outer: %w", fmt.Errorf("inner: %w", context.Canceled)), expected: true, }, { name: "context.DeadlineExceeded - not client cancel", err: context.DeadlineExceeded, expected: false, // 可能是服务端超时 }, { name: "broken pipe - connection issue, should failover", err: errors.New("write tcp: broken pipe"), expected: false, // 连接故障,应继续 failover }, { name: "connection reset - connection issue, should failover", err: errors.New("read tcp: connection reset by peer"), expected: false, // 连接故障,应继续 failover }, { name: "EOF - upstream issue", err: errors.New("unexpected EOF"), expected: false, }, { name: "normal error", err: errors.New("upstream error: 500"), expected: false, }, { name: "network timeout", err: errors.New("dial tcp: i/o timeout"), expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := isClientSideError(tt.err) if result != tt.expected { t.Errorf("isClientSideError(%v) = %v, expected %v", tt.err, result, tt.expected) } }) } } ================================================ FILE: backend-go/internal/handlers/common/failover.go ================================================ // Package common 提供 handlers 模块的公共功能 package common import ( "encoding/json" "log" "strings" "github.com/gin-gonic/gin" ) // FailoverError 封装故障转移错误信息 type FailoverError struct { Status int Body []byte } // ShouldRetryWithNextKey 判断是否应该使用下一个密钥重试 // 返回: (shouldFailover bool, isQuotaRelated bool) // // apiType: 接口类型(Messages/Responses/Gemini),用于日志标签前缀 // fuzzyMode: 启用时,所有非 2xx 错误都触发 failover(模糊处理错误类型) // // HTTP 状态码分类策略(非 fuzzy 模式): // - 4xx 客户端错误:部分应触发 failover(密钥/配额问题) // - 5xx 服务端错误:应触发 failover(上游临时故障) // - 2xx/3xx:不应触发 failover(成功或重定向) // // isQuotaRelated 标记用于调度器优先级调整: // - true: 额度/配额相关,降低密钥优先级 // - false: 临时错误,不影响优先级 func ShouldRetryWithNextKey(statusCode int, bodyBytes []byte, fuzzyMode bool, apiType string) (bool, bool) { log.Printf("[%s-Failover-Entry] ShouldRetryWithNextKey 入口: statusCode=%d, bodyLen=%d, fuzzyMode=%v", apiType, statusCode, len(bodyBytes), fuzzyMode) if fuzzyMode { return shouldRetryWithNextKeyFuzzy(statusCode, bodyBytes, apiType) } return shouldRetryWithNextKeyNormal(statusCode, bodyBytes, apiType) } // shouldRetryWithNextKeyFuzzy Fuzzy 模式:所有非 2xx 错误都尝试 failover // 同时检查消息体中的配额相关关键词,确保 403 + "预扣费额度" 等情况能正确识别 // 但对于内容审核等不可重试错误,即使在 Fuzzy 模式下也不应重试 func shouldRetryWithNextKeyFuzzy(statusCode int, bodyBytes []byte, apiType string) (bool, bool) { log.Printf("[%s-Failover-Fuzzy] 进入 Fuzzy 模式处理: statusCode=%d, bodyLen=%d", apiType, statusCode, len(bodyBytes)) if statusCode >= 200 && statusCode < 300 { return false, false } // 检查是否为不可重试错误(内容审核等) if len(bodyBytes) > 0 { if isNonRetryableError(bodyBytes) { log.Printf("[%s-Failover-Fuzzy] 检测到不可重试错误,不进行 failover", apiType) return false, false } } // 状态码直接标记为配额相关 if statusCode == 402 || statusCode == 429 { log.Printf("[%s-Failover-Fuzzy] 状态码 %d 直接标记为配额相关", apiType, statusCode) return true, true } // 对于其他状态码,检查消息体是否包含配额相关关键词 // 这样 403 + "预扣费额度" 消息 → isQuotaRelated=true if len(bodyBytes) > 0 { _, msgQuota := classifyByErrorMessage(bodyBytes, apiType) if msgQuota { log.Printf("[%s-Failover-Fuzzy] 消息体包含配额相关关键词,标记为配额相关", apiType) return true, true } } log.Printf("[%s-Failover-Fuzzy] Fuzzy 模式结果: shouldFailover=true, isQuotaRelated=false", apiType) return true, false } // shouldRetryWithNextKeyNormal 原有的精确错误分类逻辑 func shouldRetryWithNextKeyNormal(statusCode int, bodyBytes []byte, apiType string) (bool, bool) { // 先检查是否为不可重试错误(内容审核等),这类错误无论状态码如何都不应重试 if len(bodyBytes) > 0 && isNonRetryableError(bodyBytes) { log.Printf("[%s-Failover-Debug] 检测到不可重试错误,不进行 failover", apiType) return false, false } shouldFailover, isQuotaRelated := classifyByStatusCode(statusCode) log.Printf("[%s-Failover-Debug] shouldRetryWithNextKeyNormal: statusCode=%d, bodyLen=%d, shouldFailover=%v, isQuotaRelated=%v", apiType, statusCode, len(bodyBytes), shouldFailover, isQuotaRelated) if shouldFailover { // 如果状态码已标记为 quota 相关,直接返回 if isQuotaRelated { return true, true } // 否则,仍检查消息体是否包含 quota 相关关键词 // 这样 403 + "预扣费额度" 消息 → isQuotaRelated=true log.Printf("[%s-Failover-Debug] 调用 classifyByErrorMessage, body=%s", apiType, string(bodyBytes)) _, msgQuota := classifyByErrorMessage(bodyBytes, apiType) log.Printf("[%s-Failover-Debug] classifyByErrorMessage 返回: msgQuota=%v", apiType, msgQuota) if msgQuota { return true, true } return true, false } // statusCode 不触发 failover 时,完全依赖消息体判断 return classifyByErrorMessage(bodyBytes, apiType) } // classifyByStatusCode 基于 HTTP 状态码分类 func classifyByStatusCode(statusCode int) (bool, bool) { switch { // 认证/授权错误 (应 failover,非配额相关) case statusCode == 401: return true, false case statusCode == 403: return true, false // 配额/计费错误 (应 failover,配额相关) case statusCode == 402: return true, true case statusCode == 429: return true, true // 超时错误 (应 failover,非配额相关) case statusCode == 408: return true, false // 需要检查消息体的状态码 (交给第二层判断) case statusCode == 400: return false, false // 请求错误 (不应 failover,客户端问题) case statusCode == 404, statusCode == 405, statusCode == 406, statusCode == 409, statusCode == 410, statusCode == 411, statusCode == 412, statusCode == 413, statusCode == 414, statusCode == 415, statusCode == 416, statusCode == 417, statusCode == 422, statusCode == 423, statusCode == 424, statusCode == 426, statusCode == 428, statusCode == 431, statusCode == 451: return false, false // 服务端错误 (应 failover,非配额相关) case statusCode >= 500: return true, false // 其他 4xx (保守处理,不 failover) case statusCode >= 400 && statusCode < 500: return false, false // 成功/重定向 (不应 failover) default: return false, false } } // classifyByErrorMessage 基于错误消息内容分类 func classifyByErrorMessage(bodyBytes []byte, apiType string) (bool, bool) { var errResp map[string]interface{} if err := json.Unmarshal(bodyBytes, &errResp); err != nil { log.Printf("[%s-Failover-Debug] JSON解析失败: %v, body长度=%d", apiType, err, len(bodyBytes)) return false, false } errObj, ok := errResp["error"].(map[string]interface{}) if !ok { log.Printf("[%s-Failover-Debug] 未找到error对象, keys=%v", apiType, getMapKeys(errResp)) return false, false } // 检查 error.code 字段,某些错误码不应重试(内容审核、无效请求等) if errCode, ok := errObj["code"].(string); ok { if isNonRetryableErrorCode(errCode) { log.Printf("[%s-Failover-Debug] 检测到不可重试错误码: %s", apiType, errCode) return false, false } } // 尝试多个可能的消息字段: message, upstream_error, detail messageFields := []string{"message", "upstream_error", "detail"} for _, field := range messageFields { if msg, ok := errObj[field].(string); ok { log.Printf("[%s-Failover-Debug] 提取到消息 (字段: %s): %s", apiType, field, msg) if failover, quota := classifyMessage(msg); failover { log.Printf("[%s-Failover-Debug] 消息分类结果: failover=%v, quota=%v", apiType, failover, quota) return true, quota } } } // 如果 upstream_error 是嵌套对象,尝试提取其中的消息 if upstreamErr, ok := errObj["upstream_error"].(map[string]interface{}); ok { if msg, ok := upstreamErr["message"].(string); ok { log.Printf("[%s-Failover-Debug] 提取到嵌套 upstream_error.message: %s", apiType, msg) if failover, quota := classifyMessage(msg); failover { log.Printf("[%s-Failover-Debug] 消息分类结果: failover=%v, quota=%v", apiType, failover, quota) return true, quota } } } // 检查 type 字段 if errType, ok := errObj["type"].(string); ok { if failover, quota := classifyErrorType(errType); failover { return true, quota } } log.Printf("[%s-Failover-Debug] 未匹配任何关键词, errObj keys=%v", apiType, getMapKeys(errObj)) return false, false } // classifyMessage 基于错误消息内容分类 func classifyMessage(msg string) (bool, bool) { msgLower := strings.ToLower(msg) // 配额/余额相关关键词 (failover + quota) quotaKeywords := []string{ "insufficient", "quota", "credit", "balance", "rate limit", "limit exceeded", "exceeded", "billing", "payment", "subscription", "积分不足", "余额不足", "请求数限制", "额度", "预扣费", } for _, keyword := range quotaKeywords { if strings.Contains(msgLower, keyword) { return true, true } } // 认证/授权相关关键词 (failover + 非 quota) authKeywords := []string{ "invalid", "unauthorized", "authentication", "api key", "apikey", "token", "expired", "permission", "forbidden", "denied", "密钥无效", "认证失败", "权限不足", } for _, keyword := range authKeywords { if strings.Contains(msgLower, keyword) { return true, false } } // 临时错误关键词 (failover + 非 quota) transientKeywords := []string{ "timeout", "timed out", "temporarily", "overloaded", "unavailable", "retry", "server error", "internal error", "超时", "暂时", "重试", } for _, keyword := range transientKeywords { if strings.Contains(msgLower, keyword) { return true, false } } return false, false } // classifyErrorType 基于错误类型分类 func classifyErrorType(errType string) (bool, bool) { typeLower := strings.ToLower(errType) // 配额相关的错误类型 (failover + quota) quotaTypes := []string{ "over_quota", "quota_exceeded", "rate_limit", "billing", "insufficient", "payment", } for _, t := range quotaTypes { if strings.Contains(typeLower, t) { return true, true } } // 认证相关的错误类型 (failover + 非 quota) authTypes := []string{ "authentication", "authorization", "permission", "invalid_api_key", "invalid_token", "expired", } for _, t := range authTypes { if strings.Contains(typeLower, t) { return true, false } } // 服务端错误类型 (failover + 非 quota) serverTypes := []string{ "server_error", "internal_error", "service_unavailable", "timeout", "overloaded", } for _, t := range serverTypes { if strings.Contains(typeLower, t) { return true, false } } return false, false } // HandleAllChannelsFailed 处理所有渠道都失败的情况 // fuzzyMode: 是否启用模糊模式(返回通用错误) // lastFailoverError: 最后一个故障转移错误 // lastError: 最后一个错误 // apiType: API 类型(用于错误消息) func HandleAllChannelsFailed(c *gin.Context, fuzzyMode bool, lastFailoverError *FailoverError, lastError error, apiType string) { // Fuzzy 模式下返回通用错误,不透传上游详情 if fuzzyMode { c.JSON(503, gin.H{ "type": "error", "error": gin.H{ "type": "service_unavailable", "message": "All upstream channels are currently unavailable", }, }) return } // 非 Fuzzy 模式:透传最后一个错误的详情 if lastFailoverError != nil { status := lastFailoverError.Status if status == 0 { status = 503 } var errBody map[string]interface{} if err := json.Unmarshal(lastFailoverError.Body, &errBody); err == nil { c.JSON(status, errBody) } else { c.JSON(status, gin.H{"error": string(lastFailoverError.Body)}) } } else { errMsg := "所有渠道都不可用" if lastError != nil { errMsg = lastError.Error() } c.JSON(503, gin.H{ "error": "所有" + apiType + "渠道都不可用", "details": errMsg, }) } } // HandleAllKeysFailed 处理所有密钥都失败的情况(单渠道模式) func HandleAllKeysFailed(c *gin.Context, fuzzyMode bool, lastFailoverError *FailoverError, lastError error, apiType string) { // Fuzzy 模式下返回通用错误 if fuzzyMode { c.JSON(503, gin.H{ "type": "error", "error": gin.H{ "type": "service_unavailable", "message": "All upstream channels are currently unavailable", }, }) return } // 非 Fuzzy 模式:透传最后一个错误的详情 if lastFailoverError != nil { status := lastFailoverError.Status if status == 0 { status = 500 } var errBody map[string]interface{} if err := json.Unmarshal(lastFailoverError.Body, &errBody); err == nil { c.JSON(status, errBody) } else { c.JSON(status, gin.H{"error": string(lastFailoverError.Body)}) } } else { errMsg := "未知错误" if lastError != nil { errMsg = lastError.Error() } c.JSON(500, gin.H{ "error": "所有上游" + apiType + "API密钥都不可用", "details": errMsg, }) } } // getMapKeys 获取 map 的所有 key(用于调试日志) func getMapKeys(m map[string]interface{}) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } return keys } // isNonRetryableErrorCode 判断错误码是否不应重试 // 这些错误与请求内容相关,换 Key 重试不会改变结果 func isNonRetryableErrorCode(code string) bool { nonRetryableCodes := []string{ // 内容审核相关 "sensitive_words_detected", "content_policy_violation", "content_filter", "content_blocked", "moderation_blocked", // 请求内容无效 "invalid_request", "invalid_request_error", "bad_request", } codeLower := strings.ToLower(code) for _, c := range nonRetryableCodes { if codeLower == c { return true } } return false } // isNonRetryableError 检查响应体是否包含不可重试的错误码 func isNonRetryableError(bodyBytes []byte) bool { var errResp map[string]interface{} if err := json.Unmarshal(bodyBytes, &errResp); err != nil { return false } errObj, ok := errResp["error"].(map[string]interface{}) if !ok { return false } if errCode, ok := errObj["code"].(string); ok { return isNonRetryableErrorCode(errCode) } return false } ================================================ FILE: backend-go/internal/handlers/common/failover_test.go ================================================ package common import ( "encoding/json" "testing" ) // TestClassifyByStatusCode 测试基于状态码的分类 func TestClassifyByStatusCode(t *testing.T) { tests := []struct { name string statusCode int wantFailover bool wantQuota bool }{ // 认证/授权错误 {"401 Unauthorized", 401, true, false}, {"403 Forbidden", 403, true, false}, // 配额/计费错误 {"402 Payment Required", 402, true, true}, {"429 Too Many Requests", 429, true, true}, // 超时错误 {"408 Request Timeout", 408, true, false}, // 服务端错误 {"500 Internal Server Error", 500, true, false}, {"502 Bad Gateway", 502, true, false}, {"503 Service Unavailable", 503, true, false}, {"504 Gateway Timeout", 504, true, false}, // 不应 failover 的客户端错误 {"400 Bad Request", 400, false, false}, {"404 Not Found", 404, false, false}, {"405 Method Not Allowed", 405, false, false}, {"413 Payload Too Large", 413, false, false}, {"422 Unprocessable Entity", 422, false, false}, // 成功状态码 {"200 OK", 200, false, false}, {"201 Created", 201, false, false}, {"204 No Content", 204, false, false}, // 重定向 {"301 Moved Permanently", 301, false, false}, {"302 Found", 302, false, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotFailover, gotQuota := classifyByStatusCode(tt.statusCode) if gotFailover != tt.wantFailover { t.Errorf("classifyByStatusCode(%d) failover = %v, want %v", tt.statusCode, gotFailover, tt.wantFailover) } if gotQuota != tt.wantQuota { t.Errorf("classifyByStatusCode(%d) quota = %v, want %v", tt.statusCode, gotQuota, tt.wantQuota) } }) } } // TestClassifyMessage 测试基于错误消息的分类 func TestClassifyMessage(t *testing.T) { tests := []struct { name string message string wantFailover bool wantQuota bool }{ // 配额相关 {"insufficient credits", "You have insufficient credits", true, true}, {"quota exceeded", "API quota exceeded for this month", true, true}, {"rate limit", "Rate limit exceeded, please retry later", true, true}, {"balance", "Account balance is zero", true, true}, {"billing", "Billing issue detected", true, true}, {"中文-积分不足", "您的积分不足,请充值", true, true}, {"中文-余额不足", "账户余额不足", true, true}, {"中文-请求数限制", "已达到请求数限制", true, true}, // 认证相关 {"invalid api key", "Invalid API key provided", true, false}, {"unauthorized", "Unauthorized access", true, false}, {"token expired", "Your token has expired", true, false}, {"permission denied", "Permission denied for this resource", true, false}, {"中文-密钥无效", "密钥无效,请检查", true, false}, // 临时错误 {"timeout", "Request timeout, please retry", true, false}, {"server overloaded", "Server is overloaded", true, false}, {"temporarily unavailable", "Service temporarily unavailable", true, false}, {"中文-超时", "请求超时", true, false}, // 不应 failover {"normal error", "Something went wrong", false, false}, {"validation error", "Field 'name' is required", false, false}, {"empty message", "", false, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotFailover, gotQuota := classifyMessage(tt.message) if gotFailover != tt.wantFailover { t.Errorf("classifyMessage(%q) failover = %v, want %v", tt.message, gotFailover, tt.wantFailover) } if gotQuota != tt.wantQuota { t.Errorf("classifyMessage(%q) quota = %v, want %v", tt.message, gotQuota, tt.wantQuota) } }) } } // TestClassifyErrorType 测试基于错误类型的分类 func TestClassifyErrorType(t *testing.T) { tests := []struct { name string errType string wantFailover bool wantQuota bool }{ // 配额相关 {"over_quota", "over_quota", true, true}, {"quota_exceeded", "quota_exceeded", true, true}, {"rate_limit_exceeded", "rate_limit_exceeded", true, true}, {"billing_error", "billing_error", true, true}, {"insufficient_funds", "insufficient_funds", true, true}, // 认证相关 {"authentication_error", "authentication_error", true, false}, {"invalid_api_key", "invalid_api_key", true, false}, {"permission_denied", "permission_denied", true, false}, // 服务端错误 {"server_error", "server_error", true, false}, {"internal_error", "internal_error", true, false}, {"service_unavailable", "service_unavailable", true, false}, // 不应 failover {"invalid_request", "invalid_request", false, false}, {"validation_error", "validation_error", false, false}, {"unknown_error", "unknown_error", false, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotFailover, gotQuota := classifyErrorType(tt.errType) if gotFailover != tt.wantFailover { t.Errorf("classifyErrorType(%q) failover = %v, want %v", tt.errType, gotFailover, tt.wantFailover) } if gotQuota != tt.wantQuota { t.Errorf("classifyErrorType(%q) quota = %v, want %v", tt.errType, gotQuota, tt.wantQuota) } }) } } // TestClassifyByErrorMessage 测试基于响应体的分类 func TestClassifyByErrorMessage(t *testing.T) { tests := []struct { name string body map[string]interface{} wantFailover bool wantQuota bool }{ { name: "quota error in message", body: map[string]interface{}{ "error": map[string]interface{}{ "message": "You have exceeded your quota", "type": "error", }, }, wantFailover: true, wantQuota: true, }, { name: "auth error in message", body: map[string]interface{}{ "error": map[string]interface{}{ "message": "Invalid API key", "type": "error", }, }, wantFailover: true, wantQuota: false, }, { name: "quota error in type", body: map[string]interface{}{ "error": map[string]interface{}{ "message": "Error occurred", "type": "over_quota", }, }, wantFailover: true, wantQuota: true, }, { name: "server error in type", body: map[string]interface{}{ "error": map[string]interface{}{ "message": "Error occurred", "type": "server_error", }, }, wantFailover: true, wantQuota: false, }, { name: "no failover keywords", body: map[string]interface{}{ "error": map[string]interface{}{ "message": "Bad request format", "type": "invalid_request", }, }, wantFailover: false, wantQuota: false, }, { name: "empty body", body: map[string]interface{}{}, wantFailover: false, wantQuota: false, }, { name: "no error field", body: map[string]interface{}{ "status": "error", }, wantFailover: false, wantQuota: false, }, // upstream_error 字段支持(Responses API 错误格式) { name: "upstream_error string field - auth error", body: map[string]interface{}{ "error": map[string]interface{}{ "type": "upstream_error", "upstream_error": "Invalid API key provided", }, }, wantFailover: true, wantQuota: false, }, { name: "upstream_error string field - quota error", body: map[string]interface{}{ "error": map[string]interface{}{ "type": "upstream_error", "upstream_error": "Rate limit exceeded, please retry later", }, }, wantFailover: true, wantQuota: true, }, { name: "upstream_error nested object with message", body: map[string]interface{}{ "error": map[string]interface{}{ "type": "upstream_error", "upstream_error": map[string]interface{}{ "message": "Insufficient credits", }, }, }, wantFailover: true, wantQuota: true, }, { name: "detail field - auth error", body: map[string]interface{}{ "error": map[string]interface{}{ "type": "error", "detail": "Token expired, please refresh", }, }, wantFailover: true, wantQuota: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { bodyBytes, _ := json.Marshal(tt.body) gotFailover, gotQuota := classifyByErrorMessage(bodyBytes, "Messages") if gotFailover != tt.wantFailover { t.Errorf("classifyByErrorMessage() failover = %v, want %v", gotFailover, tt.wantFailover) } if gotQuota != tt.wantQuota { t.Errorf("classifyByErrorMessage() quota = %v, want %v", gotQuota, tt.wantQuota) } }) } } // TestClassifyByErrorMessage_InvalidJSON 测试无效 JSON 的处理 func TestClassifyByErrorMessage_InvalidJSON(t *testing.T) { invalidBodies := [][]byte{ []byte("not json"), []byte("{invalid}"), []byte(""), nil, } for _, body := range invalidBodies { gotFailover, gotQuota := classifyByErrorMessage(body, "Messages") if gotFailover || gotQuota { t.Errorf("classifyByErrorMessage(%q) should return (false, false) for invalid JSON", string(body)) } } } // TestShouldRetryWithNextKey_403WithPredeductQuotaError 测试 403 + 预扣费额度失败的场景 // 这是生产环境实际发生的错误格式 func TestShouldRetryWithNextKey_403WithPredeductQuotaError(t *testing.T) { // 使用生产环境的精确 JSON 格式 body := []byte(`{"error":{"type":"new_api_error","message":"预扣费额度失败, 用户剩余额度: ¥0.053950, 需要预扣费额度: ¥0.191160, 下次重置时间: 2025-01-01 00:00:00"},"type":"error"}`) gotFailover, gotQuota := ShouldRetryWithNextKey(403, body, false, "Messages") if !gotFailover { t.Errorf("ShouldRetryWithNextKey(403, prededuct_error, false) failover = %v, want true", gotFailover) } if !gotQuota { t.Errorf("ShouldRetryWithNextKey(403, prededuct_error, false) quota = %v, want true", gotQuota) } } // TestClassifyMessage_ChineseQuotaKeywords 测试中文额度关键词 func TestClassifyMessage_ChineseQuotaKeywords(t *testing.T) { tests := []struct { name string message string wantFailover bool wantQuota bool }{ {"预扣费额度失败", "预扣费额度失败, 用户剩余额度: ¥0.053950", true, true}, {"额度不足", "账户额度不足", true, true}, {"预扣费失败", "预扣费失败,请充值", true, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotFailover, gotQuota := classifyMessage(tt.message) if gotFailover != tt.wantFailover { t.Errorf("classifyMessage(%q) failover = %v, want %v", tt.message, gotFailover, tt.wantFailover) } if gotQuota != tt.wantQuota { t.Errorf("classifyMessage(%q) quota = %v, want %v", tt.message, gotQuota, tt.wantQuota) } }) } } // TestShouldRetryWithNextKey 测试完整的重试判断逻辑 func TestShouldRetryWithNextKey(t *testing.T) { tests := []struct { name string statusCode int body map[string]interface{} wantFailover bool wantQuota bool }{ // 403 + 中文配额相关消息 { name: "403 with chinese quota message", statusCode: 403, body: map[string]interface{}{ "error": map[string]interface{}{ "type": "new_api_error", "message": "预扣费额度失败, 用户剩余额度: ¥0.053950", }, "type": "error", }, wantFailover: true, wantQuota: true, }, // 状态码优先 { name: "401 always failover", statusCode: 401, body: map[string]interface{}{}, wantFailover: true, wantQuota: false, }, { name: "402 always failover with quota", statusCode: 402, body: map[string]interface{}{}, wantFailover: true, wantQuota: true, }, { name: "408 always failover", statusCode: 408, body: map[string]interface{}{}, wantFailover: true, wantQuota: false, }, { name: "500 always failover", statusCode: 500, body: map[string]interface{}{}, wantFailover: true, wantQuota: false, }, // 400 需要检查消息体 { name: "400 with quota message", statusCode: 400, body: map[string]interface{}{ "error": map[string]interface{}{ "message": "Quota exceeded", }, }, wantFailover: true, wantQuota: true, }, { name: "400 with auth message", statusCode: 400, body: map[string]interface{}{ "error": map[string]interface{}{ "message": "Invalid API key", }, }, wantFailover: true, wantQuota: false, }, { name: "400 without failover keywords", statusCode: 400, body: map[string]interface{}{ "error": map[string]interface{}{ "message": "Bad request", }, }, wantFailover: false, wantQuota: false, }, // 404 不应 failover { name: "404 never failover", statusCode: 404, body: map[string]interface{}{}, wantFailover: false, wantQuota: false, }, // 200 不应 failover { name: "200 never failover", statusCode: 200, body: map[string]interface{}{}, wantFailover: false, wantQuota: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { bodyBytes, _ := json.Marshal(tt.body) // 测试非 Fuzzy 模式(精确错误分类) gotFailover, gotQuota := ShouldRetryWithNextKey(tt.statusCode, bodyBytes, false, "Messages") if gotFailover != tt.wantFailover { t.Errorf("shouldRetryWithNextKey(%d, ..., false) failover = %v, want %v", tt.statusCode, gotFailover, tt.wantFailover) } if gotQuota != tt.wantQuota { t.Errorf("shouldRetryWithNextKey(%d, ..., false) quota = %v, want %v", tt.statusCode, gotQuota, tt.wantQuota) } }) } } // TestShouldRetryWithNextKeyFuzzyMode 测试 Fuzzy 模式下的错误分类 // Fuzzy 模式:所有非 2xx 错误都触发 failover func TestShouldRetryWithNextKeyFuzzyMode(t *testing.T) { tests := []struct { name string statusCode int wantFailover bool wantQuota bool }{ // 2xx 成功响应不 failover { name: "200 OK - no failover", statusCode: 200, wantFailover: false, wantQuota: false, }, { name: "201 Created - no failover", statusCode: 201, wantFailover: false, wantQuota: false, }, // 3xx 重定向在 Fuzzy 模式下触发 failover { name: "301 Redirect - failover in fuzzy mode", statusCode: 301, wantFailover: true, wantQuota: false, }, { name: "302 Found - failover in fuzzy mode", statusCode: 302, wantFailover: true, wantQuota: false, }, // 4xx 客户端错误在 Fuzzy 模式下都触发 failover { name: "400 Bad Request - failover in fuzzy mode", statusCode: 400, wantFailover: true, wantQuota: false, }, { name: "401 Unauthorized - failover in fuzzy mode", statusCode: 401, wantFailover: true, wantQuota: false, }, { name: "402 Payment Required - failover with quota", statusCode: 402, wantFailover: true, wantQuota: true, // 配额相关 }, { name: "403 Forbidden - failover in fuzzy mode", statusCode: 403, wantFailover: true, wantQuota: false, }, { name: "404 Not Found - failover in fuzzy mode", statusCode: 404, wantFailover: true, wantQuota: false, }, { name: "422 Unprocessable Entity - failover in fuzzy mode", statusCode: 422, wantFailover: true, wantQuota: false, }, { name: "429 Too Many Requests - failover with quota", statusCode: 429, wantFailover: true, wantQuota: true, // 配额相关 }, // 5xx 服务端错误在 Fuzzy 模式下触发 failover { name: "500 Internal Server Error - failover in fuzzy mode", statusCode: 500, wantFailover: true, wantQuota: false, }, { name: "502 Bad Gateway - failover in fuzzy mode", statusCode: 502, wantFailover: true, wantQuota: false, }, { name: "503 Service Unavailable - failover in fuzzy mode", statusCode: 503, wantFailover: true, wantQuota: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // 测试 Fuzzy 模式(所有非 2xx 都 failover) gotFailover, gotQuota := ShouldRetryWithNextKey(tt.statusCode, nil, true, "Messages") if gotFailover != tt.wantFailover { t.Errorf("shouldRetryWithNextKey(%d, nil, true) failover = %v, want %v", tt.statusCode, gotFailover, tt.wantFailover) } if gotQuota != tt.wantQuota { t.Errorf("shouldRetryWithNextKey(%d, nil, true) quota = %v, want %v", tt.statusCode, gotQuota, tt.wantQuota) } }) } } // TestShouldRetryWithNextKey_FuzzyMode_403WithQuotaMessage 测试 Fuzzy 模式下 403 + 预扣费消息 // 验证修复:Fuzzy 模式下也会检查消息体中的配额相关关键词 func TestShouldRetryWithNextKey_FuzzyMode_403WithQuotaMessage(t *testing.T) { tests := []struct { name string statusCode int body []byte wantFailover bool wantQuota bool }{ { name: "403 with prededuct quota error in fuzzy mode", statusCode: 403, body: []byte(`{"error":{"type":"new_api_error","message":"预扣费额度失败, 用户剩余额度: ¥0.053950, 需要预扣费额度: ¥0.191160"},"type":"error"}`), wantFailover: true, wantQuota: true, }, { name: "403 with insufficient balance in fuzzy mode", statusCode: 403, body: []byte(`{"error":{"message":"余额不足,请充值"}}`), wantFailover: true, wantQuota: true, }, { name: "403 without quota keywords in fuzzy mode", statusCode: 403, body: []byte(`{"error":{"message":"Access denied"}}`), wantFailover: true, wantQuota: false, }, { name: "403 with empty body in fuzzy mode", statusCode: 403, body: nil, wantFailover: true, wantQuota: false, }, { name: "500 with quota message in fuzzy mode", statusCode: 500, body: []byte(`{"error":{"message":"Quota exceeded"}}`), wantFailover: true, wantQuota: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotFailover, gotQuota := ShouldRetryWithNextKey(tt.statusCode, tt.body, true, "Messages") if gotFailover != tt.wantFailover { t.Errorf("ShouldRetryWithNextKey(%d, body, true) failover = %v, want %v", tt.statusCode, gotFailover, tt.wantFailover) } if gotQuota != tt.wantQuota { t.Errorf("ShouldRetryWithNextKey(%d, body, true) quota = %v, want %v", tt.statusCode, gotQuota, tt.wantQuota) } }) } } // TestIsNonRetryableErrorCode 测试不可重试错误码判断 func TestIsNonRetryableErrorCode(t *testing.T) { tests := []struct { code string want bool }{ // 内容审核相关 - 不应重试 {"sensitive_words_detected", true}, {"content_policy_violation", true}, {"content_filter", true}, {"content_blocked", true}, {"moderation_blocked", true}, // 请求内容无效 - 不应重试 {"invalid_request", true}, {"invalid_request_error", true}, {"bad_request", true}, // 大小写不敏感 {"SENSITIVE_WORDS_DETECTED", true}, {"Content_Policy_Violation", true}, // 其他错误码 - 应该重试 {"server_error", false}, {"rate_limit", false}, {"authentication_error", false}, {"unknown_error", false}, {"", false}, } for _, tt := range tests { name := tt.code if name == "" { name = "empty" } t.Run(name, func(t *testing.T) { got := isNonRetryableErrorCode(tt.code) if got != tt.want { t.Errorf("isNonRetryableErrorCode(%q) = %v, want %v", tt.code, got, tt.want) } }) } } // TestShouldRetryWithNextKey_SensitiveWordsDetected 测试敏感词检测错误不应重试 // 这是修复的核心场景:500 + sensitive_words_detected 不应触发无限重试 func TestShouldRetryWithNextKey_SensitiveWordsDetected(t *testing.T) { // 模拟生产环境的敏感词检测错误 body := []byte(`{"error":{"message":"sensitive words detected","type":"new_api_error","param":"","code":"sensitive_words_detected"}}`) tests := []struct { name string statusCode int fuzzyMode bool wantFailover bool wantQuota bool }{ { name: "500 with sensitive_words_detected - normal mode", statusCode: 500, fuzzyMode: false, wantFailover: false, // 不应重试 wantQuota: false, }, { name: "500 with sensitive_words_detected - fuzzy mode", statusCode: 500, fuzzyMode: true, wantFailover: false, // 即使在 fuzzy 模式下也不应重试 wantQuota: false, }, { name: "400 with sensitive_words_detected - normal mode", statusCode: 400, fuzzyMode: false, wantFailover: false, wantQuota: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotFailover, gotQuota := ShouldRetryWithNextKey(tt.statusCode, body, tt.fuzzyMode, "Messages") if gotFailover != tt.wantFailover { t.Errorf("ShouldRetryWithNextKey(%d, sensitive_words_body, %v) failover = %v, want %v", tt.statusCode, tt.fuzzyMode, gotFailover, tt.wantFailover) } if gotQuota != tt.wantQuota { t.Errorf("ShouldRetryWithNextKey(%d, sensitive_words_body, %v) quota = %v, want %v", tt.statusCode, tt.fuzzyMode, gotQuota, tt.wantQuota) } }) } } ================================================ FILE: backend-go/internal/handlers/common/multi_channel_failover.go ================================================ package common import ( "fmt" "log" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/scheduler" "github.com/BenedictKing/claude-proxy/internal/types" "github.com/gin-gonic/gin" ) // MultiChannelAttemptResult 描述一次“选中渠道”的尝试结果(用于多渠道 failover 外壳复用)。 type MultiChannelAttemptResult struct { Handled bool Attempted bool SuccessKey string SuccessBaseURLIdx int FailoverError *FailoverError Usage *types.Usage LastError error } // TrySelectedChannelFunc 尝试一次选中的渠道,返回该渠道的尝试结果。 type TrySelectedChannelFunc func(selection *scheduler.SelectionResult) MultiChannelAttemptResult // OnMultiChannelHandledFunc 在请求被“处理完成”时回调(成功或非 failover 错误都会触发)。 type OnMultiChannelHandledFunc func(selection *scheduler.SelectionResult, result MultiChannelAttemptResult) // HandleAllFailedFunc 处理“所有渠道都失败”的返回逻辑(不同入口可能有不同错误格式)。 type HandleAllFailedFunc func(c *gin.Context, failoverErr *FailoverError, lastError error) // HandleMultiChannelFailover 处理多渠道 failover 外壳逻辑(选渠道 + 聚合错误 + Trace 亲和)。 // 具体“渠道内 Key/BaseURL 轮转”由 trySelectedChannel 实现(通常调用 TryUpstreamWithAllKeys)。 func HandleMultiChannelFailover( c *gin.Context, envCfg *config.EnvConfig, channelScheduler *scheduler.ChannelScheduler, kind scheduler.ChannelKind, apiType string, userID string, trySelectedChannel TrySelectedChannelFunc, onHandled OnMultiChannelHandledFunc, handleAllFailed HandleAllFailedFunc, ) { if c == nil || envCfg == nil || channelScheduler == nil || trySelectedChannel == nil { return } if handleAllFailed == nil { handleAllFailed = func(c *gin.Context, failoverErr *FailoverError, lastError error) { HandleAllChannelsFailed(c, false, failoverErr, lastError, apiType) } } failedChannels := make(map[int]bool) var lastError error var lastFailoverError *FailoverError maxChannelAttempts := channelScheduler.GetActiveChannelCount(kind) for channelAttempt := 0; channelAttempt < maxChannelAttempts; channelAttempt++ { // 检查客户端是否已断开连接 select { case <-c.Request.Context().Done(): if envCfg.ShouldLog("info") { log.Printf("[%s-Cancel] 请求已取消,停止渠道 failover", apiType) } return default: // 继续正常流程 } selection, err := channelScheduler.SelectChannel(c.Request.Context(), userID, failedChannels, kind) if err != nil { lastError = err break } upstream := selection.Upstream channelIndex := selection.ChannelIndex if envCfg.ShouldLog("info") && upstream != nil { log.Printf("[%s-Select] 选择渠道: [%d] %s (原因: %s, 尝试 %d/%d)", apiType, channelIndex, upstream.Name, selection.Reason, channelAttempt+1, maxChannelAttempts) } result := trySelectedChannel(selection) if result.Handled { if onHandled != nil { onHandled(selection, result) } // 只有真正成功的请求才设置 Trace 亲和(客户端取消时 SuccessKey 为空) if result.SuccessKey != "" { channelScheduler.SetTraceAffinity(userID, channelIndex) } return } failedChannels[channelIndex] = true if result.FailoverError != nil { lastFailoverError = result.FailoverError if upstream != nil { lastError = fmt.Errorf("渠道 [%d] %s 失败", channelIndex, upstream.Name) } else { lastError = fmt.Errorf("渠道 [%d] 失败", channelIndex) } } if result.Attempted && upstream != nil { log.Printf("[%s-Failover] 警告: 渠道 [%d] %s 所有密钥都失败,尝试下一个渠道", apiType, channelIndex, upstream.Name) } } log.Printf("[%s-Error] 所有渠道都失败了", apiType) handleAllFailed(c, lastFailoverError, lastError) } ================================================ FILE: backend-go/internal/handlers/common/request.go ================================================ // Package common 提供 handlers 模块的公共功能 package common import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "time" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/httpclient" "github.com/BenedictKing/claude-proxy/internal/metrics" "github.com/BenedictKing/claude-proxy/internal/utils" "github.com/gin-gonic/gin" ) // ReadRequestBody 读取并验证请求体大小 // 返回: (bodyBytes, error) // 如果请求体过大,会自动返回 413 错误并排空剩余数据 func ReadRequestBody(c *gin.Context, maxBodySize int64) ([]byte, error) { limitedReader := io.LimitReader(c.Request.Body, maxBodySize+1) bodyBytes, err := io.ReadAll(limitedReader) if err != nil { c.JSON(400, gin.H{"error": "Failed to read request body"}) return nil, err } if int64(len(bodyBytes)) > maxBodySize { // 排空剩余请求体,避免 keep-alive 连接污染 io.Copy(io.Discard, c.Request.Body) c.JSON(413, gin.H{"error": fmt.Sprintf("Request body too large, maximum size is %d MB", maxBodySize/1024/1024)}) return nil, fmt.Errorf("request body too large") } // 恢复请求体供后续使用 c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes)) return bodyBytes, nil } // RestoreRequestBody 恢复请求体供后续使用 func RestoreRequestBody(c *gin.Context, bodyBytes []byte) { c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes)) } // SendRequest 发送 HTTP 请求到上游 // isStream: 是否为流式请求(流式请求使用无超时客户端) // apiType: 接口类型(Messages/Responses/Gemini),用于日志标签前缀 func SendRequest(req *http.Request, upstream *config.UpstreamConfig, envCfg *config.EnvConfig, isStream bool, apiType string) (*http.Response, error) { clientManager := httpclient.GetManager() var client *http.Client if isStream { client = clientManager.GetStreamClient(upstream.InsecureSkipVerify) } else { timeout := time.Duration(envCfg.RequestTimeout) * time.Millisecond client = clientManager.GetStandardClient(timeout, upstream.InsecureSkipVerify) } if upstream.InsecureSkipVerify && envCfg.EnableRequestLogs { log.Printf("[%s-Request-TLS] 警告: 正在跳过对 %s 的TLS证书验证", apiType, req.URL.String()) } if envCfg.EnableRequestLogs { log.Printf("[%s-Request-URL] 实际请求URL: %s", apiType, req.URL.String()) log.Printf("[%s-Request-Method] 请求方法: %s", apiType, req.Method) if envCfg.IsDevelopment() { logRequestDetails(req, envCfg, apiType) } } return client.Do(req) } // logRequestDetails 记录请求详情(仅开发模式) // apiType: 接口类型(Messages/Responses/Gemini),用于日志标签前缀 func logRequestDetails(req *http.Request, envCfg *config.EnvConfig, apiType string) { // 对请求头做敏感信息脱敏 reqHeaders := make(map[string]string) for key, values := range req.Header { if len(values) > 0 { reqHeaders[key] = values[0] } } maskedReqHeaders := utils.MaskSensitiveHeaders(reqHeaders) var reqHeadersJSON []byte if envCfg.RawLogOutput { reqHeadersJSON, _ = json.Marshal(maskedReqHeaders) } else { reqHeadersJSON, _ = json.MarshalIndent(maskedReqHeaders, "", " ") } log.Printf("[%s-Request-Headers] 实际请求头:\n%s", apiType, string(reqHeadersJSON)) if req.Body != nil { bodyBytes, err := io.ReadAll(req.Body) if err == nil { req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) var formattedBody string if envCfg.RawLogOutput { formattedBody = utils.FormatJSONBytesRaw(bodyBytes) } else { formattedBody = utils.FormatJSONBytesForLog(bodyBytes, 500) } log.Printf("[%s-Request-Body] 实际请求体:\n%s", apiType, formattedBody) } } } // LogOriginalRequest 记录原始请求信息 func LogOriginalRequest(c *gin.Context, bodyBytes []byte, envCfg *config.EnvConfig, apiType string) { if !envCfg.EnableRequestLogs { return } log.Printf("[Request-Receive] 收到%s请求: %s %s", apiType, c.Request.Method, c.Request.URL.Path) if envCfg.IsDevelopment() { var formattedBody string if envCfg.RawLogOutput { formattedBody = utils.FormatJSONBytesRaw(bodyBytes) } else { formattedBody = utils.FormatJSONBytesForLog(bodyBytes, 500) } log.Printf("[Request-OriginalBody] 原始请求体:\n%s", formattedBody) sanitizedHeaders := make(map[string]string) for key, values := range c.Request.Header { if len(values) > 0 { sanitizedHeaders[key] = values[0] } } maskedHeaders := utils.MaskSensitiveHeaders(sanitizedHeaders) var headersJSON []byte if envCfg.RawLogOutput { headersJSON, _ = json.Marshal(maskedHeaders) } else { headersJSON, _ = json.MarshalIndent(maskedHeaders, "", " ") } log.Printf("[Request-OriginalHeaders] 原始请求头:\n%s", string(headersJSON)) } } // AreAllKeysSuspended 检查渠道的所有 Key 是否都处于熔断状态 // 用于判断是否需要启用强制探测模式 func AreAllKeysSuspended(metricsManager *metrics.MetricsManager, baseURL string, apiKeys []string) bool { if len(apiKeys) == 0 { return false } for _, apiKey := range apiKeys { if !metricsManager.ShouldSuspendKey(baseURL, apiKey) { return false } } return true } // RemoveEmptySignatures 移除请求体中 messages[*].content[*].signature 的空值 // 用于预防 Claude API 返回 400 错误 // 仅处理已知路径:messages 数组中各消息的 content 数组中的 signature 字段 // enableLog: 是否输出日志(由 envCfg.EnableRequestLogs 控制) // apiType: 接口类型(Messages/Responses/Gemini),用于日志标签前缀 func RemoveEmptySignatures(bodyBytes []byte, enableLog bool, apiType string) ([]byte, bool) { decoder := json.NewDecoder(bytes.NewReader(bodyBytes)) decoder.UseNumber() // 保留数字精度 var data map[string]interface{} if err := decoder.Decode(&data); err != nil { return bodyBytes, false } modified, removedCount := removeEmptySignaturesInMessages(data) if !modified { return bodyBytes, false } if enableLog && removedCount > 0 { log.Printf("[%s-Preprocess] 已移除 %d 个空 signature 字段", apiType, removedCount) } // 使用 Encoder 并禁用 HTML 转义,保持原始格式 newBytes, err := utils.MarshalJSONNoEscape(data) if err != nil { return bodyBytes, false } return newBytes, true } // removeEmptySignaturesInMessages 仅处理 messages[*].content[*].signature 路径 // 返回 (是否有修改, 移除的字段数) func removeEmptySignaturesInMessages(data map[string]interface{}) (bool, int) { modified := false removedCount := 0 messages, ok := data["messages"].([]interface{}) if !ok { return false, 0 } for _, msg := range messages { msgMap, ok := msg.(map[string]interface{}) if !ok { continue } content, ok := msgMap["content"].([]interface{}) if !ok { continue } for _, block := range content { blockMap, ok := block.(map[string]interface{}) if !ok { continue } if sig, exists := blockMap["signature"]; exists { if sig == nil { delete(blockMap, "signature") modified = true removedCount++ } else if str, isStr := sig.(string); isStr && str == "" { delete(blockMap, "signature") modified = true removedCount++ } } } } return modified, removedCount } // ExtractUserID 从请求体中提取 user_id(用于 Messages API) func ExtractUserID(bodyBytes []byte) string { var req struct { Metadata struct { UserID string `json:"user_id"` } `json:"metadata"` } if err := json.Unmarshal(bodyBytes, &req); err == nil { return req.Metadata.UserID } return "" } // ExtractConversationID 从请求中提取对话标识(用于 Responses API) // 优先级: Conversation_id Header > Session_id Header > X-Gemini-Api-Privileged-User-Id > prompt_cache_key > metadata.user_id func ExtractConversationID(c *gin.Context, bodyBytes []byte) string { // 1. HTTP Header: Conversation_id if convID := c.GetHeader("Conversation_id"); convID != "" { return convID } // 2. HTTP Header: Session_id if sessID := c.GetHeader("Session_id"); sessID != "" { return sessID } // 3. HTTP Header: X-Gemini-Api-Privileged-User-Id (Gemini 专用) if geminiUserID := c.GetHeader("X-Gemini-Api-Privileged-User-Id"); geminiUserID != "" { return geminiUserID } // 4. Request Body: prompt_cache_key 或 metadata.user_id var req struct { PromptCacheKey string `json:"prompt_cache_key"` Metadata struct { UserID string `json:"user_id"` } `json:"metadata"` } if err := json.Unmarshal(bodyBytes, &req); err == nil { if req.PromptCacheKey != "" { return req.PromptCacheKey } if req.Metadata.UserID != "" { return req.Metadata.UserID } } return "" } ================================================ FILE: backend-go/internal/handlers/common/stream.go ================================================ // Package common 提供 handlers 模块的公共功能 package common import ( "bytes" "encoding/json" "fmt" "log" "net/http" "strings" "time" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/providers" "github.com/BenedictKing/claude-proxy/internal/types" "github.com/BenedictKing/claude-proxy/internal/utils" "github.com/gin-gonic/gin" "github.com/google/uuid" ) // StreamContext 流处理上下文 type StreamContext struct { LogBuffer bytes.Buffer OutputTextBuffer bytes.Buffer Synthesizer *utils.StreamSynthesizer LoggingEnabled bool ClientGone bool HasUsage bool NeedTokenPatch bool // 累积的 token 统计 CollectedUsage CollectedUsageData // 用于日志的"续写前缀"(不参与真实转发,只影响 Stream-Synth 输出可读性) LogPrefillText string // SSE 事件调试追踪 EventCount int // 事件总数 ContentBlockCount int // content block 计数 ContentBlockTypes map[int]string // 每个 block 的类型 // 低质量渠道处理 RequestModel string // 请求中的 model(用于一致性检查) LowQuality bool // 是否为低质量渠道 // 隐式缓存推断 MessageStartInputTokens int // message_start 事件中的 input_tokens(用于推断隐式缓存) } // CollectedUsageData 从流事件中收集的 usage 数据 type CollectedUsageData struct { InputTokens int OutputTokens int CacheCreationInputTokens int CacheReadInputTokens int // 缓存 TTL 细分 CacheCreation5mInputTokens int CacheCreation1hInputTokens int CacheTTL string // "5m" | "1h" | "mixed" } // NewStreamContext 创建流处理上下文 func NewStreamContext(envCfg *config.EnvConfig) *StreamContext { ctx := &StreamContext{ LoggingEnabled: envCfg.IsDevelopment() && envCfg.EnableResponseLogs, ContentBlockTypes: make(map[int]string), } if ctx.LoggingEnabled { ctx.Synthesizer = utils.NewStreamSynthesizer("claude") } return ctx } // seedSynthesizerFromRequest 将请求里预置的 assistant 文本拼接进合成器(仅用于日志可读性) // // Claude Code 的部分内部调用会在 messages 里预置一条 assistant 内容(例如 "{"),让模型只输出“续写”部分。 // 这会导致我们仅基于 SSE delta 合成的日志缺失开头。这里用请求体做一次轻量补齐。 func seedSynthesizerFromRequest(ctx *StreamContext, requestBody []byte) { if ctx == nil || ctx.Synthesizer == nil || len(requestBody) == 0 { return } var req struct { Messages []struct { Role string `json:"role"` Content []struct { Type string `json:"type"` Text string `json:"text"` } `json:"content"` } `json:"messages"` } if err := json.Unmarshal(requestBody, &req); err != nil { return } // 只取最后一条 assistant,避免把历史上下文都拼进日志 for i := len(req.Messages) - 1; i >= 0; i-- { msg := req.Messages[i] if msg.Role != "assistant" { continue } var b strings.Builder for _, c := range msg.Content { if c.Type == "text" && c.Text != "" { b.WriteString(c.Text) } } prefill := b.String() // 防止把很长的预置内容刷进日志 if len(prefill) > 0 && len(prefill) <= 256 { ctx.LogPrefillText = prefill } return } } // SetupStreamHeaders 设置流式响应头 func SetupStreamHeaders(c *gin.Context, resp *http.Response) { utils.ForwardResponseHeaders(resp.Header, c.Writer) c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") c.Status(200) } // ProcessStreamEvents 处理流事件循环 // 返回值: error 表示流处理过程中是否发生错误(用于调用方决定是否记录失败指标) func ProcessStreamEvents( c *gin.Context, w gin.ResponseWriter, flusher http.Flusher, eventChan <-chan string, errChan <-chan error, ctx *StreamContext, envCfg *config.EnvConfig, startTime time.Time, requestBody []byte, ) (*types.Usage, error) { for { select { case event, ok := <-eventChan: if !ok { usage := logStreamCompletion(ctx, envCfg, startTime) return usage, nil } ProcessStreamEvent(c, w, flusher, event, ctx, envCfg, requestBody) case err, ok := <-errChan: if !ok { continue } if err != nil { log.Printf("[Messages-Stream] 错误: 流式传输错误: %v", err) logPartialResponse(ctx, envCfg) // 向客户端发送错误事件(如果连接仍然有效) if !ctx.ClientGone { errorEvent := BuildStreamErrorEvent(err) w.Write([]byte(errorEvent)) flusher.Flush() } return nil, err } } } } // ProcessStreamEvent 处理单个流事件 func ProcessStreamEvent( c *gin.Context, w gin.ResponseWriter, flusher http.Flusher, event string, ctx *StreamContext, envCfg *config.EnvConfig, requestBody []byte, ) { // SSE 事件调试日志 ctx.EventCount++ if envCfg.SSEDebugLevel == "full" || envCfg.SSEDebugLevel == "summary" { eventType, blockIndex, blockType := extractSSEEventInfo(event) if eventType == "content_block_start" { ctx.ContentBlockCount++ if blockType != "" { ctx.ContentBlockTypes[blockIndex] = blockType } } if envCfg.SSEDebugLevel == "full" { log.Printf("[Messages-Stream-Event] #%d 类型=%s 长度=%d block_index=%d block_type=%s", ctx.EventCount, eventType, len(event), blockIndex, blockType) // 对于 content_block 相关事件,记录详细内容 if strings.Contains(event, "content_block") { log.Printf("[Messages-Stream-Event] 详情: %s", truncateForLog(event, 500)) } } } // 提取文本用于估算 token ExtractTextFromEvent(event, &ctx.OutputTextBuffer) // 检测并收集 usage hasUsage, needInputPatch, needOutputPatch, usageData := CheckEventUsageStatus(event, envCfg.EnableResponseLogs && envCfg.ShouldLog("debug")) needPatch := needInputPatch || needOutputPatch // 保存原始 usageData 用于后续 PatchMessageStartInputTokensIfNeeded originalUsageData := usageData if hasUsage { if !ctx.HasUsage { ctx.HasUsage = true ctx.NeedTokenPatch = needPatch || ctx.LowQuality if envCfg.EnableResponseLogs && envCfg.ShouldLog("debug") && needPatch && !IsMessageDeltaEvent(event) { log.Printf("[Messages-Stream-Token] 检测到虚假值, 延迟到流结束修补") } } // 对于 message_start 事件,不累积 input_tokens 到 CollectedUsage // 因为 message_start 的 input_tokens 是请求总 token,而非最终计费值 // CollectedUsage.InputTokens 应该只记录 message_delta 的最终计费值 if IsMessageStartEvent(event) && usageData.InputTokens > 0 { usageData.InputTokens = 0 } // 累积收集 usage 数据 updateCollectedUsage(&ctx.CollectedUsage, usageData) } // 日志缓存 if ctx.LoggingEnabled { ctx.LogBuffer.WriteString(event) if ctx.Synthesizer != nil { for _, line := range strings.Split(event, "\n") { ctx.Synthesizer.ProcessLine(line) } } } // 在 message_stop 前注入 usage(上游完全没有 usage 的情况) if !ctx.HasUsage && !ctx.ClientGone && IsMessageStopEvent(event) { usageEvent := BuildUsageEvent(requestBody, ctx.OutputTextBuffer.String()) if envCfg.EnableResponseLogs && envCfg.ShouldLog("debug") { log.Printf("[Messages-Stream-Token] 上游无usage, 注入本地估算事件") } w.Write([]byte(usageEvent)) flusher.Flush() ctx.HasUsage = true } // 修补 token eventToSend := event // 处理 message_start 事件:补全空 id 和检查 model 一致性(可选) if IsMessageStartEvent(event) && ctx.RequestModel != "" { eventToSend = PatchMessageStartEvent(eventToSend, ctx.RequestModel, envCfg.RewriteResponseModel, envCfg.EnableResponseLogs && envCfg.ShouldLog("debug")) } // 处理 message_start 事件:尽早补全 input_tokens(部分客户端只读取首个 usage 来累计) // 注意:使用 originalUsageData 而非被清零后的 usageData,避免误判 if hasUsage { eventToSend = PatchMessageStartInputTokensIfNeeded(eventToSend, requestBody, needInputPatch, originalUsageData, envCfg.EnableResponseLogs && envCfg.ShouldLog("debug"), ctx.LowQuality) } // 记录 message_start 中的 input_tokens(用于后续推断隐式缓存) // 注意:必须在 PatchMessageStartInputTokensIfNeeded 之后执行,因为原始值可能是 0 被修补成估算值 if IsMessageStartEvent(event) && ctx.MessageStartInputTokens == 0 { if patchedInputTokens := ExtractInputTokensFromEvent(eventToSend); patchedInputTokens > 0 { ctx.MessageStartInputTokens = patchedInputTokens } } if ctx.NeedTokenPatch && HasEventWithUsage(event) { if IsMessageDeltaEvent(event) || IsMessageStopEvent(event) { hasCacheTokens := ctx.CollectedUsage.CacheCreationInputTokens > 0 || ctx.CollectedUsage.CacheReadInputTokens > 0 || ctx.CollectedUsage.CacheCreation5mInputTokens > 0 || ctx.CollectedUsage.CacheCreation1hInputTokens > 0 // 在转发前执行隐式缓存推断,确保下游能收到推断的 cache_read_input_tokens if !hasCacheTokens { inferImplicitCacheRead(ctx, envCfg.EnableResponseLogs && envCfg.ShouldLog("debug")) // 重新检查是否有缓存 token(可能刚被推断出来) hasCacheTokens = ctx.CollectedUsage.CacheReadInputTokens > 0 } // 检测隐式缓存信号:message_start 的 input_tokens 远大于最终值 // 这种情况下不应该用本地估算值覆盖,因为低 input_tokens 是缓存命中的正常结果 hasImplicitCacheSignal := ctx.MessageStartInputTokens > 0 && ctx.CollectedUsage.InputTokens > 0 && ctx.MessageStartInputTokens > ctx.CollectedUsage.InputTokens inputTokens := ctx.CollectedUsage.InputTokens estimatedInputTokens := utils.EstimateRequestTokens(requestBody) // 仅在无缓存信号(显式或隐式)且 input_tokens 异常小时才用估算值修补 if !hasCacheTokens && !hasImplicitCacheSignal && inputTokens < 10 && estimatedInputTokens > inputTokens { inputTokens = estimatedInputTokens } outputTokens := ctx.CollectedUsage.OutputTokens estimatedOutputTokens := utils.EstimateTokens(ctx.OutputTextBuffer.String()) if outputTokens <= 1 && estimatedOutputTokens > outputTokens { outputTokens = estimatedOutputTokens } if inputTokens > ctx.CollectedUsage.InputTokens { ctx.CollectedUsage.InputTokens = inputTokens } if outputTokens > ctx.CollectedUsage.OutputTokens { ctx.CollectedUsage.OutputTokens = outputTokens } // 修补事件,包括推断的 cache_read_input_tokens eventToSend = PatchTokensInEventWithCache(eventToSend, inputTokens, outputTokens, ctx.CollectedUsage.CacheReadInputTokens, hasCacheTokens, envCfg.EnableResponseLogs && envCfg.ShouldLog("debug"), ctx.LowQuality) ctx.NeedTokenPatch = false } } // 转发给客户端 if !ctx.ClientGone { if _, err := w.Write([]byte(eventToSend)); err != nil { ctx.ClientGone = true if !IsClientDisconnectError(err) { log.Printf("[Messages-Stream] 警告: 写入错误: %v", err) } else if envCfg.ShouldLog("info") { log.Printf("[Messages-Stream] 客户端中断连接 (正常行为),继续接收上游数据...") } } else { flusher.Flush() } } } // updateCollectedUsage 更新收集的 usage 数据 func updateCollectedUsage(collected *CollectedUsageData, usageData CollectedUsageData) { if usageData.InputTokens > collected.InputTokens { collected.InputTokens = usageData.InputTokens } if usageData.OutputTokens > collected.OutputTokens { collected.OutputTokens = usageData.OutputTokens } if usageData.CacheCreationInputTokens > 0 { collected.CacheCreationInputTokens = usageData.CacheCreationInputTokens } if usageData.CacheReadInputTokens > 0 { collected.CacheReadInputTokens = usageData.CacheReadInputTokens } if usageData.CacheCreation5mInputTokens > 0 { collected.CacheCreation5mInputTokens = usageData.CacheCreation5mInputTokens } if usageData.CacheCreation1hInputTokens > 0 { collected.CacheCreation1hInputTokens = usageData.CacheCreation1hInputTokens } if usageData.CacheTTL != "" { collected.CacheTTL = usageData.CacheTTL } } // inferImplicitCacheRead 推断隐式缓存读取 // // 当 message_start 中的 input_tokens 与 message_delta 中的最终 input_tokens 存在显著差异时, // 差额可能是上游 prompt caching 命中但未明确返回 cache_read_input_tokens 的情况。 // 触发条件:差额 > 10% 或差额 > 10000 tokens,且上游未返回 cache_read_input_tokens。 func inferImplicitCacheRead(ctx *StreamContext, enableLog bool) { // 前置条件检查 if ctx.MessageStartInputTokens == 0 || ctx.CollectedUsage.InputTokens == 0 { return } // 上游已明确返回 cache_read,无需推断 if ctx.CollectedUsage.CacheReadInputTokens > 0 { return } // 计算差额 diff := ctx.MessageStartInputTokens - ctx.CollectedUsage.InputTokens if diff <= 0 { return } // 计算差额比例 ratio := float64(diff) / float64(ctx.MessageStartInputTokens) // 触发条件:差额 > 10% 或差额 > 10000 tokens if ratio > 0.10 || diff > 10000 { ctx.CollectedUsage.CacheReadInputTokens = diff if enableLog { log.Printf("[Messages-Stream-Token] 推断隐式缓存: message_start=%d, final=%d, cache_read=%d (%.1f%%)", ctx.MessageStartInputTokens, ctx.CollectedUsage.InputTokens, diff, ratio*100) } } } // logStreamCompletion 记录流完成日志 func logStreamCompletion(ctx *StreamContext, envCfg *config.EnvConfig, startTime time.Time) *types.Usage { if envCfg.EnableResponseLogs { log.Printf("[Messages-Stream] 流式响应完成: %dms", time.Since(startTime).Milliseconds()) } // SSE 事件统计日志 if envCfg.SSEDebugLevel == "full" || envCfg.SSEDebugLevel == "summary" { blockTypeSummary := make(map[string]int) for _, bt := range ctx.ContentBlockTypes { blockTypeSummary[bt]++ } log.Printf("[Messages-Stream-Summary] 总事件数=%d, content_blocks=%d, 类型分布=%v", ctx.EventCount, ctx.ContentBlockCount, blockTypeSummary) } if envCfg.IsDevelopment() { logSynthesizedContent(ctx) } // 推断隐式缓存读取 inferImplicitCacheRead(ctx, envCfg.EnableResponseLogs && envCfg.ShouldLog("debug")) // 将累积的 usage 数据转换为 *types.Usage var usage *types.Usage hasUsageData := ctx.CollectedUsage.InputTokens > 0 || ctx.CollectedUsage.OutputTokens > 0 || ctx.CollectedUsage.CacheCreationInputTokens > 0 || ctx.CollectedUsage.CacheReadInputTokens > 0 || ctx.CollectedUsage.CacheCreation5mInputTokens > 0 || ctx.CollectedUsage.CacheCreation1hInputTokens > 0 if hasUsageData { usage = &types.Usage{ InputTokens: ctx.CollectedUsage.InputTokens, OutputTokens: ctx.CollectedUsage.OutputTokens, CacheCreationInputTokens: ctx.CollectedUsage.CacheCreationInputTokens, CacheReadInputTokens: ctx.CollectedUsage.CacheReadInputTokens, CacheCreation5mInputTokens: ctx.CollectedUsage.CacheCreation5mInputTokens, CacheCreation1hInputTokens: ctx.CollectedUsage.CacheCreation1hInputTokens, CacheTTL: ctx.CollectedUsage.CacheTTL, } } return usage } // logPartialResponse 记录部分响应日志 func logPartialResponse(ctx *StreamContext, envCfg *config.EnvConfig) { if envCfg.EnableResponseLogs && envCfg.IsDevelopment() { logSynthesizedContent(ctx) } } // logSynthesizedContent 记录合成内容 func logSynthesizedContent(ctx *StreamContext) { if ctx.Synthesizer != nil { content := ctx.Synthesizer.GetSynthesizedContent() if content != "" && !ctx.Synthesizer.IsParseFailed() { trimmed := strings.TrimSpace(content) // 仅在“明显是 JSON 续写”的情况下拼接预置前缀,避免出现 "{OK" 这类误导日志 if ctx.LogPrefillText == "{" && !strings.HasPrefix(strings.TrimLeft(trimmed, " \t\r\n"), "{") { left := strings.TrimLeft(trimmed, " \t\r\n") if strings.HasPrefix(left, "\"") { trimmed = ctx.LogPrefillText + trimmed } } log.Printf("[Messages-Stream] 上游流式响应合成内容:\n%s", strings.TrimSpace(trimmed)) return } } if ctx.LogBuffer.Len() > 0 { log.Printf("[Messages-Stream] 上游流式响应原始内容:\n%s", ctx.LogBuffer.String()) } } // IsClientDisconnectError 判断是否为客户端断开连接错误 func IsClientDisconnectError(err error) bool { msg := err.Error() return strings.Contains(msg, "broken pipe") || strings.Contains(msg, "connection reset") } // HandleStreamResponse 处理流式响应(Messages API) func HandleStreamResponse( c *gin.Context, resp *http.Response, provider providers.Provider, envCfg *config.EnvConfig, startTime time.Time, upstream *config.UpstreamConfig, requestBody []byte, requestModel string, ) (*types.Usage, error) { defer resp.Body.Close() eventChan, errChan, err := provider.HandleStreamResponse(resp.Body) if err != nil { c.JSON(500, gin.H{"error": "Failed to handle stream response"}) return nil, err } SetupStreamHeaders(c, resp) w := c.Writer flusher, ok := w.(http.Flusher) if !ok { log.Printf("[Messages-Stream] 警告: ResponseWriter不支持Flush接口") return nil, fmt.Errorf("ResponseWriter不支持Flush接口") } flusher.Flush() ctx := NewStreamContext(envCfg) ctx.RequestModel = requestModel ctx.LowQuality = upstream.LowQuality seedSynthesizerFromRequest(ctx, requestBody) return ProcessStreamEvents(c, w, flusher, eventChan, errChan, ctx, envCfg, startTime, requestBody) } // ========== Token 检测和修补相关函数 ========== // CheckEventUsageStatus 检测事件是否包含 usage 字段 func CheckEventUsageStatus(event string, enableLog bool) (bool, bool, bool, CollectedUsageData) { for _, line := range strings.Split(event, "\n") { if !strings.HasPrefix(line, "data: ") { continue } jsonStr := strings.TrimPrefix(line, "data: ") var data map[string]interface{} if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { continue } // 检查顶层 usage 字段 if hasUsage, needInputPatch, needOutputPatch := checkUsageFieldsWithPatch(data["usage"]); hasUsage { var usageData CollectedUsageData if usage, ok := data["usage"].(map[string]interface{}); ok { if enableLog { logUsageDetection("顶层usage", usage, needInputPatch || needOutputPatch) } usageData = extractUsageFromMap(usage) } return true, needInputPatch, needOutputPatch, usageData } // 检查 message.usage if msg, ok := data["message"].(map[string]interface{}); ok { if hasUsage, needInputPatch, needOutputPatch := checkUsageFieldsWithPatch(msg["usage"]); hasUsage { var usageData CollectedUsageData if usage, ok := msg["usage"].(map[string]interface{}); ok { if enableLog { logUsageDetection("message.usage", usage, needInputPatch || needOutputPatch) } usageData = extractUsageFromMap(usage) } return true, needInputPatch, needOutputPatch, usageData } } } return false, false, false, CollectedUsageData{} } // checkUsageFieldsWithPatch 检查 usage 对象是否包含 token 字段 func checkUsageFieldsWithPatch(usage interface{}) (bool, bool, bool) { if u, ok := usage.(map[string]interface{}); ok { inputTokens, hasInput := u["input_tokens"] outputTokens, hasOutput := u["output_tokens"] if hasInput || hasOutput { needInputPatch := false needOutputPatch := false cacheCreation, _ := u["cache_creation_input_tokens"].(float64) cacheRead, _ := u["cache_read_input_tokens"].(float64) hasCacheTokens := cacheCreation > 0 || cacheRead > 0 if hasInput { if inputTokens == nil { // input_tokens 为 nil 时需要修补 needInputPatch = true } else if v, ok := inputTokens.(float64); ok && v <= 1 && !hasCacheTokens { needInputPatch = true } } if hasOutput { if v, ok := outputTokens.(float64); ok && v <= 1 { needOutputPatch = true } } return true, needInputPatch, needOutputPatch } } return false, false, false } // extractUsageFromMap 从 usage map 中提取 token 数据 func extractUsageFromMap(usage map[string]interface{}) CollectedUsageData { var data CollectedUsageData if v, ok := usage["input_tokens"].(float64); ok { data.InputTokens = int(v) } if v, ok := usage["output_tokens"].(float64); ok { data.OutputTokens = int(v) } if v, ok := usage["cache_creation_input_tokens"].(float64); ok { data.CacheCreationInputTokens = int(v) } if v, ok := usage["cache_read_input_tokens"].(float64); ok { data.CacheReadInputTokens = int(v) } var has5m, has1h bool if v, ok := usage["cache_creation_5m_input_tokens"].(float64); ok { data.CacheCreation5mInputTokens = int(v) has5m = data.CacheCreation5mInputTokens > 0 } if v, ok := usage["cache_creation_1h_input_tokens"].(float64); ok { data.CacheCreation1hInputTokens = int(v) has1h = data.CacheCreation1hInputTokens > 0 } if has5m && has1h { data.CacheTTL = "mixed" } else if has1h { data.CacheTTL = "1h" } else if has5m { data.CacheTTL = "5m" } return data } // logUsageDetection 统一格式输出 usage 检测日志 func logUsageDetection(location string, usage map[string]interface{}, needPatch bool) { inputTokens := usage["input_tokens"] outputTokens := usage["output_tokens"] cacheCreation, _ := usage["cache_creation_input_tokens"].(float64) cacheRead, _ := usage["cache_read_input_tokens"].(float64) log.Printf("[Messages-Stream-Token] %s: InputTokens=%v, OutputTokens=%v, CacheCreation=%.0f, CacheRead=%.0f, 需补全=%v", location, inputTokens, outputTokens, cacheCreation, cacheRead, needPatch) } // HasEventWithUsage 检查事件是否包含 usage 字段 func HasEventWithUsage(event string) bool { for _, line := range strings.Split(event, "\n") { if !strings.HasPrefix(line, "data: ") { continue } jsonStr := strings.TrimPrefix(line, "data: ") var data map[string]interface{} if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { continue } if _, ok := data["usage"].(map[string]interface{}); ok { return true } if msg, ok := data["message"].(map[string]interface{}); ok { if _, ok := msg["usage"].(map[string]interface{}); ok { return true } } } return false } // PatchTokensInEvent 修补事件中的 token 字段 func PatchTokensInEvent(event string, estimatedInputTokens, estimatedOutputTokens int, hasCacheTokens bool, enableLog bool, lowQuality bool) string { var result strings.Builder lines := strings.Split(event, "\n") for _, line := range lines { if !strings.HasPrefix(line, "data: ") { result.WriteString(line) result.WriteString("\n") continue } jsonStr := strings.TrimPrefix(line, "data: ") var data map[string]interface{} if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { result.WriteString(line) result.WriteString("\n") continue } // 修补顶层 usage if usage, ok := data["usage"].(map[string]interface{}); ok { patchUsageFieldsWithLog(usage, estimatedInputTokens, estimatedOutputTokens, hasCacheTokens, enableLog, "顶层usage", lowQuality) } // 修补 message.usage if msg, ok := data["message"].(map[string]interface{}); ok { if usage, ok := msg["usage"].(map[string]interface{}); ok { patchUsageFieldsWithLog(usage, estimatedInputTokens, estimatedOutputTokens, hasCacheTokens, enableLog, "message.usage", lowQuality) } } patchedJSON, err := json.Marshal(data) if err != nil { result.WriteString(line) result.WriteString("\n") continue } result.WriteString("data: ") result.Write(patchedJSON) result.WriteString("\n") } return result.String() } // PatchTokensInEventWithCache 修补事件中的 token 字段,并写入推断的 cache_read_input_tokens // 当 inferredCacheRead > 0 且事件中没有 cache_read_input_tokens 时,将推断值写入 func PatchTokensInEventWithCache(event string, estimatedInputTokens, estimatedOutputTokens, inferredCacheRead int, hasCacheTokens bool, enableLog bool, lowQuality bool) string { var result strings.Builder lines := strings.Split(event, "\n") for _, line := range lines { if !strings.HasPrefix(line, "data: ") { result.WriteString(line) result.WriteString("\n") continue } jsonStr := strings.TrimPrefix(line, "data: ") var data map[string]interface{} if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { result.WriteString(line) result.WriteString("\n") continue } // 修补顶层 usage if usage, ok := data["usage"].(map[string]interface{}); ok { patchUsageFieldsWithLog(usage, estimatedInputTokens, estimatedOutputTokens, hasCacheTokens, enableLog, "顶层usage", lowQuality) // 写入推断的 cache_read_input_tokens(仅当字段不存在时) if inferredCacheRead > 0 { if _, exists := usage["cache_read_input_tokens"]; !exists { usage["cache_read_input_tokens"] = inferredCacheRead if enableLog { log.Printf("[Messages-Stream-Token] 顶层usage: 写入推断的 cache_read_input_tokens=%d", inferredCacheRead) } } } } // 修补 message.usage if msg, ok := data["message"].(map[string]interface{}); ok { if usage, ok := msg["usage"].(map[string]interface{}); ok { patchUsageFieldsWithLog(usage, estimatedInputTokens, estimatedOutputTokens, hasCacheTokens, enableLog, "message.usage", lowQuality) // 写入推断的 cache_read_input_tokens(仅当字段不存在时) if inferredCacheRead > 0 { if _, exists := usage["cache_read_input_tokens"]; !exists { usage["cache_read_input_tokens"] = inferredCacheRead if enableLog { log.Printf("[Messages-Stream-Token] message.usage: 写入推断的 cache_read_input_tokens=%d", inferredCacheRead) } } } } } patchedJSON, err := json.Marshal(data) if err != nil { result.WriteString(line) result.WriteString("\n") continue } result.WriteString("data: ") result.Write(patchedJSON) result.WriteString("\n") } return result.String() } // PatchMessageStartInputTokensIfNeeded 在首个 message_start 事件中尽早补全 input_tokens。 // // 部分客户端(例如终端工具)只读取首个 usage 来累计 prompt tokens;如果 message_start 的 input_tokens 为 0/极小值, // 即便后续顶层 usage 给出正确值,也可能导致累计失败。 func PatchMessageStartInputTokensIfNeeded(event string, requestBody []byte, needInputPatch bool, usageData CollectedUsageData, enableLog bool, lowQuality bool) string { if !IsMessageStartEvent(event) { return event } if !HasEventWithUsage(event) { return event } hasCacheTokens := usageData.CacheCreationInputTokens > 0 || usageData.CacheReadInputTokens > 0 || usageData.CacheCreation5mInputTokens > 0 || usageData.CacheCreation1hInputTokens > 0 // 仅在 input_tokens 明显异常时提前补齐;缓存命中场景不应强行补 input_tokens(除非上游返回 nil) if !needInputPatch && (hasCacheTokens || usageData.InputTokens >= 10) { return event } estimatedInputTokens := utils.EstimateRequestTokens(requestBody) if estimatedInputTokens <= 0 { return event } return PatchTokensInEvent(event, estimatedInputTokens, 0, hasCacheTokens, enableLog, lowQuality) } // patchUsageFieldsWithLog 修补 usage 对象中的 token 字段 // lowQuality 模式:偏差 > 5% 时使用本地估算值 func patchUsageFieldsWithLog(usage map[string]interface{}, estimatedInput, estimatedOutput int, hasCacheTokens bool, enableLog bool, location string, lowQuality bool) { originalInput := usage["input_tokens"] originalOutput := usage["output_tokens"] inputPatched := false outputPatched := false cacheCreation, _ := usage["cache_creation_input_tokens"].(float64) cacheRead, _ := usage["cache_read_input_tokens"].(float64) cacheCreation5m, _ := usage["cache_creation_5m_input_tokens"].(float64) cacheCreation1h, _ := usage["cache_creation_1h_input_tokens"].(float64) cacheTTL, _ := usage["cache_ttl"].(string) // 低质量渠道模式:偏差 > 5% 时使用本地估算值 if lowQuality { if v, ok := usage["input_tokens"].(float64); ok && estimatedInput > 0 { currentInput := int(v) if currentInput > 0 { deviation := float64(abs(currentInput-estimatedInput)) / float64(estimatedInput) if deviation > 0.05 { usage["input_tokens"] = estimatedInput inputPatched = true if enableLog { log.Printf("[Messages-Stream-Token-LowQuality] %s: input_tokens %d -> %d (偏差 %.1f%% > 5%%)", location, currentInput, estimatedInput, deviation*100) } } else if enableLog { log.Printf("[Messages-Stream-Token-LowQuality] %s: input_tokens %d ≈ %d (偏差 %.1f%% ≤ 5%%, 保留上游值)", location, currentInput, estimatedInput, deviation*100) } } } else if enableLog && estimatedInput > 0 { log.Printf("[Messages-Stream-Token-LowQuality] %s: input_tokens=%v (上游无效值, 本地估算=%d)", location, usage["input_tokens"], estimatedInput) } if v, ok := usage["output_tokens"].(float64); ok && estimatedOutput > 0 { currentOutput := int(v) if currentOutput > 0 { deviation := float64(abs(currentOutput-estimatedOutput)) / float64(estimatedOutput) if deviation > 0.05 { usage["output_tokens"] = estimatedOutput outputPatched = true if enableLog { log.Printf("[Messages-Stream-Token-LowQuality] %s: output_tokens %d -> %d (偏差 %.1f%% > 5%%)", location, currentOutput, estimatedOutput, deviation*100) } } else if enableLog { log.Printf("[Messages-Stream-Token-LowQuality] %s: output_tokens %d ≈ %d (偏差 %.1f%% ≤ 5%%, 保留上游值)", location, currentOutput, estimatedOutput, deviation*100) } } } else if enableLog && estimatedOutput > 0 { log.Printf("[Messages-Stream-Token-LowQuality] %s: output_tokens=%v (上游无效值, 本地估算=%d)", location, usage["output_tokens"], estimatedOutput) } } // 常规修补逻辑(非 lowQuality 模式或 lowQuality 模式下未修补的情况) if !inputPatched { if v, ok := usage["input_tokens"].(float64); ok { currentInput := int(v) if !hasCacheTokens && ((currentInput <= 1) || (estimatedInput > currentInput && estimatedInput > 1)) { usage["input_tokens"] = estimatedInput inputPatched = true } } else if usage["input_tokens"] == nil && estimatedInput > 0 { // input_tokens 为 nil 时,用收集到的值修补 usage["input_tokens"] = estimatedInput inputPatched = true } } if !outputPatched { if v, ok := usage["output_tokens"].(float64); ok { currentOutput := int(v) if currentOutput <= 1 || (estimatedOutput > currentOutput && estimatedOutput > 1) { usage["output_tokens"] = estimatedOutput outputPatched = true } } } if enableLog { if inputPatched || outputPatched { log.Printf("[Messages-Stream-Token-Patch] %s: InputTokens=%v -> %v, OutputTokens=%v -> %v", location, originalInput, usage["input_tokens"], originalOutput, usage["output_tokens"]) } log.Printf("[Messages-Stream-Token] %s: InputTokens=%v, OutputTokens=%v, CacheCreationInputTokens=%.0f, CacheReadInputTokens=%.0f, CacheCreation5m=%.0f, CacheCreation1h=%.0f, CacheTTL=%s", location, usage["input_tokens"], usage["output_tokens"], cacheCreation, cacheRead, cacheCreation5m, cacheCreation1h, cacheTTL) } } // abs 返回整数的绝对值 func abs(x int) int { if x < 0 { return -x } return x } // BuildStreamErrorEvent 构建流错误 SSE 事件 func BuildStreamErrorEvent(err error) string { errorEvent := map[string]interface{}{ "type": "error", "error": map[string]interface{}{ "type": "stream_error", "message": fmt.Sprintf("Stream processing error: %v", err), }, } eventJSON, _ := json.Marshal(errorEvent) return fmt.Sprintf("event: error\ndata: %s\n\n", eventJSON) } // BuildUsageEvent 构建带 usage 的 message_delta SSE 事件 func BuildUsageEvent(requestBody []byte, outputText string) string { inputTokens := utils.EstimateRequestTokens(requestBody) outputTokens := utils.EstimateTokens(outputText) event := map[string]interface{}{ "type": "message_delta", "usage": map[string]int{ "input_tokens": inputTokens, "output_tokens": outputTokens, }, } eventJSON, _ := json.Marshal(event) return fmt.Sprintf("event: message_delta\ndata: %s\n\n", eventJSON) } // IsMessageStartEvent 检测是否为 message_start 事件 func IsMessageStartEvent(event string) bool { return strings.Contains(event, "\"type\":\"message_start\"") || strings.Contains(event, "\"type\": \"message_start\"") } // PatchMessageStartEvent 修补 message_start 事件中的 id 和 model 字段 func PatchMessageStartEvent(event string, requestModel string, rewriteModel bool, enableLog bool) string { if !IsMessageStartEvent(event) { return event } var result strings.Builder lines := strings.Split(event, "\n") patched := false for _, line := range lines { if !strings.HasPrefix(line, "data: ") { result.WriteString(line) result.WriteString("\n") continue } jsonStr := strings.TrimPrefix(line, "data: ") var data map[string]interface{} if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { result.WriteString(line) result.WriteString("\n") continue } msg, ok := data["message"].(map[string]interface{}) if !ok { result.WriteString(line) result.WriteString("\n") continue } // 补全空 id if id, _ := msg["id"].(string); id == "" { msg["id"] = fmt.Sprintf("msg_%s", uuid.New().String()) patched = true if enableLog { log.Printf("[Messages-Stream-Patch] 补全空 message.id: %s", msg["id"]) } } // 检查 model 一致性(仅在配置启用时改写) if rewriteModel { if responseModel, _ := msg["model"].(string); responseModel != "" && requestModel != "" && responseModel != requestModel { msg["model"] = requestModel patched = true if enableLog { log.Printf("[Messages-Stream-Patch] 改写 message.model: %s -> %s", responseModel, requestModel) } } } if patched { patchedJSON, err := json.Marshal(data) if err != nil { result.WriteString(line) result.WriteString("\n") continue } result.WriteString("data: ") result.Write(patchedJSON) result.WriteString("\n") } else { result.WriteString(line) result.WriteString("\n") } } return result.String() } // IsMessageStopEvent 检测是否为 message_stop 事件 func IsMessageStopEvent(event string) bool { if strings.Contains(event, "event: message_stop") { return true } for _, line := range strings.Split(event, "\n") { if !strings.HasPrefix(line, "data: ") { continue } jsonStr := strings.TrimPrefix(line, "data: ") var data map[string]interface{} if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { continue } if data["type"] == "message_stop" { return true } } return false } // IsMessageDeltaEvent 检测是否为 message_delta 事件 func IsMessageDeltaEvent(event string) bool { if strings.Contains(event, "event: message_delta") { return true } for _, line := range strings.Split(event, "\n") { if !strings.HasPrefix(line, "data: ") { continue } jsonStr := strings.TrimPrefix(line, "data: ") var data map[string]interface{} if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { continue } if data["type"] == "message_delta" { return true } } return false } // ExtractInputTokensFromEvent 从 SSE 事件中提取 input_tokens // 支持 message_start 事件的 message.usage.input_tokens 和顶层 usage.input_tokens func ExtractInputTokensFromEvent(event string) int { for _, line := range strings.Split(event, "\n") { if !strings.HasPrefix(line, "data: ") { continue } jsonStr := strings.TrimPrefix(line, "data: ") var data map[string]interface{} if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { continue } // 检查 message.usage.input_tokens (message_start 事件) if msg, ok := data["message"].(map[string]interface{}); ok { if usage, ok := msg["usage"].(map[string]interface{}); ok { if v, ok := usage["input_tokens"].(float64); ok && v > 0 { return int(v) } } } // 检查顶层 usage.input_tokens (message_delta 事件) if usage, ok := data["usage"].(map[string]interface{}); ok { if v, ok := usage["input_tokens"].(float64); ok && v > 0 { return int(v) } } } return 0 } // ExtractTextFromEvent 从 SSE 事件中提取文本内容 func ExtractTextFromEvent(event string, buf *bytes.Buffer) { for _, line := range strings.Split(event, "\n") { if !strings.HasPrefix(line, "data: ") { continue } jsonStr := strings.TrimPrefix(line, "data: ") var data map[string]interface{} if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { continue } // Claude SSE: delta.text if delta, ok := data["delta"].(map[string]interface{}); ok { if text, ok := delta["text"].(string); ok { buf.WriteString(text) } if partialJSON, ok := delta["partial_json"].(string); ok { buf.WriteString(partialJSON) } } // content_block_start 中的初始文本 if cb, ok := data["content_block"].(map[string]interface{}); ok { if text, ok := cb["text"].(string); ok { buf.WriteString(text) } } } } // extractSSEEventInfo 从 SSE 事件中提取事件类型、block 索引和 block 类型 func extractSSEEventInfo(event string) (eventType string, blockIndex int, blockType string) { for _, line := range strings.Split(event, "\n") { if !strings.HasPrefix(line, "data: ") { continue } jsonStr := strings.TrimPrefix(line, "data: ") var data map[string]interface{} if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { continue } eventType, _ = data["type"].(string) if idx, ok := data["index"].(float64); ok { blockIndex = int(idx) } // 从 content_block 中提取类型 if cb, ok := data["content_block"].(map[string]interface{}); ok { blockType, _ = cb["type"].(string) } return } return } // truncateForLog 截断字符串用于日志输出 func truncateForLog(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] + "..." } ================================================ FILE: backend-go/internal/handlers/common/stream_test.go ================================================ package common import ( "encoding/json" "strings" "testing" "github.com/BenedictKing/claude-proxy/internal/utils" ) func TestPatchUsageFieldsWithLog_NilInputTokens(t *testing.T) { tests := []struct { name string usage map[string]interface{} estimatedInput int hasCacheTokens bool wantPatched bool wantValue int }{ { name: "nil input_tokens without cache - should patch", usage: map[string]interface{}{"input_tokens": nil, "output_tokens": float64(100)}, estimatedInput: 10920, hasCacheTokens: false, wantPatched: true, wantValue: 10920, }, { name: "nil input_tokens with cache - should also patch", usage: map[string]interface{}{"input_tokens": nil, "output_tokens": float64(100)}, estimatedInput: 10920, hasCacheTokens: true, wantPatched: true, wantValue: 10920, }, { name: "valid input_tokens - should not patch", usage: map[string]interface{}{"input_tokens": float64(5000), "output_tokens": float64(100)}, estimatedInput: 10920, hasCacheTokens: true, wantPatched: false, wantValue: 5000, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { patchUsageFieldsWithLog(tt.usage, tt.estimatedInput, 100, tt.hasCacheTokens, false, "test", false) if tt.wantPatched { if v, ok := tt.usage["input_tokens"].(int); !ok || v != tt.wantValue { t.Errorf("expected input_tokens=%d, got %v", tt.wantValue, tt.usage["input_tokens"]) } } else if tt.usage["input_tokens"] == nil { // nil case - expected to remain nil } else if v, ok := tt.usage["input_tokens"].(float64); ok && int(v) != tt.wantValue { t.Errorf("expected input_tokens=%d, got %v", tt.wantValue, tt.usage["input_tokens"]) } }) } } func TestPatchMessageStartInputTokensIfNeeded(t *testing.T) { requestBody := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hello world hello world hello world"}]}]}`) estimated := utils.EstimateRequestTokens(requestBody) if estimated <= 0 { t.Fatalf("expected estimated input tokens > 0, got %d", estimated) } extractInputTokens := func(t *testing.T, event string) float64 { t.Helper() for _, line := range strings.Split(event, "\n") { if !strings.HasPrefix(line, "data: ") { continue } var data map[string]interface{} if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &data); err != nil { t.Fatalf("failed to unmarshal data: %v", err) } msg, ok := data["message"].(map[string]interface{}) if !ok { t.Fatalf("missing message field") } usage, ok := msg["usage"].(map[string]interface{}) if !ok { t.Fatalf("missing message.usage field") } v, ok := usage["input_tokens"].(float64) if !ok { t.Fatalf("missing input_tokens field") } return v } t.Fatalf("no data line found") return 0 } t.Run("input_tokens=0 should patch in message_start", func(t *testing.T) { event := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}}\n\n" hasUsage, needInputPatch, _, usageData := CheckEventUsageStatus(event, false) if !hasUsage { t.Fatalf("expected hasUsage=true") } if !needInputPatch { t.Fatalf("expected needInputPatch=true") } patched := PatchMessageStartInputTokensIfNeeded(event, requestBody, needInputPatch, usageData, false, false) got := extractInputTokens(t, patched) if got != float64(estimated) { t.Fatalf("expected input_tokens=%d, got %v", estimated, got) } }) t.Run("input_tokens<10 should patch in message_start", func(t *testing.T) { event := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}\n\n" hasUsage, needInputPatch, _, usageData := CheckEventUsageStatus(event, false) if !hasUsage { t.Fatalf("expected hasUsage=true") } if needInputPatch { t.Fatalf("expected needInputPatch=false") } patched := PatchMessageStartInputTokensIfNeeded(event, requestBody, needInputPatch, usageData, false, false) got := extractInputTokens(t, patched) if got != float64(estimated) { t.Fatalf("expected input_tokens=%d, got %v", estimated, got) } }) t.Run("cache hit should not patch input_tokens", func(t *testing.T) { event := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"cache_read_input_tokens\":100}}}\n\n" hasUsage, needInputPatch, _, usageData := CheckEventUsageStatus(event, false) if !hasUsage { t.Fatalf("expected hasUsage=true") } if needInputPatch { t.Fatalf("expected needInputPatch=false") } patched := PatchMessageStartInputTokensIfNeeded(event, requestBody, needInputPatch, usageData, false, false) got := extractInputTokens(t, patched) if got != 0 { t.Fatalf("expected input_tokens=0, got %v", got) } }) t.Run("valid input_tokens should not patch", func(t *testing.T) { event := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":50,\"output_tokens\":0}}}\n\n" hasUsage, needInputPatch, _, usageData := CheckEventUsageStatus(event, false) if !hasUsage { t.Fatalf("expected hasUsage=true") } if needInputPatch { t.Fatalf("expected needInputPatch=false") } patched := PatchMessageStartInputTokensIfNeeded(event, requestBody, needInputPatch, usageData, false, false) got := extractInputTokens(t, patched) if got != 50 { t.Fatalf("expected input_tokens=50, got %v", got) } }) } // TestInferImplicitCacheRead 测试隐式缓存推断逻辑 func TestInferImplicitCacheRead(t *testing.T) { tests := []struct { name string messageStartInputTokens int collectedInputTokens int existingCacheRead int wantCacheRead int }{ { name: "large diff ratio (>10%) should infer cache", messageStartInputTokens: 100000, collectedInputTokens: 20000, existingCacheRead: 0, wantCacheRead: 80000, }, { name: "large diff value (>10k) should infer cache", messageStartInputTokens: 50000, collectedInputTokens: 38000, existingCacheRead: 0, wantCacheRead: 12000, }, { name: "small diff should not infer cache", messageStartInputTokens: 10000, collectedInputTokens: 9500, existingCacheRead: 0, wantCacheRead: 0, }, { name: "existing cache_read should not be overwritten", messageStartInputTokens: 100000, collectedInputTokens: 20000, existingCacheRead: 50000, wantCacheRead: 50000, }, { name: "zero message_start should not infer", messageStartInputTokens: 0, collectedInputTokens: 20000, existingCacheRead: 0, wantCacheRead: 0, }, { name: "zero collected should not infer", messageStartInputTokens: 100000, collectedInputTokens: 0, existingCacheRead: 0, wantCacheRead: 0, }, { name: "negative diff should not infer", messageStartInputTokens: 10000, collectedInputTokens: 15000, existingCacheRead: 0, wantCacheRead: 0, }, { name: "exactly 10% diff should not infer", messageStartInputTokens: 10000, collectedInputTokens: 9000, existingCacheRead: 0, wantCacheRead: 0, }, { name: "just over 10% diff should infer", messageStartInputTokens: 10000, collectedInputTokens: 8900, existingCacheRead: 0, wantCacheRead: 1100, }, { name: "10k diff but ratio <10% should infer (diff > 10k takes precedence)", messageStartInputTokens: 150000, collectedInputTokens: 139000, existingCacheRead: 0, wantCacheRead: 11000, }, { name: "diff exactly 10k with ratio <10% should not infer", messageStartInputTokens: 150000, collectedInputTokens: 140000, existingCacheRead: 0, wantCacheRead: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := &StreamContext{ MessageStartInputTokens: tt.messageStartInputTokens, CollectedUsage: CollectedUsageData{ InputTokens: tt.collectedInputTokens, CacheReadInputTokens: tt.existingCacheRead, }, } inferImplicitCacheRead(ctx, false) if ctx.CollectedUsage.CacheReadInputTokens != tt.wantCacheRead { t.Errorf("CacheReadInputTokens = %d, want %d", ctx.CollectedUsage.CacheReadInputTokens, tt.wantCacheRead) } }) } } // TestPatchTokensInEventWithCache 测试带缓存推断的事件修补 func TestPatchTokensInEventWithCache(t *testing.T) { extractCacheRead := func(t *testing.T, event string) float64 { t.Helper() for _, line := range strings.Split(event, "\n") { if !strings.HasPrefix(line, "data: ") { continue } var data map[string]interface{} if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &data); err != nil { continue } if usage, ok := data["usage"].(map[string]interface{}); ok { if v, ok := usage["cache_read_input_tokens"].(float64); ok { return v } } } return 0 } t.Run("should write inferred cache_read when not present", func(t *testing.T) { event := "event: message_delta\ndata: {\"type\":\"message_delta\",\"usage\":{\"input_tokens\":20000,\"output_tokens\":100}}\n\n" patched := PatchTokensInEventWithCache(event, 20000, 100, 80000, true, false, false) got := extractCacheRead(t, patched) if got != 80000 { t.Errorf("expected cache_read_input_tokens=80000, got %v", got) } }) t.Run("should not overwrite existing cache_read", func(t *testing.T) { event := "event: message_delta\ndata: {\"type\":\"message_delta\",\"usage\":{\"input_tokens\":20000,\"output_tokens\":100,\"cache_read_input_tokens\":50000}}\n\n" patched := PatchTokensInEventWithCache(event, 20000, 100, 80000, true, false, false) got := extractCacheRead(t, patched) if got != 50000 { t.Errorf("expected cache_read_input_tokens=50000 (unchanged), got %v", got) } }) t.Run("should not write when inferredCacheRead is 0", func(t *testing.T) { event := "event: message_delta\ndata: {\"type\":\"message_delta\",\"usage\":{\"input_tokens\":20000,\"output_tokens\":100}}\n\n" patched := PatchTokensInEventWithCache(event, 20000, 100, 0, false, false, false) got := extractCacheRead(t, patched) if got != 0 { t.Errorf("expected cache_read_input_tokens=0, got %v", got) } }) t.Run("should not overwrite explicit zero from upstream", func(t *testing.T) { // 上游显式返回 cache_read_input_tokens: 0 表示"明确无缓存",不应被推断值覆盖 event := "event: message_delta\ndata: {\"type\":\"message_delta\",\"usage\":{\"input_tokens\":20000,\"output_tokens\":100,\"cache_read_input_tokens\":0}}\n\n" patched := PatchTokensInEventWithCache(event, 20000, 100, 80000, true, false, false) got := extractCacheRead(t, patched) if got != 0 { t.Errorf("expected cache_read_input_tokens=0 (explicit zero preserved), got %v", got) } }) } ================================================ FILE: backend-go/internal/handlers/common/upstream_failover.go ================================================ // Package common 提供 handlers 模块的公共功能 package common import ( "context" "errors" "fmt" "io" "log" "net/http" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/metrics" "github.com/BenedictKing/claude-proxy/internal/scheduler" "github.com/BenedictKing/claude-proxy/internal/types" "github.com/BenedictKing/claude-proxy/internal/utils" "github.com/BenedictKing/claude-proxy/internal/warmup" "github.com/gin-gonic/gin" ) // isClientSideError 判断错误是否由客户端明确取消(不应计入渠道失败) // 仅识别 context.Canceled,broken pipe/connection reset 视为连接故障需要 failover func isClientSideError(err error) bool { if err == nil { return false } // 只有 context.Canceled 才是明确的客户端取消意图 return errors.Is(err, context.Canceled) } // NextAPIKeyFunc 返回下一个可用 API key(按 failover 策略) type NextAPIKeyFunc func(upstream *config.UpstreamConfig, failedKeys map[string]bool) (string, error) // BuildRequestFunc 构建上游请求(upstreamCopy.BaseURL 已写入当前尝试的 BaseURL) type BuildRequestFunc func(c *gin.Context, upstreamCopy *config.UpstreamConfig, apiKey string) (*http.Request, error) // DeprioritizeKeyFunc 对 quota 相关失败的 key 做降级(实现可选择是否记录日志) type DeprioritizeKeyFunc func(apiKey string) // HandleSuccessFunc 处理成功响应(负责写回客户端),并返回 usage(可为 nil) // 注意:实现方需要自行关闭 resp.Body(与现有 handlers 保持一致)。 type HandleSuccessFunc func(c *gin.Context, resp *http.Response, upstreamCopy *config.UpstreamConfig, apiKey string) (*types.Usage, error) // TryUpstreamWithAllKeys 尝试一个 upstream 的所有 BaseURL + Key(纯 failover) // 返回: // - handled: 是否已向客户端写回响应(成功或非 failover 错误) // - successKey: 成功的 key(仅 handled=true 且成功时有值) // - successBaseURLIdx: 成功 BaseURL 的原始索引(用于指标记录) // - failoverErr: 最后一次可故障转移的上游错误(用于多渠道聚合错误) // - usage: usage 统计(可能为 nil) func TryUpstreamWithAllKeys( c *gin.Context, envCfg *config.EnvConfig, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler, kind scheduler.ChannelKind, apiType string, metricsManager *metrics.MetricsManager, upstream *config.UpstreamConfig, urlResults []warmup.URLLatencyResult, requestBody []byte, isStream bool, nextAPIKey NextAPIKeyFunc, buildRequest BuildRequestFunc, deprioritizeKey DeprioritizeKeyFunc, markURLFailure func(url string), markURLSuccess func(url string), handleSuccess HandleSuccessFunc, ) (handled bool, successKey string, successBaseURLIdx int, failoverErr *FailoverError, usage *types.Usage, lastError error) { if upstream == nil || len(upstream.APIKeys) == 0 { return false, "", 0, nil, nil, nil } if metricsManager == nil { return false, "", 0, nil, nil, nil } if nextAPIKey == nil || buildRequest == nil || handleSuccess == nil { return false, "", 0, nil, nil, nil } if len(urlResults) == 0 { return false, "", 0, nil, nil, nil } var lastFailoverError *FailoverError deprioritizeCandidates := make(map[string]bool) // 强制探测模式:基于本次优先尝试的 BaseURL 判断(避免 BaseURL/BaseURLs 不一致导致误判) forceProbeMode := AreAllKeysSuspended(metricsManager, urlResults[0].URL, upstream.APIKeys) if forceProbeMode { log.Printf("[%s-ForceProbe] 渠道 %s 所有 Key 都被熔断,启用强制探测模式", apiType, upstream.Name) } for urlIdx, urlResult := range urlResults { currentBaseURL := urlResult.URL originalIdx := urlResult.OriginalIdx // 原始索引用于指标记录 failedKeys := make(map[string]bool) // 每个 BaseURL 重置失败 Key 列表 maxRetries := len(upstream.APIKeys) for attempt := 0; attempt < maxRetries; attempt++ { RestoreRequestBody(c, requestBody) apiKey, err := nextAPIKey(upstream, failedKeys) if err != nil { lastError = err break // 当前 BaseURL 没有可用 Key,尝试下一个 BaseURL } // 检查熔断状态 if !forceProbeMode && metricsManager.ShouldSuspendKey(currentBaseURL, apiKey) { failedKeys[apiKey] = true log.Printf("[%s-Circuit] 跳过熔断中的 Key: %s", apiType, utils.MaskAPIKey(apiKey)) continue } if envCfg.ShouldLog("info") { log.Printf("[%s-Key] 使用API密钥: %s (BaseURL %d/%d, 尝试 %d/%d)", apiType, utils.MaskAPIKey(apiKey), urlIdx+1, len(urlResults), attempt+1, maxRetries) } // 使用深拷贝避免并发修改问题 upstreamCopy := upstream.Clone() upstreamCopy.BaseURL = currentBaseURL req, err := buildRequest(c, upstreamCopy, apiKey) if err != nil { lastError = err failedKeys[apiKey] = true channelScheduler.RecordFailure(currentBaseURL, apiKey, kind) continue } // 记录请求开始 channelScheduler.RecordRequestStart(currentBaseURL, apiKey, kind) // TCP 建连开始即计数:将活跃度统计提前到发起上游请求之前 requestID := metricsManager.RecordRequestConnected(currentBaseURL, apiKey) resp, err := SendRequest(req, upstream, envCfg, isStream, apiType) if err != nil { lastError = err // 区分客户端取消和真实渠道故障(统一口径) if isClientSideError(err) { // 客户端取消:不计入失败,不触发 failover metricsManager.RecordRequestFinalizeClientCancel(currentBaseURL, apiKey, requestID) channelScheduler.RecordRequestEnd(currentBaseURL, apiKey, kind) log.Printf("[%s-Cancel] 请求已取消(SendRequest 阶段)", apiType) return true, "", 0, nil, nil, err } // 真实渠道故障:计入失败,继续 failover failedKeys[apiKey] = true cfgManager.MarkKeyAsFailed(apiKey, apiType) metricsManager.RecordRequestFinalizeFailure(currentBaseURL, apiKey, requestID) channelScheduler.RecordRequestEnd(currentBaseURL, apiKey, kind) if markURLFailure != nil { markURLFailure(currentBaseURL) } log.Printf("[%s-Key] 警告: API密钥失败: %v", apiType, err) continue } if resp.StatusCode < 200 || resp.StatusCode >= 300 { respBodyBytes, _ := io.ReadAll(resp.Body) resp.Body.Close() respBodyBytes = utils.DecompressGzipIfNeeded(resp, respBodyBytes) shouldFailover, isQuotaRelated := ShouldRetryWithNextKey(resp.StatusCode, respBodyBytes, cfgManager.GetFuzzyModeEnabled(), apiType) if shouldFailover { lastError = fmt.Errorf("上游错误: %d", resp.StatusCode) failedKeys[apiKey] = true cfgManager.MarkKeyAsFailed(apiKey, apiType) metricsManager.RecordRequestFinalizeFailure(currentBaseURL, apiKey, requestID) channelScheduler.RecordRequestEnd(currentBaseURL, apiKey, kind) if markURLFailure != nil { markURLFailure(currentBaseURL) } log.Printf("[%s-Key] 警告: API密钥失败 (状态: %d),尝试下一个密钥", apiType, resp.StatusCode) lastFailoverError = &FailoverError{ Status: resp.StatusCode, Body: respBodyBytes, } if isQuotaRelated { deprioritizeCandidates[apiKey] = true } continue } // 非 failover 错误,记录失败指标后返回(请求已处理) metricsManager.RecordRequestFinalizeFailure(currentBaseURL, apiKey, requestID) channelScheduler.RecordRequestEnd(currentBaseURL, apiKey, kind) c.Data(resp.StatusCode, "application/json", respBodyBytes) return true, "", 0, nil, nil, nil } // 成功响应:处理 quota key 降级 if deprioritizeKey != nil && len(deprioritizeCandidates) > 0 { for key := range deprioritizeCandidates { deprioritizeKey(key) } } if markURLSuccess != nil { markURLSuccess(currentBaseURL) } usage, err = handleSuccess(c, resp, upstreamCopy, apiKey) if err != nil { lastError = err // 区分客户端错误和渠道故障 if isClientSideError(err) { // 客户端取消/断开:计入总请求数但不计入失败 metricsManager.RecordRequestFinalizeClientCancel(currentBaseURL, apiKey, requestID) channelScheduler.RecordRequestEnd(currentBaseURL, apiKey, kind) log.Printf("[%s-Cancel] 请求已取消,停止渠道 failover", apiType) } else { // 真实渠道故障:计入失败指标 cfgManager.MarkKeyAsFailed(apiKey, apiType) metricsManager.RecordRequestFinalizeFailure(currentBaseURL, apiKey, requestID) channelScheduler.RecordRequestEnd(currentBaseURL, apiKey, kind) log.Printf("[%s-Key] 警告: 响应处理失败: %v", apiType, err) } return true, "", 0, nil, usage, err } metricsManager.RecordRequestFinalizeSuccess(currentBaseURL, apiKey, requestID, usage) channelScheduler.RecordRequestEnd(currentBaseURL, apiKey, kind) return true, apiKey, originalIdx, nil, usage, nil } // 当前 BaseURL 的所有 Key 都失败,记录并尝试下一个 BaseURL if envCfg.ShouldLog("info") && urlIdx < len(urlResults)-1 { log.Printf("[%s-BaseURL] BaseURL %d/%d 所有 Key 失败,切换到下一个 BaseURL", apiType, urlIdx+1, len(urlResults)) } } return false, "", 0, lastFailoverError, nil, lastError } // BuildDefaultURLResults 将 URLs 转为按原始顺序的结果列表(无动态排序) func BuildDefaultURLResults(urls []string) []warmup.URLLatencyResult { results := make([]warmup.URLLatencyResult, len(urls)) for i, url := range urls { results[i] = warmup.URLLatencyResult{ URL: url, OriginalIdx: i, Success: true, } } return results } ================================================ FILE: backend-go/internal/handlers/frontend.go ================================================ package handlers import ( "embed" "io/fs" "net/http" "strings" "github.com/gin-gonic/gin" ) // ServeFrontend 提供前端静态文件服务 func ServeFrontend(r *gin.Engine, frontendFS embed.FS) { // 从嵌入的文件系统中提取 frontend/dist 子目录 distFS, err := fs.Sub(frontendFS, "frontend/dist") if err != nil { // 如果提取失败,返回错误页面 r.GET("/", func(c *gin.Context) { c.Data(503, "text/html; charset=utf-8", []byte(getErrorPage())) }) return } // 使用 Gin 的静态文件服务 - /assets 路由 r.StaticFS("/assets", http.FS(distFS)) // 根路径返回 index.html r.GET("/", func(c *gin.Context) { indexContent, err := fs.ReadFile(distFS, "index.html") if err != nil { c.Data(503, "text/html; charset=utf-8", []byte(getErrorPage())) return } c.Data(200, "text/html; charset=utf-8", indexContent) }) // NoRoute 处理器 - 智能SPA支持 r.NoRoute(func(c *gin.Context) { path := c.Request.URL.Path // API 路由优先处理 - 返回 JSON 格式的 404 if isAPIPath(path) { c.JSON(http.StatusNotFound, gin.H{ "error": "API endpoint not found", "path": path, "message": "请求的API端点不存在", }) return } // 去掉开头的 / if len(path) > 0 && path[0] == '/' { path = path[1:] } // 尝试从嵌入的文件系统读取文件 fileContent, err := fs.ReadFile(distFS, path) if err == nil { // 文件存在,根据扩展名设置正确的 Content-Type contentType := getContentType(path) c.Data(200, contentType, fileContent) return } // 文件不存在,返回 index.html (SPA 路由支持) indexContent, err := fs.ReadFile(distFS, "index.html") if err != nil { c.Data(503, "text/html; charset=utf-8", []byte(getErrorPage())) return } c.Data(200, "text/html; charset=utf-8", indexContent) }) } // isAPIPath 检查路径是否为 API 端点 func isAPIPath(path string) bool { // API 路由前缀列表 apiPrefixes := []string{ "/v1/", // Claude API 代理端点 "/api/", // Web 管理界面 API "/admin/", // 管理端点 } for _, prefix := range apiPrefixes { if strings.HasPrefix(path, prefix) { return true } } return false } // getContentType 根据文件扩展名返回 Content-Type func getContentType(path string) string { if len(path) == 0 { return "text/html; charset=utf-8" } // 从路径末尾查找扩展名 ext := "" for i := len(path) - 1; i >= 0 && path[i] != '/'; i-- { if path[i] == '.' { ext = path[i:] break } } switch ext { case ".html": return "text/html; charset=utf-8" case ".css": return "text/css; charset=utf-8" case ".js": return "application/javascript; charset=utf-8" case ".json": return "application/json; charset=utf-8" case ".png": return "image/png" case ".jpg", ".jpeg": return "image/jpeg" case ".gif": return "image/gif" case ".svg": return "image/svg+xml" case ".ico": return "image/x-icon" case ".woff": return "font/woff" case ".woff2": return "font/woff2" case ".ttf": return "font/ttf" case ".eot": return "application/vnd.ms-fontobject" default: return "application/octet-stream" } } // getErrorPage 获取错误页面 func getErrorPage() string { return ` Claude Proxy - 配置错误

前端资源未找到

无法找到前端构建文件。请执行以下步骤之一:

方案1: 重新构建(推荐)

./build.sh

方案2: 禁用Web界面

.env 文件中设置: ENABLE_WEB_UI=false

然后只使用API端点: /v1/messages

` } ================================================ FILE: backend-go/internal/handlers/gemini/channels.go ================================================ // Package gemini 提供 Gemini API 的渠道管理 package gemini import ( "fmt" "net/http" "strconv" "strings" "time" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/scheduler" "github.com/gin-gonic/gin" ) // GetUpstreams 获取 Gemini 上游列表 func GetUpstreams(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { cfg := cfgManager.GetConfig() upstreams := make([]gin.H, len(cfg.GeminiUpstream)) for i, up := range cfg.GeminiUpstream { status := config.GetChannelStatus(&up) priority := config.GetChannelPriority(&up, i) upstreams[i] = gin.H{ "index": i, "name": up.Name, "serviceType": up.ServiceType, "baseUrl": up.BaseURL, "baseUrls": up.BaseURLs, "apiKeys": up.APIKeys, "description": up.Description, "website": up.Website, "insecureSkipVerify": up.InsecureSkipVerify, "modelMapping": up.ModelMapping, "latency": nil, "status": status, "priority": priority, "promotionUntil": up.PromotionUntil, "lowQuality": up.LowQuality, "injectDummyThoughtSignature": up.InjectDummyThoughtSignature, "stripThoughtSignature": up.StripThoughtSignature, } } c.JSON(200, gin.H{ "channels": upstreams, "loadBalance": cfg.GeminiLoadBalance, }) } } // AddUpstream 添加 Gemini 上游 func AddUpstream(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { var upstream config.UpstreamConfig if err := c.ShouldBindJSON(&upstream); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } if err := cfgManager.AddGeminiUpstream(upstream); err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{"message": "Gemini upstream added successfully"}) } } // UpdateUpstream 更新 Gemini 上游 func UpdateUpstream(cfgManager *config.ConfigManager, sch *scheduler.ChannelScheduler) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid upstream ID"}) return } var updates config.UpstreamUpdate if err := c.ShouldBindJSON(&updates); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } shouldResetMetrics, err := cfgManager.UpdateGeminiUpstream(id, updates) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } // 单 key 更换时重置熔断状态 if shouldResetMetrics { sch.ResetChannelMetrics(id, scheduler.ChannelKindGemini) } c.JSON(200, gin.H{"message": "Gemini upstream updated successfully"}) } } // DeleteUpstream 删除 Gemini 上游 func DeleteUpstream(cfgManager *config.ConfigManager, sch *scheduler.ChannelScheduler) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid upstream ID"}) return } removed, err := cfgManager.RemoveGeminiUpstream(id) if err != nil { if strings.Contains(err.Error(), "无效的") { c.JSON(404, gin.H{"error": "Upstream not found"}) } else { c.JSON(500, gin.H{"error": err.Error()}) } return } // 删除成功后清理指标数据(使用 RemoveGeminiUpstream 返回的渠道信息) sch.DeleteChannelMetrics(removed, scheduler.ChannelKindGemini) c.JSON(200, gin.H{"message": "Gemini upstream deleted successfully"}) } } // AddApiKey 添加 Gemini 渠道 API 密钥 func AddApiKey(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid upstream ID"}) return } var req struct { APIKey string `json:"apiKey"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "Invalid request body"}) return } if err := cfgManager.AddGeminiAPIKey(id, req.APIKey); err != nil { if strings.Contains(err.Error(), "无效的上游索引") { c.JSON(404, gin.H{"error": "Upstream not found"}) } else if strings.Contains(err.Error(), "API密钥已存在") { c.JSON(400, gin.H{"error": "API密钥已存在"}) } else { c.JSON(500, gin.H{"error": "Failed to save config"}) } return } c.JSON(200, gin.H{ "message": "API密钥已添加", "success": true, }) } } // DeleteApiKey 删除 Gemini 渠道 API 密钥 func DeleteApiKey(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid upstream ID"}) return } apiKey := c.Param("apiKey") if apiKey == "" { c.JSON(400, gin.H{"error": "API key is required"}) return } if err := cfgManager.RemoveGeminiAPIKey(id, apiKey); err != nil { if strings.Contains(err.Error(), "无效的上游索引") { c.JSON(404, gin.H{"error": "Upstream not found"}) } else if strings.Contains(err.Error(), "API密钥不存在") { c.JSON(404, gin.H{"error": "API key not found"}) } else { c.JSON(500, gin.H{"error": "Failed to save config"}) } return } c.JSON(200, gin.H{ "message": "API密钥已删除", }) } } // MoveApiKeyToTop 将 Gemini 渠道 API 密钥移到最前面 func MoveApiKeyToTop(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) apiKey := c.Param("apiKey") if err := cfgManager.MoveGeminiAPIKeyToTop(id, apiKey); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{"message": "API密钥已置顶"}) } } // MoveApiKeyToBottom 将 Gemini 渠道 API 密钥移到最后面 func MoveApiKeyToBottom(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) apiKey := c.Param("apiKey") if err := cfgManager.MoveGeminiAPIKeyToBottom(id, apiKey); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{"message": "API密钥已置底"}) } } // ReorderChannels 重新排序 Gemini 渠道优先级 func ReorderChannels(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { var req struct { Order []int `json:"order"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "Invalid request body"}) return } if err := cfgManager.ReorderGeminiUpstreams(req.Order); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{ "success": true, "message": "Gemini 渠道优先级已更新", }) } } // SetChannelStatus 设置 Gemini 渠道状态 func SetChannelStatus(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid channel ID"}) return } var req struct { Status string `json:"status"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "Invalid request body"}) return } if err := cfgManager.SetGeminiChannelStatus(id, req.Status); err != nil { if strings.Contains(err.Error(), "无效的上游索引") { c.JSON(404, gin.H{"error": "Channel not found"}) } else { c.JSON(400, gin.H{"error": err.Error()}) } return } c.JSON(200, gin.H{ "success": true, "message": "Gemini 渠道状态已更新", "status": req.Status, }) } } // SetChannelPromotion 设置 Gemini 渠道促销期 func SetChannelPromotion(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid channel ID"}) return } var req struct { Duration int `json:"duration"` // 促销期时长(秒),0 表示清除 } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "Invalid request body"}) return } duration := time.Duration(req.Duration) * time.Second if err := cfgManager.SetGeminiChannelPromotion(id, duration); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } if req.Duration <= 0 { c.JSON(200, gin.H{ "success": true, "message": "Gemini 渠道促销期已清除", }) } else { c.JSON(200, gin.H{ "success": true, "message": "Gemini 渠道促销期已设置", "duration": req.Duration, }) } } } // PingChannel 测试 Gemini 渠道连通性 func PingChannel(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid channel ID"}) return } cfg := cfgManager.GetConfig() if id < 0 || id >= len(cfg.GeminiUpstream) { c.JSON(404, gin.H{"error": "Channel not found"}) return } upstream := cfg.GeminiUpstream[id] baseURL := upstream.GetEffectiveBaseURL() if baseURL == "" { c.JSON(400, gin.H{"error": "No base URL configured"}) return } // 简单的连通性测试 client := &http.Client{Timeout: 10 * time.Second} testURL := fmt.Sprintf("%s/v1beta/models", strings.TrimRight(baseURL, "/")) req, _ := http.NewRequest("GET", testURL, nil) if len(upstream.APIKeys) > 0 { req.Header.Set("x-goog-api-key", upstream.APIKeys[0]) } start := time.Now() resp, err := client.Do(req) latency := time.Since(start).Milliseconds() if err != nil { c.JSON(200, gin.H{ "success": false, "error": err.Error(), "latency": latency, }) return } defer resp.Body.Close() c.JSON(200, gin.H{ "success": resp.StatusCode >= 200 && resp.StatusCode < 400, "statusCode": resp.StatusCode, "latency": latency, }) } } // PingAllChannels 测试所有 Gemini 渠道连通性 func PingAllChannels(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { cfg := cfgManager.GetConfig() results := make([]gin.H, len(cfg.GeminiUpstream)) client := &http.Client{Timeout: 10 * time.Second} for i, upstream := range cfg.GeminiUpstream { baseURL := upstream.GetEffectiveBaseURL() if baseURL == "" { results[i] = gin.H{ "index": i, "name": upstream.Name, "success": false, "error": "No base URL configured", } continue } testURL := fmt.Sprintf("%s/v1beta/models", strings.TrimRight(baseURL, "/")) req, _ := http.NewRequest("GET", testURL, nil) if len(upstream.APIKeys) > 0 { req.Header.Set("x-goog-api-key", upstream.APIKeys[0]) } start := time.Now() resp, err := client.Do(req) latency := time.Since(start).Milliseconds() if err != nil { results[i] = gin.H{ "index": i, "name": upstream.Name, "success": false, "error": err.Error(), "latency": latency, } continue } resp.Body.Close() results[i] = gin.H{ "index": i, "name": upstream.Name, "success": resp.StatusCode >= 200 && resp.StatusCode < 400, "statusCode": resp.StatusCode, "latency": latency, } } c.JSON(200, gin.H{ "channels": results, }) } } // UpdateLoadBalance 更新 Gemini 负载均衡策略 func UpdateLoadBalance(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { var req struct { Strategy string `json:"strategy"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "Invalid request body"}) return } if err := cfgManager.SetGeminiLoadBalance(req.Strategy); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{ "success": true, "message": "Gemini 负载均衡策略已更新", "strategy": req.Strategy, }) } } ================================================ FILE: backend-go/internal/handlers/gemini/dashboard.go ================================================ package gemini import ( "github.com/gin-gonic/gin" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/metrics" "github.com/BenedictKing/claude-proxy/internal/scheduler" ) // GetDashboard 获取 Gemini 渠道仪表盘数据(合并 channels + metrics + stats + recentActivity) // GET /api/gemini/channels/dashboard // 将原本需要 3 个请求的数据合并为 1 个请求,减少网络开销 func GetDashboard(cfgManager *config.ConfigManager, sch *scheduler.ChannelScheduler) gin.HandlerFunc { return func(c *gin.Context) { cfg := cfgManager.GetConfig() upstreams := cfg.GeminiUpstream loadBalance := cfg.GeminiLoadBalance metricsManager := sch.GetGeminiMetricsManager() // 1. 构建 channels 数据 channels := make([]gin.H, len(upstreams)) for i, up := range upstreams { status := config.GetChannelStatus(&up) priority := config.GetChannelPriority(&up, i) channels[i] = gin.H{ "index": i, "name": up.Name, "serviceType": up.ServiceType, "baseUrl": up.BaseURL, "baseUrls": up.BaseURLs, "apiKeys": up.APIKeys, "description": up.Description, "website": up.Website, "insecureSkipVerify": up.InsecureSkipVerify, "modelMapping": up.ModelMapping, "latency": nil, "status": status, "priority": priority, "promotionUntil": up.PromotionUntil, "lowQuality": up.LowQuality, "injectDummyThoughtSignature": up.InjectDummyThoughtSignature, "stripThoughtSignature": up.StripThoughtSignature, } } // 2. 构建 metrics 数据 metricsResult := make([]gin.H, 0, len(upstreams)) for i, upstream := range upstreams { resp := metricsManager.ToResponseMultiURL(i, upstream.GetAllBaseURLs(), upstream.APIKeys, 0, upstream.HistoricalAPIKeys) item := gin.H{ "channelIndex": i, "channelName": upstream.Name, "requestCount": resp.RequestCount, "successCount": resp.SuccessCount, "failureCount": resp.FailureCount, "successRate": resp.SuccessRate, "errorRate": resp.ErrorRate, "consecutiveFailures": resp.ConsecutiveFailures, "latency": resp.Latency, "keyMetrics": resp.KeyMetrics, "timeWindows": resp.TimeWindows, } if resp.LastSuccessAt != nil { item["lastSuccessAt"] = *resp.LastSuccessAt } if resp.LastFailureAt != nil { item["lastFailureAt"] = *resp.LastFailureAt } if resp.CircuitBrokenAt != nil { item["circuitBrokenAt"] = *resp.CircuitBrokenAt } metricsResult = append(metricsResult, item) } // 3. 构建 stats 数据 stats := gin.H{ "multiChannelMode": sch.IsMultiChannelMode(scheduler.ChannelKindGemini), "activeChannelCount": sch.GetActiveChannelCount(scheduler.ChannelKindGemini), "traceAffinityCount": sch.GetTraceAffinityManager().Size(), "traceAffinityTTL": sch.GetTraceAffinityManager().GetTTL().String(), "failureThreshold": metricsManager.GetFailureThreshold() * 100, "windowSize": metricsManager.GetWindowSize(), "circuitRecoveryTime": metricsManager.GetCircuitRecoveryTime().String(), } // 4. 构建 recentActivity 数据(最近 15 分钟分段活跃度) recentActivity := make([]*metrics.ChannelRecentActivity, len(upstreams)) for i, upstream := range upstreams { recentActivity[i] = metricsManager.GetRecentActivityMultiURL(i, upstream.GetAllBaseURLs(), upstream.APIKeys) } // 返回合并数据 c.JSON(200, gin.H{ "channels": channels, "loadBalance": loadBalance, "metrics": metricsResult, "stats": stats, "recentActivity": recentActivity, }) } } ================================================ FILE: backend-go/internal/handlers/gemini/dashboard_test.go ================================================ package gemini import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/metrics" "github.com/BenedictKing/claude-proxy/internal/scheduler" "github.com/BenedictKing/claude-proxy/internal/session" "github.com/BenedictKing/claude-proxy/internal/warmup" "github.com/gin-gonic/gin" ) func TestGetDashboard_IncludesStripThoughtSignature(t *testing.T) { gin.SetMode(gin.TestMode) cfg := config.Config{ GeminiUpstream: []config.UpstreamConfig{ { Name: "gemini-test", ServiceType: "gemini", BaseURL: "https://example.com", APIKeys: []string{"test-key"}, StripThoughtSignature: true, }, }, GeminiLoadBalance: "round-robin", } tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "config.json") data, err := json.MarshalIndent(cfg, "", " ") if err != nil { t.Fatalf("序列化配置失败: %v", err) } if err := os.WriteFile(configFile, data, 0644); err != nil { t.Fatalf("写入配置文件失败: %v", err) } cfgManager, err := config.NewConfigManager(configFile) if err != nil { t.Fatalf("创建配置管理器失败: %v", err) } t.Cleanup(func() { cfgManager.Close() }) messagesMetrics := metrics.NewMetricsManager() responsesMetrics := metrics.NewMetricsManager() geminiMetrics := metrics.NewMetricsManager() t.Cleanup(func() { messagesMetrics.Stop() responsesMetrics.Stop() geminiMetrics.Stop() }) traceAffinity := session.NewTraceAffinityManager() urlManager := warmup.NewURLManager(30*time.Second, 3) sch := scheduler.NewChannelScheduler(cfgManager, messagesMetrics, responsesMetrics, geminiMetrics, traceAffinity, urlManager) r := gin.New() r.GET("/gemini/channels/dashboard", GetDashboard(cfgManager, sch)) req := httptest.NewRequest(http.MethodGet, "/gemini/channels/dashboard", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status=%d, want=%d, body=%s", w.Code, http.StatusOK, w.Body.String()) } var resp struct { Channels []map[string]any `json:"channels"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("解析响应失败: %v", err) } if len(resp.Channels) != 1 { t.Fatalf("channels len=%d, want=1", len(resp.Channels)) } value, ok := resp.Channels[0]["stripThoughtSignature"] if !ok { t.Fatalf("响应缺少 stripThoughtSignature 字段: %v", resp.Channels[0]) } strip, ok := value.(bool) if !ok { t.Fatalf("stripThoughtSignature 类型=%T, want=bool", value) } if strip != true { t.Fatalf("stripThoughtSignature=%v, want=true", strip) } } ================================================ FILE: backend-go/internal/handlers/gemini/handler.go ================================================ // Package gemini 提供 Gemini API 的处理器 package gemini import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "strings" "time" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/converters" "github.com/BenedictKing/claude-proxy/internal/handlers/common" "github.com/BenedictKing/claude-proxy/internal/middleware" "github.com/BenedictKing/claude-proxy/internal/scheduler" "github.com/BenedictKing/claude-proxy/internal/types" "github.com/BenedictKing/claude-proxy/internal/utils" "github.com/gin-gonic/gin" ) // Handler Gemini API 代理处理器 // 支持多渠道调度:当配置多个渠道时自动启用 func Handler( envCfg *config.EnvConfig, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler, ) gin.HandlerFunc { return gin.HandlerFunc(func(c *gin.Context) { // Gemini 代理端点统一使用代理访问密钥鉴权(x-api-key / Authorization: Bearer) middleware.ProxyAuthMiddleware(envCfg)(c) if c.IsAborted() { return } startTime := time.Now() // 读取原始请求体 maxBodySize := envCfg.MaxRequestBodySize bodyBytes, err := common.ReadRequestBody(c, maxBodySize) if err != nil { return } // 解析 Gemini 请求 var geminiReq types.GeminiRequest if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, &geminiReq); err != nil { c.JSON(400, types.GeminiError{ Error: types.GeminiErrorDetail{ Code: 400, Message: fmt.Sprintf("Invalid request body: %v", err), Status: "INVALID_ARGUMENT", }, }) return } } // 从 URL 路径提取模型名称 // 格式: /v1/models/{model}:generateContent 或 /v1/models/{model}:streamGenerateContent // 使用 *modelAction 通配符捕获整个后缀,如 /gemini-pro:generateContent modelAction := c.Param("modelAction") // 移除前导斜杠(Gin 的 * 通配符会保留前导斜杠) modelAction = strings.TrimPrefix(modelAction, "/") model := extractModelName(modelAction) if model == "" { c.JSON(400, types.GeminiError{ Error: types.GeminiErrorDetail{ Code: 400, Message: "Model name is required in URL path", Status: "INVALID_ARGUMENT", }, }) return } // 判断是否流式 isStream := strings.Contains(c.Request.URL.Path, "streamGenerateContent") // 提取对话标识用于 Trace 亲和性 userID := common.ExtractConversationID(c, bodyBytes) // 记录原始请求信息 common.LogOriginalRequest(c, bodyBytes, envCfg, "Gemini") // 检查是否为多渠道模式 isMultiChannel := channelScheduler.IsMultiChannelMode(scheduler.ChannelKindGemini) if isMultiChannel { handleMultiChannel(c, envCfg, cfgManager, channelScheduler, bodyBytes, &geminiReq, model, isStream, userID, startTime) } else { handleSingleChannel(c, envCfg, cfgManager, channelScheduler, bodyBytes, &geminiReq, model, isStream, startTime) } }) } // extractModelName 从 URL 参数提取模型名称 // 输入: "gemini-2.0-flash:generateContent" 或 "gemini-2.0-flash" // 输出: "gemini-2.0-flash" func extractModelName(param string) string { if param == "" { return "" } // 移除 :generateContent 或 :streamGenerateContent 后缀 if idx := strings.Index(param, ":"); idx > 0 { return param[:idx] } return param } // handleMultiChannel 处理多渠道 Gemini 请求 func handleMultiChannel( c *gin.Context, envCfg *config.EnvConfig, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler, bodyBytes []byte, geminiReq *types.GeminiRequest, model string, isStream bool, userID string, startTime time.Time, ) { metricsManager := channelScheduler.GetGeminiMetricsManager() common.HandleMultiChannelFailover( c, envCfg, channelScheduler, scheduler.ChannelKindGemini, "Gemini", userID, func(selection *scheduler.SelectionResult) common.MultiChannelAttemptResult { upstream := selection.Upstream channelIndex := selection.ChannelIndex if upstream == nil { return common.MultiChannelAttemptResult{} } baseURLs := upstream.GetAllBaseURLs() sortedURLResults := channelScheduler.GetSortedURLsForChannel(scheduler.ChannelKindGemini, channelIndex, baseURLs) handled, successKey, successBaseURLIdx, failoverErr, usage, lastErr := common.TryUpstreamWithAllKeys( c, envCfg, cfgManager, channelScheduler, scheduler.ChannelKindGemini, "Gemini", metricsManager, upstream, sortedURLResults, bodyBytes, isStream, func(upstream *config.UpstreamConfig, failedKeys map[string]bool) (string, error) { return cfgManager.GetNextGeminiAPIKey(upstream, failedKeys) }, func(c *gin.Context, upstreamCopy *config.UpstreamConfig, apiKey string) (*http.Request, error) { return buildProviderRequest(c, upstreamCopy, upstreamCopy.BaseURL, apiKey, geminiReq, model, isStream) }, func(apiKey string) { _ = cfgManager.DeprioritizeAPIKey(apiKey) }, func(url string) { channelScheduler.MarkURLFailure(scheduler.ChannelKindGemini, channelIndex, url) }, func(url string) { channelScheduler.MarkURLSuccess(scheduler.ChannelKindGemini, channelIndex, url) }, func(c *gin.Context, resp *http.Response, upstreamCopy *config.UpstreamConfig, apiKey string) (*types.Usage, error) { return handleSuccess(c, resp, upstreamCopy.ServiceType, envCfg, startTime, geminiReq, model, isStream) }, ) return common.MultiChannelAttemptResult{ Handled: handled, Attempted: true, SuccessKey: successKey, SuccessBaseURLIdx: successBaseURLIdx, FailoverError: failoverErr, Usage: usage, LastError: lastErr, } }, nil, func(ctx *gin.Context, failoverErr *common.FailoverError, lastError error) { handleAllChannelsFailed(ctx, failoverErr, lastError) }, ) } // handleSingleChannel 处理单渠道 Gemini 请求 func handleSingleChannel( c *gin.Context, envCfg *config.EnvConfig, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler, bodyBytes []byte, geminiReq *types.GeminiRequest, model string, isStream bool, startTime time.Time, ) { upstream, err := cfgManager.GetCurrentGeminiUpstream() if err != nil { c.JSON(503, types.GeminiError{ Error: types.GeminiErrorDetail{ Code: 503, Message: "No Gemini upstream configured", Status: "UNAVAILABLE", }, }) return } if len(upstream.APIKeys) == 0 { c.JSON(503, types.GeminiError{ Error: types.GeminiErrorDetail{ Code: 503, Message: fmt.Sprintf("No API keys configured for upstream \"%s\"", upstream.Name), Status: "UNAVAILABLE", }, }) return } metricsManager := channelScheduler.GetGeminiMetricsManager() baseURLs := upstream.GetAllBaseURLs() urlResults := common.BuildDefaultURLResults(baseURLs) handled, _, _, lastFailoverError, _, lastError := common.TryUpstreamWithAllKeys( c, envCfg, cfgManager, channelScheduler, scheduler.ChannelKindGemini, "Gemini", metricsManager, upstream, urlResults, bodyBytes, isStream, func(upstream *config.UpstreamConfig, failedKeys map[string]bool) (string, error) { return cfgManager.GetNextGeminiAPIKey(upstream, failedKeys) }, func(c *gin.Context, upstreamCopy *config.UpstreamConfig, apiKey string) (*http.Request, error) { return buildProviderRequest(c, upstreamCopy, upstreamCopy.BaseURL, apiKey, geminiReq, model, isStream) }, func(apiKey string) { _ = cfgManager.DeprioritizeAPIKey(apiKey) }, nil, nil, func(c *gin.Context, resp *http.Response, upstreamCopy *config.UpstreamConfig, apiKey string) (*types.Usage, error) { return handleSuccess(c, resp, upstreamCopy.ServiceType, envCfg, startTime, geminiReq, model, isStream) }, ) if handled { return } log.Printf("[Gemini-Error] 所有 API密钥都失败了") handleAllKeysFailed(c, lastFailoverError, lastError) } // ensureThoughtSignatures 确保所有 functionCall 都有 thought_signature 字段 // 用于兼容 x666.me 等要求必须有该字段的第三方 API // 参考: https://ai.google.dev/gemini-api/docs/thought-signatures // // 行为: // - 如果 functionCall 已有 thought_signature(非空),保留原始值 // - 如果 functionCall 没有 thought_signature(空字符串),填充 DummyThoughtSignature // // 使用场景: // - x666.me 等第三方 API 会验证 thought_signature 字段必须存在 // - Gemini CLI 等客户端可能不会为所有 functionCall 提供 thought_signature func ensureThoughtSignatures(geminiReq *types.GeminiRequest) { for i := range geminiReq.Contents { for j := range geminiReq.Contents[i].Parts { part := &geminiReq.Contents[i].Parts[j] if part.FunctionCall != nil && part.FunctionCall.ThoughtSignature == "" { part.FunctionCall.ThoughtSignature = types.DummyThoughtSignature } } } } // stripThoughtSignature 移除所有 functionCall 的 thought_signature 字段 // 用于兼容旧版 Gemini API(不支持该字段) func stripThoughtSignature(geminiReq *types.GeminiRequest) { for i := range geminiReq.Contents { for j := range geminiReq.Contents[i].Parts { part := &geminiReq.Contents[i].Parts[j] if part.FunctionCall != nil { // 使用特殊标记表示需要完全移除字段 part.FunctionCall.ThoughtSignature = types.StripThoughtSignatureMarker } } } } // cloneGeminiRequest 深拷贝 GeminiRequest(通过 JSON 序列化/反序列化) func cloneGeminiRequest(req *types.GeminiRequest) *types.GeminiRequest { clone := &types.GeminiRequest{} data, _ := json.Marshal(req) json.Unmarshal(data, clone) return clone } // buildProviderRequest 构建上游请求 func buildProviderRequest( c *gin.Context, upstream *config.UpstreamConfig, baseURL string, apiKey string, geminiReq *types.GeminiRequest, model string, isStream bool, ) (*http.Request, error) { // 应用模型映射 mappedModel := config.RedirectModel(model, upstream) var requestBody []byte var url string var err error switch upstream.ServiceType { case "gemini": // Gemini 上游:根据配置处理 thought_signature 字段 reqToUse := geminiReq // 优先处理 StripThoughtSignature(移除字段) if upstream.StripThoughtSignature { reqCopy := cloneGeminiRequest(geminiReq) stripThoughtSignature(reqCopy) reqToUse = reqCopy } else if upstream.InjectDummyThoughtSignature { // 给空签名注入 dummy 值(兼容 x666.me 等要求必须有该字段的 API) reqCopy := cloneGeminiRequest(geminiReq) ensureThoughtSignatures(reqCopy) reqToUse = reqCopy } // else: 默认直接透传,不做任何修改 requestBody, err = json.Marshal(reqToUse) if err != nil { return nil, err } action := "generateContent" if isStream { action = "streamGenerateContent" } url = fmt.Sprintf("%s/v1beta/models/%s:%s", strings.TrimRight(baseURL, "/"), mappedModel, action) if isStream { url += "?alt=sse" } case "claude": // Claude 上游:需要转换 claudeReq, err := converters.GeminiToClaudeRequest(geminiReq, mappedModel) if err != nil { return nil, err } claudeReq["stream"] = isStream requestBody, err = json.Marshal(claudeReq) if err != nil { return nil, err } url = fmt.Sprintf("%s/v1/messages", strings.TrimRight(baseURL, "/")) case "openai": // OpenAI 上游:需要转换 openaiReq, err := converters.GeminiToOpenAIRequest(geminiReq, mappedModel) if err != nil { return nil, err } openaiReq["stream"] = isStream requestBody, err = json.Marshal(openaiReq) if err != nil { return nil, err } url = fmt.Sprintf("%s/v1/chat/completions", strings.TrimRight(baseURL, "/")) default: // 默认当作 Gemini 处理,根据配置处理 thought_signature 字段 reqToUse := geminiReq // 优先处理 StripThoughtSignature(移除字段) if upstream.StripThoughtSignature { reqCopy := cloneGeminiRequest(geminiReq) stripThoughtSignature(reqCopy) reqToUse = reqCopy } else if upstream.InjectDummyThoughtSignature { // 给空签名注入 dummy 值(兼容 x666.me 等要求必须有该字段的 API) reqCopy := cloneGeminiRequest(geminiReq) ensureThoughtSignatures(reqCopy) reqToUse = reqCopy } // else: 默认直接透传,不做任何修改 requestBody, err = json.Marshal(reqToUse) if err != nil { return nil, err } action := "generateContent" if isStream { action = "streamGenerateContent" } url = fmt.Sprintf("%s/v1beta/models/%s:%s", strings.TrimRight(baseURL, "/"), mappedModel, action) if isStream { url += "?alt=sse" } } req, err := http.NewRequestWithContext(c.Request.Context(), "POST", url, bytes.NewReader(requestBody)) if err != nil { return nil, err } // 使用统一的头部处理逻辑(透明代理) // 保留客户端的大部分 headers,只移除/替换必要的认证和代理相关 headers req.Header = utils.PrepareUpstreamHeaders(c, req.URL.Host) // 设置 Content-Type(覆盖可能来自客户端的值) req.Header.Set("Content-Type", "application/json") // 设置认证头 switch upstream.ServiceType { case "gemini": utils.SetGeminiAuthenticationHeader(req.Header, apiKey) case "claude": utils.SetAuthenticationHeader(req.Header, apiKey) req.Header.Set("anthropic-version", "2023-06-01") case "openai": utils.SetAuthenticationHeader(req.Header, apiKey) default: utils.SetGeminiAuthenticationHeader(req.Header, apiKey) } return req, nil } // handleSuccess 处理成功的响应 func handleSuccess( c *gin.Context, resp *http.Response, upstreamType string, envCfg *config.EnvConfig, startTime time.Time, geminiReq *types.GeminiRequest, model string, isStream bool, ) (*types.Usage, error) { defer resp.Body.Close() if isStream { return handleStreamSuccess(c, resp, upstreamType, envCfg, startTime, model), nil } // 非流式响应处理 bodyBytes, err := io.ReadAll(resp.Body) if err != nil { c.JSON(500, types.GeminiError{ Error: types.GeminiErrorDetail{ Code: 500, Message: "Failed to read response", Status: "INTERNAL", }, }) return nil, err } if envCfg.EnableResponseLogs { responseTime := time.Since(startTime).Milliseconds() log.Printf("[Gemini-Timing] 响应完成: %dms, 状态: %d", responseTime, resp.StatusCode) } // 根据上游类型转换响应 var geminiResp *types.GeminiResponse switch upstreamType { case "gemini": // 直接解析 Gemini 响应 if err := json.Unmarshal(bodyBytes, &geminiResp); err != nil { c.Data(resp.StatusCode, "application/json", bodyBytes) return nil, nil } case "claude": // 转换 Claude 响应为 Gemini 格式 var claudeResp map[string]interface{} if err := json.Unmarshal(bodyBytes, &claudeResp); err != nil { c.Data(resp.StatusCode, "application/json", bodyBytes) return nil, nil } geminiResp, err = converters.ClaudeResponseToGemini(claudeResp) if err != nil { c.Data(resp.StatusCode, "application/json", bodyBytes) return nil, nil } case "openai": // 转换 OpenAI 响应为 Gemini 格式 var openaiResp map[string]interface{} if err := json.Unmarshal(bodyBytes, &openaiResp); err != nil { c.Data(resp.StatusCode, "application/json", bodyBytes) return nil, nil } geminiResp, err = converters.OpenAIResponseToGemini(openaiResp) if err != nil { c.Data(resp.StatusCode, "application/json", bodyBytes) return nil, nil } default: // 默认直接返回 c.Data(resp.StatusCode, "application/json", bodyBytes) return nil, nil } // 返回 Gemini 格式响应 respBytes, err := json.Marshal(geminiResp) if err != nil { c.Data(resp.StatusCode, "application/json", bodyBytes) return nil, nil } c.Data(resp.StatusCode, "application/json", respBytes) // 提取 usage 统计 var usage *types.Usage if geminiResp.UsageMetadata != nil { usage = &types.Usage{ InputTokens: geminiResp.UsageMetadata.PromptTokenCount - geminiResp.UsageMetadata.CachedContentTokenCount, OutputTokens: geminiResp.UsageMetadata.CandidatesTokenCount, } } return usage, nil } // handleAllChannelsFailed 处理所有渠道失败的情况 func handleAllChannelsFailed(c *gin.Context, failoverErr *common.FailoverError, lastError error) { if failoverErr != nil { c.Data(failoverErr.Status, "application/json", failoverErr.Body) return } errMsg := "All channels failed" if lastError != nil { errMsg = lastError.Error() } c.JSON(503, types.GeminiError{ Error: types.GeminiErrorDetail{ Code: 503, Message: errMsg, Status: "UNAVAILABLE", }, }) } // handleAllKeysFailed 处理所有 Key 失败的情况 func handleAllKeysFailed(c *gin.Context, failoverErr *common.FailoverError, lastError error) { if failoverErr != nil { c.Data(failoverErr.Status, "application/json", failoverErr.Body) return } errMsg := "All API keys failed" if lastError != nil { errMsg = lastError.Error() } c.JSON(503, types.GeminiError{ Error: types.GeminiErrorDetail{ Code: 503, Message: errMsg, Status: "UNAVAILABLE", }, }) } ================================================ FILE: backend-go/internal/handlers/gemini/handler_test.go ================================================ package gemini import ( "bytes" "encoding/json" "io" "net/http" "net/http/httptest" "testing" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/types" "github.com/gin-gonic/gin" ) func TestHandler_RequiresProxyAccessKeyEvenWhenGeminiKeyProvided(t *testing.T) { gin.SetMode(gin.TestMode) envCfg := &config.EnvConfig{ ProxyAccessKey: "secret-key", MaxRequestBodySize: 1024 * 1024, } r := gin.New() r.POST("/v1beta/models/*modelAction", Handler(envCfg, nil, nil)) t.Run("x-goog-api-key does not bypass proxy auth", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.0-flash:generateContent", bytes.NewReader([]byte(`{}`))) req.Header.Set("Content-Type", "application/json") req.Header.Set("x-goog-api-key", "any-gemini-key") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized) } }) t.Run("query key does not bypass proxy auth", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.0-flash:generateContent?key=any-gemini-key", bytes.NewReader([]byte(`{}`))) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized) } }) } // TestStripThoughtSignature 测试 stripThoughtSignature 函数 func TestStripThoughtSignature(t *testing.T) { tests := []struct { name string input *types.GeminiRequest expected *types.GeminiRequest }{ { name: "移除单个 functionCall 的 thought_signature", input: &types.GeminiRequest{ Contents: []types.GeminiContent{ { Parts: []types.GeminiPart{ { FunctionCall: &types.GeminiFunctionCall{ Name: "test_function", Args: map[string]interface{}{"arg1": "value1"}, ThoughtSignature: "test_signature", }, }, }, }, }, }, expected: &types.GeminiRequest{ Contents: []types.GeminiContent{ { Parts: []types.GeminiPart{ { FunctionCall: &types.GeminiFunctionCall{ Name: "test_function", Args: map[string]interface{}{"arg1": "value1"}, ThoughtSignature: "", }, }, }, }, }, }, }, { name: "移除多个 functionCall 的 thought_signature", input: &types.GeminiRequest{ Contents: []types.GeminiContent{ { Parts: []types.GeminiPart{ { FunctionCall: &types.GeminiFunctionCall{ Name: "func1", Args: map[string]interface{}{}, ThoughtSignature: "sig1", }, }, { FunctionCall: &types.GeminiFunctionCall{ Name: "func2", Args: map[string]interface{}{}, ThoughtSignature: "sig2", }, }, }, }, }, }, expected: &types.GeminiRequest{ Contents: []types.GeminiContent{ { Parts: []types.GeminiPart{ { FunctionCall: &types.GeminiFunctionCall{ Name: "func1", Args: map[string]interface{}{}, ThoughtSignature: "", }, }, { FunctionCall: &types.GeminiFunctionCall{ Name: "func2", Args: map[string]interface{}{}, ThoughtSignature: "", }, }, }, }, }, }, }, { name: "不影响非 functionCall 的 parts", input: &types.GeminiRequest{ Contents: []types.GeminiContent{ { Parts: []types.GeminiPart{ { Text: "some text", }, { FunctionCall: &types.GeminiFunctionCall{ Name: "func", Args: map[string]interface{}{}, ThoughtSignature: "sig", }, }, }, }, }, }, expected: &types.GeminiRequest{ Contents: []types.GeminiContent{ { Parts: []types.GeminiPart{ { Text: "some text", }, { FunctionCall: &types.GeminiFunctionCall{ Name: "func", Args: map[string]interface{}{}, ThoughtSignature: "", }, }, }, }, }, }, }, { name: "处理空 thought_signature", input: &types.GeminiRequest{ Contents: []types.GeminiContent{ { Parts: []types.GeminiPart{ { FunctionCall: &types.GeminiFunctionCall{ Name: "func", Args: map[string]interface{}{}, ThoughtSignature: "", }, }, }, }, }, }, expected: &types.GeminiRequest{ Contents: []types.GeminiContent{ { Parts: []types.GeminiPart{ { FunctionCall: &types.GeminiFunctionCall{ Name: "func", Args: map[string]interface{}{}, ThoughtSignature: "", }, }, }, }, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { stripThoughtSignature(tt.input) // 验证结果 if len(tt.input.Contents) != len(tt.expected.Contents) { t.Fatalf("Contents length mismatch: got %d, want %d", len(tt.input.Contents), len(tt.expected.Contents)) } for i := range tt.input.Contents { if len(tt.input.Contents[i].Parts) != len(tt.expected.Contents[i].Parts) { t.Fatalf("Parts length mismatch at content %d: got %d, want %d", i, len(tt.input.Contents[i].Parts), len(tt.expected.Contents[i].Parts)) } for j := range tt.input.Contents[i].Parts { inputPart := &tt.input.Contents[i].Parts[j] expectedPart := &tt.expected.Contents[i].Parts[j] if inputPart.FunctionCall != nil { if expectedPart.FunctionCall == nil { t.Fatalf("FunctionCall mismatch at content %d, part %d: got non-nil, want nil", i, j) } // stripThoughtSignature 使用特殊标记而不是空字符串 if inputPart.FunctionCall.ThoughtSignature != types.StripThoughtSignatureMarker { t.Errorf("ThoughtSignature mismatch at content %d, part %d: got %q, want %q", i, j, inputPart.FunctionCall.ThoughtSignature, types.StripThoughtSignatureMarker) } } } } }) } } // TestBuildProviderRequest_StripThoughtSignature 测试 buildProviderRequest 中的 StripThoughtSignature 配置 func TestBuildProviderRequest_StripThoughtSignature(t *testing.T) { gin.SetMode(gin.TestMode) tests := []struct { name string stripThoughtSignature bool injectDummyThoughtSig bool inputThoughtSignature string expectedThoughtSignature string }{ { name: "StripThoughtSignature=true 移除字段", stripThoughtSignature: true, injectDummyThoughtSig: false, inputThoughtSignature: "test_signature", expectedThoughtSignature: "", }, { name: "默认行为:透传非空签名", stripThoughtSignature: false, injectDummyThoughtSig: false, inputThoughtSignature: "test_signature", expectedThoughtSignature: "test_signature", }, { name: "默认行为:完全透传空签名", stripThoughtSignature: false, injectDummyThoughtSig: false, inputThoughtSignature: "", expectedThoughtSignature: "", }, { name: "InjectDummyThoughtSignature=true 注入 dummy", stripThoughtSignature: false, injectDummyThoughtSig: true, inputThoughtSignature: "", expectedThoughtSignature: types.DummyThoughtSignature, }, { name: "StripThoughtSignature=true 优先于 InjectDummyThoughtSignature", stripThoughtSignature: true, injectDummyThoughtSig: true, inputThoughtSignature: "test_signature", expectedThoughtSignature: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { upstream := &config.UpstreamConfig{ BaseURL: "https://test.example.com", ServiceType: "gemini", StripThoughtSignature: tt.stripThoughtSignature, InjectDummyThoughtSignature: tt.injectDummyThoughtSig, } geminiReq := &types.GeminiRequest{ Contents: []types.GeminiContent{ { Parts: []types.GeminiPart{ { FunctionCall: &types.GeminiFunctionCall{ Name: "test_function", Args: map[string]interface{}{"arg1": "value1"}, ThoughtSignature: tt.inputThoughtSignature, }, }, }, }, }, } c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Request = httptest.NewRequest(http.MethodPost, "/test", nil) req, err := buildProviderRequest(c, upstream, upstream.BaseURL, "test-key", geminiReq, "gemini-2.0-flash", false) if err != nil { t.Fatalf("buildProviderRequest failed: %v", err) } // 解析请求体 var resultReq types.GeminiRequest if err := json.NewDecoder(req.Body).Decode(&resultReq); err != nil { t.Fatalf("Failed to decode request body: %v", err) } // 验证 thought_signature if len(resultReq.Contents) == 0 || len(resultReq.Contents[0].Parts) == 0 { t.Fatal("Request body is empty") } part := resultReq.Contents[0].Parts[0] if part.FunctionCall == nil { t.Fatal("FunctionCall is nil") } if part.FunctionCall.ThoughtSignature != tt.expectedThoughtSignature { t.Errorf("ThoughtSignature mismatch: got %q, want %q", part.FunctionCall.ThoughtSignature, tt.expectedThoughtSignature) } // 验证原始请求未被修改(深拷贝机制) if geminiReq.Contents[0].Parts[0].FunctionCall.ThoughtSignature != tt.inputThoughtSignature { t.Errorf("Original request was modified: got %q, want %q", geminiReq.Contents[0].Parts[0].FunctionCall.ThoughtSignature, tt.inputThoughtSignature) } }) } } func TestBuildProviderRequest_InjectDummyThoughtSignature_PreservesThoughtSignatureAtPartLevel(t *testing.T) { gin.SetMode(gin.TestMode) upstream := &config.UpstreamConfig{ BaseURL: "https://test.example.com", ServiceType: "gemini", StripThoughtSignature: false, InjectDummyThoughtSignature: true, } // 模拟 Gemini CLI:thoughtSignature 出现在 part 层级(而非 functionCall 内部) var geminiReq types.GeminiRequest if err := json.Unmarshal([]byte(`{ "contents": [ { "parts": [ { "functionCall": { "name": "run_shell_command", "args": { "command": "ls -R" } }, "thoughtSignature": "sig_from_cli" } ] } ] }`), &geminiReq); err != nil { t.Fatalf("Unmarshal 请求失败: %v", err) } c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Request = httptest.NewRequest(http.MethodPost, "/test", nil) req, err := buildProviderRequest(c, upstream, upstream.BaseURL, "test-key", &geminiReq, "gemini-2.0-flash", false) if err != nil { t.Fatalf("buildProviderRequest failed: %v", err) } bodyBytes, err := io.ReadAll(req.Body) if err != nil { t.Fatalf("读取请求体失败: %v", err) } // 解析为通用 map,验证字段名格式(thought_signature vs thoughtSignature) var raw map[string]interface{} if err := json.Unmarshal(bodyBytes, &raw); err != nil { t.Fatalf("解析请求体 JSON 失败: %v", err) } contents, ok := raw["contents"].([]interface{}) if !ok || len(contents) != 1 { t.Fatalf("contents 解析失败: %T, len=%d", raw["contents"], len(contents)) } content0, ok := contents[0].(map[string]interface{}) if !ok { t.Fatalf("contents[0] 类型=%T, want=map[string]interface{}", contents[0]) } parts, ok := content0["parts"].([]interface{}) if !ok || len(parts) != 1 { t.Fatalf("parts 解析失败: %T, len=%d", content0["parts"], len(parts)) } part0, ok := parts[0].(map[string]interface{}) if !ok { t.Fatalf("parts[0] 类型=%T, want=map[string]interface{}", parts[0]) } if v, exists := part0["thoughtSignature"]; !exists || v != "sig_from_cli" { t.Fatalf("part.thoughtSignature=%v, want=%v", v, "sig_from_cli") } if _, exists := part0["thought_signature"]; exists { t.Fatalf("不应在 part 层级输出 thought_signature: %v", part0) } fc, ok := part0["functionCall"].(map[string]interface{}) if !ok { t.Fatalf("functionCall 类型=%T, want=map[string]interface{}", part0["functionCall"]) } if _, exists := fc["thoughtSignature"]; exists { t.Fatalf("不应在 functionCall 内输出 thoughtSignature: %v", fc) } if _, exists := fc["thought_signature"]; exists { t.Fatalf("不应在 functionCall 内输出 thought_signature: %v", fc) } } ================================================ FILE: backend-go/internal/handlers/gemini/stream.go ================================================ package gemini import ( "bufio" "encoding/json" "fmt" "log" "net/http" "strings" "time" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/types" "github.com/gin-gonic/gin" ) // handleStreamSuccess 处理流式响应 func handleStreamSuccess( c *gin.Context, resp *http.Response, upstreamType string, envCfg *config.EnvConfig, startTime time.Time, model string, ) *types.Usage { // 设置 SSE 响应头 c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") flusher, ok := c.Writer.(http.Flusher) if !ok { log.Printf("[Gemini-Stream] 警告: ResponseWriter 不支持 Flusher") } var totalUsage *types.Usage switch upstreamType { case "gemini": totalUsage = streamGeminiToGemini(c, resp, flusher, envCfg) case "claude": totalUsage = streamClaudeToGemini(c, resp, flusher, envCfg, model) case "openai": totalUsage = streamOpenAIToGemini(c, resp, flusher, envCfg, model) default: // 默认透传 totalUsage = streamGeminiToGemini(c, resp, flusher, envCfg) } if envCfg.EnableResponseLogs { responseTime := time.Since(startTime).Milliseconds() log.Printf("[Gemini-Stream-Timing] 流式响应完成: %dms", responseTime) } return totalUsage } // streamGeminiToGemini Gemini 上游直接透传 func streamGeminiToGemini( c *gin.Context, resp *http.Response, flusher http.Flusher, envCfg *config.EnvConfig, ) *types.Usage { scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB buffer var totalUsage *types.Usage for scanner.Scan() { line := scanner.Text() // 直接转发 SSE 数据 if strings.HasPrefix(line, "data: ") { jsonData := strings.TrimPrefix(line, "data: ") // 尝试解析 usage var chunk types.GeminiStreamChunk if err := json.Unmarshal([]byte(jsonData), &chunk); err == nil { if chunk.UsageMetadata != nil { totalUsage = &types.Usage{ InputTokens: chunk.UsageMetadata.PromptTokenCount - chunk.UsageMetadata.CachedContentTokenCount, OutputTokens: chunk.UsageMetadata.CandidatesTokenCount, } } } fmt.Fprintf(c.Writer, "%s\n", line) } else if line != "" { fmt.Fprintf(c.Writer, "%s\n", line) } else { fmt.Fprintf(c.Writer, "\n") } if flusher != nil { flusher.Flush() } } return totalUsage } // streamClaudeToGemini Claude 流式响应转换为 Gemini 格式 func streamClaudeToGemini( c *gin.Context, resp *http.Response, flusher http.Flusher, envCfg *config.EnvConfig, model string, ) *types.Usage { scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 1024*1024), 1024*1024) var totalUsage *types.Usage var currentText strings.Builder for scanner.Scan() { line := scanner.Text() if !strings.HasPrefix(line, "data: ") { continue } jsonData := strings.TrimPrefix(line, "data: ") if jsonData == "[DONE]" { break } var event map[string]interface{} if err := json.Unmarshal([]byte(jsonData), &event); err != nil { continue } eventType, _ := event["type"].(string) switch eventType { case "content_block_delta": // 文本增量 delta, ok := event["delta"].(map[string]interface{}) if !ok { continue } deltaType, _ := delta["type"].(string) if deltaType == "text_delta" { text, _ := delta["text"].(string) currentText.WriteString(text) // 转换为 Gemini 格式 geminiChunk := types.GeminiStreamChunk{ Candidates: []types.GeminiCandidate{ { Content: &types.GeminiContent{ Parts: []types.GeminiPart{ {Text: text}, }, Role: "model", }, }, }, } chunkBytes, _ := json.Marshal(geminiChunk) fmt.Fprintf(c.Writer, "data: %s\n\n", string(chunkBytes)) if flusher != nil { flusher.Flush() } } case "message_delta": // 消息完成,包含 usage if usage, ok := event["usage"].(map[string]interface{}); ok { inputTokens := 0 outputTokens := 0 if v, ok := usage["input_tokens"].(float64); ok { inputTokens = int(v) } if v, ok := usage["output_tokens"].(float64); ok { outputTokens = int(v) } totalUsage = &types.Usage{ InputTokens: inputTokens, OutputTokens: outputTokens, } // 发送带 finishReason 和 usage 的最终块 geminiChunk := types.GeminiStreamChunk{ Candidates: []types.GeminiCandidate{ { FinishReason: "STOP", }, }, UsageMetadata: &types.GeminiUsageMetadata{ PromptTokenCount: inputTokens, CandidatesTokenCount: outputTokens, TotalTokenCount: inputTokens + outputTokens, }, } chunkBytes, _ := json.Marshal(geminiChunk) fmt.Fprintf(c.Writer, "data: %s\n\n", string(chunkBytes)) if flusher != nil { flusher.Flush() } } } } return totalUsage } // streamOpenAIToGemini OpenAI 流式响应转换为 Gemini 格式 func streamOpenAIToGemini( c *gin.Context, resp *http.Response, flusher http.Flusher, envCfg *config.EnvConfig, model string, ) *types.Usage { scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 1024*1024), 1024*1024) var totalUsage *types.Usage var currentText strings.Builder for scanner.Scan() { line := scanner.Text() if !strings.HasPrefix(line, "data: ") { continue } jsonData := strings.TrimPrefix(line, "data: ") if jsonData == "[DONE]" { break } var chunk map[string]interface{} if err := json.Unmarshal([]byte(jsonData), &chunk); err != nil { continue } choices, ok := chunk["choices"].([]interface{}) if !ok || len(choices) == 0 { // 检查是否有 usage(某些 OpenAI 兼容 API 在最后发送) if usage, ok := chunk["usage"].(map[string]interface{}); ok { promptTokens := 0 completionTokens := 0 if v, ok := usage["prompt_tokens"].(float64); ok { promptTokens = int(v) } if v, ok := usage["completion_tokens"].(float64); ok { completionTokens = int(v) } totalUsage = &types.Usage{ InputTokens: promptTokens, OutputTokens: completionTokens, } // 发送带 usage 的最终块 geminiChunk := types.GeminiStreamChunk{ UsageMetadata: &types.GeminiUsageMetadata{ PromptTokenCount: promptTokens, CandidatesTokenCount: completionTokens, TotalTokenCount: promptTokens + completionTokens, }, } chunkBytes, _ := json.Marshal(geminiChunk) fmt.Fprintf(c.Writer, "data: %s\n\n", string(chunkBytes)) if flusher != nil { flusher.Flush() } } continue } choice, ok := choices[0].(map[string]interface{}) if !ok { continue } // 检查 finish_reason finishReason, hasFinish := choice["finish_reason"].(string) // 获取 delta delta, ok := choice["delta"].(map[string]interface{}) if !ok { if hasFinish && finishReason != "" { // 发送 finishReason geminiFinishReason := openaiFinishReasonToGemini(finishReason) geminiChunk := types.GeminiStreamChunk{ Candidates: []types.GeminiCandidate{ { FinishReason: geminiFinishReason, }, }, } chunkBytes, _ := json.Marshal(geminiChunk) fmt.Fprintf(c.Writer, "data: %s\n\n", string(chunkBytes)) if flusher != nil { flusher.Flush() } } continue } // 提取文本内容 content, _ := delta["content"].(string) if content != "" { currentText.WriteString(content) geminiChunk := types.GeminiStreamChunk{ Candidates: []types.GeminiCandidate{ { Content: &types.GeminiContent{ Parts: []types.GeminiPart{ {Text: content}, }, Role: "model", }, }, }, } chunkBytes, _ := json.Marshal(geminiChunk) fmt.Fprintf(c.Writer, "data: %s\n\n", string(chunkBytes)) if flusher != nil { flusher.Flush() } } // 如果有 finish_reason,发送 if hasFinish && finishReason != "" { geminiFinishReason := openaiFinishReasonToGemini(finishReason) geminiChunk := types.GeminiStreamChunk{ Candidates: []types.GeminiCandidate{ { FinishReason: geminiFinishReason, }, }, } chunkBytes, _ := json.Marshal(geminiChunk) fmt.Fprintf(c.Writer, "data: %s\n\n", string(chunkBytes)) if flusher != nil { flusher.Flush() } } } return totalUsage } // openaiFinishReasonToGemini 将 OpenAI 停止原因转换为 Gemini 格式 func openaiFinishReasonToGemini(finishReason string) string { switch finishReason { case "stop": return "STOP" case "length": return "MAX_TOKENS" case "tool_calls": return "STOP" case "content_filter": return "SAFETY" default: return "STOP" } } ================================================ FILE: backend-go/internal/handlers/global_stats_handler.go ================================================ package handlers import ( "time" "github.com/BenedictKing/claude-proxy/internal/metrics" "github.com/gin-gonic/gin" ) // GetGlobalStatsHistory 获取全局统计历史数据 // GET /api/{messages|responses}/global/stats/history?duration={1h|6h|24h|today} func GetGlobalStatsHistory(metricsManager *metrics.MetricsManager) gin.HandlerFunc { return func(c *gin.Context) { // 解析 duration 参数 durationStr := c.DefaultQuery("duration", "24h") var duration time.Duration var err error // 特殊处理 "today" 参数 if durationStr == "today" { duration = metrics.CalculateTodayDuration() // 如果刚过零点,duration 可能非常小,设置最小值 if duration < time.Minute { duration = time.Minute } } else { duration, err = time.ParseDuration(durationStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid duration parameter. Use: 1h, 6h, 24h, or today"}) return } } // 限制最大查询范围为 24 小时 if duration > 24*time.Hour { duration = 24 * time.Hour } // 解析或自动选择 interval intervalStr := c.Query("interval") var interval time.Duration if intervalStr != "" { interval, err = time.ParseDuration(intervalStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid interval parameter"}) return } // 限制 interval 最小值为 1 分钟,防止生成过多 bucket if interval < time.Minute { interval = time.Minute } } else { // 根据 duration 自动选择合适的聚合粒度 // 目标:每个时间段约 60-100 个数据点,保持图表清晰 // 1h = 60 points (1m interval) // 6h = 72 points (5m interval) // 24h = 96 points (15m interval) switch { case duration <= time.Hour: interval = time.Minute case duration <= 6*time.Hour: interval = 5 * time.Minute default: interval = 15 * time.Minute } } // 获取全局统计数据 result := metricsManager.GetGlobalHistoricalStatsWithTokens(duration, interval) // 更新 duration 字符串(特别是 today 情况) if durationStr == "today" { result.Summary.Duration = "today" } c.JSON(200, result) } } ================================================ FILE: backend-go/internal/handlers/health.go ================================================ package handlers import ( "time" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/gin-gonic/gin" ) // HealthCheck 健康检查处理器 func HealthCheck(envCfg *config.EnvConfig, cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { config := cfgManager.GetConfig() healthData := gin.H{ "status": "healthy", "timestamp": time.Now().Format(time.RFC3339), "uptime": time.Since(startTime).Seconds(), "mode": envCfg.Env, "version": getVersion(), "config": gin.H{ "upstreamCount": len(config.Upstream), "loadBalance": config.LoadBalance, "responsesLoadBalance": config.ResponsesLoadBalance, }, } c.JSON(200, healthData) } } // getVersion 获取版本信息 func getVersion() gin.H { // 这些变量在编译时通过 -ldflags 注入 // 从根目录 VERSION 文件读取 return gin.H{ "version": getVersionString(), "buildTime": getBuildTime(), "gitCommit": getGitCommit(), } } // 以下函数用于从 main 包获取版本信息 // 由于无法直接导入 main 包,使用默认值 var ( versionString = "v0.0.0-dev" buildTime = "unknown" gitCommit = "unknown" ) func getVersionString() string { return versionString } func getBuildTime() string { return buildTime } func getGitCommit() string { return gitCommit } // SetVersionInfo 设置版本信息(从 main 调用) func SetVersionInfo(version, build, commit string) { versionString = version buildTime = build gitCommit = commit } // SaveConfigHandler 配置保存处理器 func SaveConfigHandler(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { if err := cfgManager.SaveConfig(); err != nil { c.JSON(500, gin.H{ "status": "error", "message": "配置保存失败", "error": err.Error(), "timestamp": time.Now().Format(time.RFC3339), }) return } config := cfgManager.GetConfig() c.JSON(200, gin.H{ "status": "success", "message": "配置已保存", "timestamp": time.Now().Format(time.RFC3339), "config": gin.H{ "upstreamCount": len(config.Upstream), "loadBalance": config.LoadBalance, "responsesLoadBalance": config.ResponsesLoadBalance, }, }) } } // DevInfo 开发信息处理器 func DevInfo(envCfg *config.EnvConfig, cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { c.JSON(200, gin.H{ "status": "development", "timestamp": time.Now().Format(time.RFC3339), "config": cfgManager.GetConfig(), "environment": envCfg, }) } } var startTime = time.Now() ================================================ FILE: backend-go/internal/handlers/messages/channels.go ================================================ // Package messages 提供 Claude Messages API 的渠道管理 package messages import ( "net/http" "strconv" "strings" "sync" "time" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/httpclient" "github.com/BenedictKing/claude-proxy/internal/scheduler" "github.com/gin-gonic/gin" ) // GetUpstreams 获取上游列表 (兼容前端 channels 字段名) func GetUpstreams(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { cfg := cfgManager.GetConfig() upstreams := make([]gin.H, len(cfg.Upstream)) for i, up := range cfg.Upstream { status := config.GetChannelStatus(&up) priority := config.GetChannelPriority(&up, i) upstreams[i] = gin.H{ "index": i, "name": up.Name, "serviceType": up.ServiceType, "baseUrl": up.BaseURL, "baseUrls": up.BaseURLs, "apiKeys": up.APIKeys, "description": up.Description, "website": up.Website, "insecureSkipVerify": up.InsecureSkipVerify, "modelMapping": up.ModelMapping, "latency": nil, "status": status, "priority": priority, "promotionUntil": up.PromotionUntil, "lowQuality": up.LowQuality, } } c.JSON(200, gin.H{ "channels": upstreams, "loadBalance": cfg.LoadBalance, }) } } // AddUpstream 添加上游 func AddUpstream(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { var upstream config.UpstreamConfig if err := c.ShouldBindJSON(&upstream); err != nil { c.JSON(400, gin.H{"error": "Invalid request body"}) return } if err := cfgManager.AddUpstream(upstream); err != nil { c.JSON(500, gin.H{"error": "Failed to save config"}) return } c.JSON(200, gin.H{ "message": "上游已添加", "upstream": upstream, }) } } // UpdateUpstream 更新上游 func UpdateUpstream(cfgManager *config.ConfigManager, sch *scheduler.ChannelScheduler) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid upstream ID"}) return } var updates config.UpstreamUpdate if err := c.ShouldBindJSON(&updates); err != nil { c.JSON(400, gin.H{"error": "Invalid request body"}) return } shouldResetMetrics, err := cfgManager.UpdateUpstream(id, updates) if err != nil { if strings.Contains(err.Error(), "无效的上游索引") { c.JSON(404, gin.H{"error": "Upstream not found"}) } else { c.JSON(500, gin.H{"error": "Failed to save config"}) } return } if shouldResetMetrics { sch.ResetChannelMetrics(id, scheduler.ChannelKindMessages) } cfg := cfgManager.GetConfig() c.JSON(200, gin.H{ "message": "上游已更新", "upstream": cfg.Upstream[id], }) } } // DeleteUpstream 删除上游 func DeleteUpstream(cfgManager *config.ConfigManager, sch *scheduler.ChannelScheduler) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid upstream ID"}) return } removed, err := cfgManager.RemoveUpstream(id) if err != nil { if strings.Contains(err.Error(), "无效的上游索引") { c.JSON(404, gin.H{"error": "Upstream not found"}) } else { c.JSON(500, gin.H{"error": "Failed to save config"}) } return } // 删除成功后清理指标数据(使用 RemoveUpstream 返回的渠道信息) sch.DeleteChannelMetrics(removed, scheduler.ChannelKindMessages) c.JSON(200, gin.H{ "message": "上游已删除", "removed": removed, }) } } // AddApiKey 添加 API 密钥 func AddApiKey(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid upstream ID"}) return } var req struct { APIKey string `json:"apiKey"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "Invalid request body"}) return } if err := cfgManager.AddAPIKey(id, req.APIKey); err != nil { if strings.Contains(err.Error(), "无效的上游索引") { c.JSON(404, gin.H{"error": "Upstream not found"}) } else if strings.Contains(err.Error(), "API密钥已存在") { c.JSON(400, gin.H{"error": "API密钥已存在"}) } else { c.JSON(500, gin.H{"error": "Failed to save config"}) } return } c.JSON(200, gin.H{ "message": "API密钥已添加", "success": true, }) } } // DeleteApiKey 删除 API 密钥 func DeleteApiKey(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid upstream ID"}) return } apiKey := c.Param("apiKey") if apiKey == "" { c.JSON(400, gin.H{"error": "API key is required"}) return } if err := cfgManager.RemoveAPIKey(id, apiKey); err != nil { if strings.Contains(err.Error(), "无效的上游索引") { c.JSON(404, gin.H{"error": "Upstream not found"}) } else if strings.Contains(err.Error(), "API密钥不存在") { c.JSON(404, gin.H{"error": "API key not found"}) } else { c.JSON(500, gin.H{"error": "Failed to save config"}) } return } c.JSON(200, gin.H{ "message": "API密钥已删除", }) } } // MoveApiKeyToTop 将 API 密钥移到顶部 func MoveApiKeyToTop(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid upstream ID"}) return } apiKey := c.Param("apiKey") if apiKey == "" { c.JSON(400, gin.H{"error": "API key is required"}) return } if err := cfgManager.MoveAPIKeyToTop(id, apiKey); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{"message": "API密钥已移到顶部"}) } } // MoveApiKeyToBottom 将 API 密钥移到底部 func MoveApiKeyToBottom(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid upstream ID"}) return } apiKey := c.Param("apiKey") if apiKey == "" { c.JSON(400, gin.H{"error": "API key is required"}) return } if err := cfgManager.MoveAPIKeyToBottom(id, apiKey); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{"message": "API密钥已移到底部"}) } } // UpdateLoadBalance 更新负载均衡策略 func UpdateLoadBalance(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { var req struct { Strategy string `json:"strategy"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "Invalid request body"}) return } if err := cfgManager.SetLoadBalance(req.Strategy); err != nil { if strings.Contains(err.Error(), "无效的负载均衡策略") { c.JSON(400, gin.H{"error": err.Error()}) } else { c.JSON(500, gin.H{"error": "Failed to save config"}) } return } c.JSON(200, gin.H{ "message": "负载均衡策略已更新", "strategy": req.Strategy, }) } } // ReorderChannels 重新排序渠道 func ReorderChannels(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { var req struct { Order []int `json:"order"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "Invalid request body"}) return } if err := cfgManager.ReorderUpstreams(req.Order); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{"message": "渠道顺序已更新"}) } } // SetChannelStatus 设置渠道状态 func SetChannelStatus(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid channel ID"}) return } var req struct { Status string `json:"status"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "Invalid request body"}) return } if err := cfgManager.SetChannelStatus(id, req.Status); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{"message": "渠道状态已更新"}) } } // SetChannelPromotion 设置渠道促销期 // 促销期内的渠道会被优先选择,忽略 trace 亲和性 func SetChannelPromotion(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "无效的渠道 ID"}) return } var req struct { Duration int `json:"duration"` // 促销期时长(秒),0 表示清除 } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "无效的请求参数"}) return } // 调用配置管理器设置促销期 duration := time.Duration(req.Duration) * time.Second if err := cfgManager.SetChannelPromotion(id, duration); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } if req.Duration <= 0 { c.JSON(200, gin.H{ "success": true, "message": "渠道促销期已清除", }) } else { c.JSON(200, gin.H{ "success": true, "message": "渠道促销期已设置", "duration": req.Duration, }) } } } // PingChannel Ping单个渠道 func PingChannel(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid channel ID"}) return } cfg := cfgManager.GetConfig() if id < 0 || id >= len(cfg.Upstream) { c.JSON(http.StatusNotFound, gin.H{"error": "Channel not found"}) return } channel := cfg.Upstream[id] result := pingChannelURLs(&channel) c.JSON(http.StatusOK, result) } } // pingChannelURLs 测试渠道的所有 BaseURL,返回最快的延迟 func pingChannelURLs(ch *config.UpstreamConfig) gin.H { urls := ch.GetAllBaseURLs() if len(urls) == 0 { return gin.H{"success": false, "latency": 0, "status": "error", "error": "no_base_url"} } // 单个 URL 直接测试 if len(urls) == 1 { return pingURL(urls[0], ch.InsecureSkipVerify) } // 多个 URL 并发测试,返回最快的 type pingResult struct { url string latency int64 success bool err string } results := make(chan pingResult, len(urls)) for _, url := range urls { go func(testURL string) { startTime := time.Now() testURL = strings.TrimSuffix(testURL, "/") client := httpclient.GetManager().GetStandardClient(5*time.Second, ch.InsecureSkipVerify) req, err := http.NewRequest("HEAD", testURL, nil) if err != nil { results <- pingResult{url: testURL, latency: 0, success: false, err: "req_creation_failed"} return } resp, err := client.Do(req) latency := time.Since(startTime).Milliseconds() if err != nil { results <- pingResult{url: testURL, latency: latency, success: false, err: err.Error()} return } resp.Body.Close() results <- pingResult{url: testURL, latency: latency, success: true} }(url) } // 收集结果,找最快的成功响应(成功结果始终优先于失败结果) var bestResult *pingResult for i := 0; i < len(urls); i++ { r := <-results if r.success { // 成功结果:优先选择,或选择延迟更低的 if bestResult == nil || !bestResult.success || r.latency < bestResult.latency { bestResult = &r } } else if bestResult == nil || !bestResult.success { // 失败结果:仅当没有成功结果时保存 bestResult = &r } } if bestResult == nil { return gin.H{"success": false, "latency": 0, "status": "error", "error": "all_urls_failed"} } if bestResult.success { return gin.H{"success": true, "latency": bestResult.latency, "status": "healthy"} } return gin.H{"success": false, "latency": bestResult.latency, "status": "error", "error": bestResult.err} } // pingURL 测试单个 URL func pingURL(testURL string, insecureSkipVerify bool) gin.H { startTime := time.Now() testURL = strings.TrimSuffix(testURL, "/") client := httpclient.GetManager().GetStandardClient(5*time.Second, insecureSkipVerify) req, err := http.NewRequest("HEAD", testURL, nil) if err != nil { return gin.H{"success": false, "latency": 0, "status": "error", "error": "req_creation_failed"} } resp, err := client.Do(req) latency := time.Since(startTime).Milliseconds() if err != nil { return gin.H{"success": false, "latency": latency, "status": "error", "error": err.Error()} } resp.Body.Close() return gin.H{"success": true, "latency": latency, "status": "healthy"} } // PingAllChannels Ping所有渠道 func PingAllChannels(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { cfg := cfgManager.GetConfig() results := make(chan gin.H) var wg sync.WaitGroup for i, channel := range cfg.Upstream { wg.Add(1) go func(id int, ch config.UpstreamConfig) { defer wg.Done() result := pingChannelURLs(&ch) result["id"] = id result["name"] = ch.Name results <- result }(i, channel) } go func() { wg.Wait() close(results) }() var finalResults []gin.H for res := range results { finalResults = append(finalResults, res) } c.JSON(http.StatusOK, finalResults) } } ================================================ FILE: backend-go/internal/handlers/messages/handler.go ================================================ // Package messages 提供 Claude Messages API 的处理器 package messages import ( "encoding/json" "fmt" "io" "log" "net/http" "time" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/handlers/common" "github.com/BenedictKing/claude-proxy/internal/middleware" "github.com/BenedictKing/claude-proxy/internal/providers" "github.com/BenedictKing/claude-proxy/internal/scheduler" "github.com/BenedictKing/claude-proxy/internal/types" "github.com/BenedictKing/claude-proxy/internal/utils" "github.com/gin-gonic/gin" ) // Handler Messages API 代理处理器 // 支持多渠道调度:当配置多个渠道时自动启用 func Handler(envCfg *config.EnvConfig, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler) gin.HandlerFunc { return gin.HandlerFunc(func(c *gin.Context) { // 先进行认证 middleware.ProxyAuthMiddleware(envCfg)(c) if c.IsAborted() { return } startTime := time.Now() // 读取请求体 bodyBytes, err := common.ReadRequestBody(c, envCfg.MaxRequestBodySize) if err != nil { return } // 预处理:移除空 signature 字段,预防 400 错误 // modified 表示请求体是否被修改,详细日志由 RemoveEmptySignatures 内部记录 bodyBytes, modified := common.RemoveEmptySignatures(bodyBytes, envCfg.EnableRequestLogs, "Messages") _ = modified // 保留以便未来扩展(如需在 handler 层面做额外处理) // 解析请求 var claudeReq types.ClaudeRequest if len(bodyBytes) > 0 { _ = json.Unmarshal(bodyBytes, &claudeReq) } // 提取 user_id 用于 Trace 亲和性 userID := common.ExtractUserID(bodyBytes) // 记录原始请求信息(仅在入口处记录一次) common.LogOriginalRequest(c, bodyBytes, envCfg, "Messages") // 检查是否为多渠道模式 isMultiChannel := channelScheduler.IsMultiChannelMode(scheduler.ChannelKindMessages) if isMultiChannel { handleMultiChannel(c, envCfg, cfgManager, channelScheduler, bodyBytes, claudeReq, userID, startTime) } else { handleSingleChannel(c, envCfg, cfgManager, channelScheduler, bodyBytes, claudeReq, startTime) } }) } // handleMultiChannel 处理多渠道代理请求 func handleMultiChannel( c *gin.Context, envCfg *config.EnvConfig, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler, bodyBytes []byte, claudeReq types.ClaudeRequest, userID string, startTime time.Time, ) { common.HandleMultiChannelFailover( c, envCfg, channelScheduler, scheduler.ChannelKindMessages, "Messages", userID, func(selection *scheduler.SelectionResult) common.MultiChannelAttemptResult { upstream := selection.Upstream channelIndex := selection.ChannelIndex if upstream == nil { return common.MultiChannelAttemptResult{} } provider := providers.GetProvider(upstream.ServiceType) if provider == nil { return common.MultiChannelAttemptResult{} } metricsManager := channelScheduler.GetMessagesMetricsManager() baseURLs := upstream.GetAllBaseURLs() sortedURLResults := channelScheduler.GetSortedURLsForChannel(scheduler.ChannelKindMessages, channelIndex, baseURLs) handled, successKey, successBaseURLIdx, failoverErr, usage, lastErr := common.TryUpstreamWithAllKeys( c, envCfg, cfgManager, channelScheduler, scheduler.ChannelKindMessages, "Messages", metricsManager, upstream, sortedURLResults, bodyBytes, claudeReq.Stream, func(upstream *config.UpstreamConfig, failedKeys map[string]bool) (string, error) { return cfgManager.GetNextAPIKey(upstream, failedKeys, "Messages") }, func(c *gin.Context, upstreamCopy *config.UpstreamConfig, apiKey string) (*http.Request, error) { req, _, err := provider.ConvertToProviderRequest(c, upstreamCopy, apiKey) return req, err }, func(apiKey string) { if err := cfgManager.DeprioritizeAPIKey(apiKey); err != nil { log.Printf("[Messages-Key] 警告: 密钥降级失败: %v", err) } }, func(url string) { channelScheduler.MarkURLFailure(scheduler.ChannelKindMessages, channelIndex, url) }, func(url string) { channelScheduler.MarkURLSuccess(scheduler.ChannelKindMessages, channelIndex, url) }, func(c *gin.Context, resp *http.Response, upstreamCopy *config.UpstreamConfig, apiKey string) (*types.Usage, error) { if claudeReq.Stream { return common.HandleStreamResponse(c, resp, provider, envCfg, startTime, upstreamCopy, bodyBytes, claudeReq.Model) } return handleNormalResponse(c, resp, provider, envCfg, startTime, bodyBytes, upstreamCopy, apiKey) }, ) return common.MultiChannelAttemptResult{ Handled: handled, Attempted: true, SuccessKey: successKey, SuccessBaseURLIdx: successBaseURLIdx, FailoverError: failoverErr, Usage: usage, LastError: lastErr, } }, nil, func(ctx *gin.Context, failoverErr *common.FailoverError, lastError error) { common.HandleAllChannelsFailed(ctx, cfgManager.GetFuzzyModeEnabled(), failoverErr, lastError, "Messages") }, ) } // handleSingleChannel 处理单渠道代理请求 func handleSingleChannel( c *gin.Context, envCfg *config.EnvConfig, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler, bodyBytes []byte, claudeReq types.ClaudeRequest, startTime time.Time, ) { upstream, err := cfgManager.GetCurrentUpstream() if err != nil { c.JSON(503, gin.H{ "error": "未配置任何渠道,请先在管理界面添加渠道", "code": "NO_UPSTREAM", }) return } if len(upstream.APIKeys) == 0 { c.JSON(503, gin.H{ "error": fmt.Sprintf("当前渠道 \"%s\" 未配置API密钥", upstream.Name), "code": "NO_API_KEYS", }) return } provider := providers.GetProvider(upstream.ServiceType) if provider == nil { c.JSON(400, gin.H{"error": "Unsupported service type"}) return } metricsManager := channelScheduler.GetMessagesMetricsManager() baseURLs := upstream.GetAllBaseURLs() urlResults := common.BuildDefaultURLResults(baseURLs) handled, _, _, lastFailoverError, _, lastError := common.TryUpstreamWithAllKeys( c, envCfg, cfgManager, channelScheduler, scheduler.ChannelKindMessages, "Messages", metricsManager, upstream, urlResults, bodyBytes, claudeReq.Stream, func(upstream *config.UpstreamConfig, failedKeys map[string]bool) (string, error) { return cfgManager.GetNextAPIKey(upstream, failedKeys, "Messages") }, func(c *gin.Context, upstreamCopy *config.UpstreamConfig, apiKey string) (*http.Request, error) { req, _, err := provider.ConvertToProviderRequest(c, upstreamCopy, apiKey) return req, err }, func(apiKey string) { if err := cfgManager.DeprioritizeAPIKey(apiKey); err != nil { log.Printf("[Messages-Key] 警告: 密钥降级失败: %v", err) } }, nil, nil, func(c *gin.Context, resp *http.Response, upstreamCopy *config.UpstreamConfig, apiKey string) (*types.Usage, error) { if claudeReq.Stream { return common.HandleStreamResponse(c, resp, provider, envCfg, startTime, upstreamCopy, bodyBytes, claudeReq.Model) } return handleNormalResponse(c, resp, provider, envCfg, startTime, bodyBytes, upstreamCopy, apiKey) }, ) if handled { return } log.Printf("[Messages-Error] 所有API密钥都失败了") common.HandleAllKeysFailed(c, cfgManager.GetFuzzyModeEnabled(), lastFailoverError, lastError, "Messages") } // handleNormalResponse 处理非流式响应 func handleNormalResponse( c *gin.Context, resp *http.Response, provider providers.Provider, envCfg *config.EnvConfig, startTime time.Time, requestBody []byte, upstream *config.UpstreamConfig, apiKey string, ) (*types.Usage, error) { defer resp.Body.Close() bodyBytes, err := io.ReadAll(resp.Body) if err != nil { c.JSON(500, gin.H{"error": "Failed to read response"}) return nil, err } if envCfg.EnableResponseLogs { responseTime := time.Since(startTime).Milliseconds() log.Printf("[Messages-Timing] 响应完成: %dms, 状态: %d", responseTime, resp.StatusCode) if envCfg.IsDevelopment() { respHeaders := make(map[string]string) for key, values := range resp.Header { if len(values) > 0 { respHeaders[key] = values[0] } } var respHeadersJSON []byte if envCfg.RawLogOutput { respHeadersJSON, _ = json.Marshal(respHeaders) } else { respHeadersJSON, _ = json.MarshalIndent(respHeaders, "", " ") } log.Printf("[Messages-Response] 响应头:\n%s", string(respHeadersJSON)) var formattedBody string if envCfg.RawLogOutput { formattedBody = utils.FormatJSONBytesRaw(bodyBytes) } else { formattedBody = utils.FormatJSONBytesForLog(bodyBytes, 500) } log.Printf("[Messages-Response] 响应体:\n%s", formattedBody) } } providerResp := &types.ProviderResponse{ StatusCode: resp.StatusCode, Headers: resp.Header, Body: bodyBytes, Stream: false, } claudeResp, err := provider.ConvertToClaudeResponse(providerResp) if err != nil { c.JSON(500, gin.H{"error": "Failed to convert response"}) return nil, err } // Token 补全逻辑 if claudeResp.Usage == nil { estimatedInput := utils.EstimateRequestTokens(requestBody) estimatedOutput := utils.EstimateResponseTokens(claudeResp.Content) claudeResp.Usage = &types.Usage{ InputTokens: estimatedInput, OutputTokens: estimatedOutput, } if envCfg.EnableResponseLogs { log.Printf("[Messages-Token] 上游无Usage, 本地估算: input=%d, output=%d", estimatedInput, estimatedOutput) } } else { originalInput := claudeResp.Usage.InputTokens originalOutput := claudeResp.Usage.OutputTokens patched := false hasCacheTokens := claudeResp.Usage.CacheCreationInputTokens > 0 || claudeResp.Usage.CacheReadInputTokens > 0 if claudeResp.Usage.InputTokens <= 1 && !hasCacheTokens { claudeResp.Usage.InputTokens = utils.EstimateRequestTokens(requestBody) patched = true } if claudeResp.Usage.OutputTokens <= 1 { claudeResp.Usage.OutputTokens = utils.EstimateResponseTokens(claudeResp.Content) patched = true } if envCfg.EnableResponseLogs { if patched { log.Printf("[Messages-Token] 虚假值补全: InputTokens=%d->%d, OutputTokens=%d->%d", originalInput, claudeResp.Usage.InputTokens, originalOutput, claudeResp.Usage.OutputTokens) } log.Printf("[Messages-Token] InputTokens=%d, OutputTokens=%d, CacheCreationInputTokens=%d, CacheReadInputTokens=%d, CacheCreation5m=%d, CacheCreation1h=%d, CacheTTL=%s", claudeResp.Usage.InputTokens, claudeResp.Usage.OutputTokens, claudeResp.Usage.CacheCreationInputTokens, claudeResp.Usage.CacheReadInputTokens, claudeResp.Usage.CacheCreation5mInputTokens, claudeResp.Usage.CacheCreation1hInputTokens, claudeResp.Usage.CacheTTL) } } // 监听客户端断开连接 ctx := c.Request.Context() go func() { <-ctx.Done() if !c.Writer.Written() { if envCfg.EnableResponseLogs { responseTime := time.Since(startTime).Milliseconds() log.Printf("[Messages-Timing] 响应中断: %dms, 状态: %d", responseTime, resp.StatusCode) } } }() // 转发上游响应头 utils.ForwardResponseHeaders(resp.Header, c.Writer) c.JSON(200, claudeResp) if envCfg.EnableResponseLogs { responseTime := time.Since(startTime).Milliseconds() log.Printf("[Messages-Timing] 响应发送完成: %dms, 状态: %d", responseTime, resp.StatusCode) } return claudeResp.Usage, nil } // CountTokensHandler 处理 /v1/messages/count_tokens 请求 func CountTokensHandler(envCfg *config.EnvConfig, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler) gin.HandlerFunc { return func(c *gin.Context) { middleware.ProxyAuthMiddleware(envCfg)(c) if c.IsAborted() { return } // 使用统一的请求体读取函数,应用大小限制 bodyBytes, err := common.ReadRequestBody(c, envCfg.MaxRequestBodySize) if err != nil { // ReadRequestBody 已经返回了错误响应 return } var req struct { Model string `json:"model"` System interface{} `json:"system"` Messages interface{} `json:"messages"` Tools interface{} `json:"tools"` } if err := json.Unmarshal(bodyBytes, &req); err != nil { c.JSON(400, gin.H{"error": "Invalid JSON"}) return } inputTokens := utils.EstimateRequestTokens(bodyBytes) c.JSON(200, gin.H{ "input_tokens": inputTokens, }) if envCfg.EnableResponseLogs { log.Printf("[Messages-Token] CountTokens本地估算: model=%s, input_tokens=%d", req.Model, inputTokens) } } } ================================================ FILE: backend-go/internal/handlers/messages/models.go ================================================ // Package messages 提供 Claude Messages API 的处理器 package messages import ( "encoding/json" "io" "log" "net/http" "regexp" "strings" "time" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/httpclient" "github.com/BenedictKing/claude-proxy/internal/middleware" "github.com/BenedictKing/claude-proxy/internal/scheduler" "github.com/BenedictKing/claude-proxy/internal/utils" "github.com/gin-gonic/gin" ) const modelsRequestTimeout = 30 * time.Second // ModelsResponse OpenAI 兼容的 models 响应格式 type ModelsResponse struct { Object string `json:"object"` Data []ModelEntry `json:"data"` } // ModelEntry 单个模型条目 type ModelEntry struct { ID string `json:"id"` Object string `json:"object"` Created int64 `json:"created"` OwnedBy string `json:"owned_by"` } // ModelsHandler 处理 /v1/models 请求,从 Messages 和 Responses 渠道获取并合并模型列表 func ModelsHandler(envCfg *config.EnvConfig, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler) gin.HandlerFunc { return func(c *gin.Context) { middleware.ProxyAuthMiddleware(envCfg)(c) if c.IsAborted() { return } // 并行从两种渠道获取模型列表 messagesModels := fetchModelsFromChannels(c, cfgManager, channelScheduler, false) responsesModels := fetchModelsFromChannels(c, cfgManager, channelScheduler, true) // 合并去重 mergedModels := mergeModels(messagesModels, responsesModels) if len(mergedModels) == 0 { c.JSON(http.StatusNotFound, gin.H{ "error": gin.H{ "message": "models endpoint not available from any upstream", "type": "not_found_error", }, }) return } response := ModelsResponse{ Object: "list", Data: mergedModels, } log.Printf("[Models] 合并完成: messages=%d, responses=%d, merged=%d", len(messagesModels), len(responsesModels), len(mergedModels)) c.JSON(http.StatusOK, response) } } // ModelsDetailHandler 处理 /v1/models/:model 请求,转发到上游 func ModelsDetailHandler(envCfg *config.EnvConfig, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler) gin.HandlerFunc { return func(c *gin.Context) { middleware.ProxyAuthMiddleware(envCfg)(c) if c.IsAborted() { return } modelID := c.Param("model") if modelID == "" { c.JSON(http.StatusBadRequest, gin.H{ "error": gin.H{ "message": "model id is required", "type": "invalid_request_error", }, }) return } // 先尝试 Messages 渠道 if body, ok := tryModelsRequest(c, cfgManager, channelScheduler, "GET", "/"+modelID, false); ok { c.Data(http.StatusOK, "application/json", body) return } // 再尝试 Responses 渠道 if body, ok := tryModelsRequest(c, cfgManager, channelScheduler, "GET", "/"+modelID, true); ok { c.Data(http.StatusOK, "application/json", body) return } c.JSON(http.StatusNotFound, gin.H{ "error": gin.H{ "message": "model not found", "type": "not_found_error", }, }) } } // fetchModelsFromChannels 从指定类型的渠道获取模型列表 func fetchModelsFromChannels(c *gin.Context, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler, isResponses bool) []ModelEntry { body, ok := tryModelsRequest(c, cfgManager, channelScheduler, "GET", "", isResponses) if !ok { return nil } var resp ModelsResponse if err := json.Unmarshal(body, &resp); err != nil { channelType := "Messages" if isResponses { channelType = "Responses" } log.Printf("[%s-Models] 解析渠道响应失败: %v", channelType, err) return nil } return resp.Data } // mergeModels 合并两个模型列表并去重(按 ID) func mergeModels(models1, models2 []ModelEntry) []ModelEntry { seen := make(map[string]bool) var result []ModelEntry // 先添加第一个列表的模型 for _, m := range models1 { if !seen[m.ID] { seen[m.ID] = true result = append(result, m) } } // 再添加第二个列表中不重复的模型 for _, m := range models2 { if !seen[m.ID] { seen[m.ID] = true result = append(result, m) } } return result } // tryModelsRequest 使用调度器选择渠道,按故障转移顺序尝试请求 models 端点 func tryModelsRequest(c *gin.Context, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler, method, suffix string, isResponses bool) ([]byte, bool) { failedChannels := make(map[int]bool) maxChannelRetries := 10 // 最多尝试 10 个渠道 channelType := "Messages" if isResponses { channelType = "Responses" } for attempt := 0; attempt < maxChannelRetries; attempt++ { kind := scheduler.ChannelKindMessages if isResponses { kind = scheduler.ChannelKindResponses } // 使用调度器选择渠道 selection, err := channelScheduler.SelectChannel(c.Request.Context(), "", failedChannels, kind) if err != nil { log.Printf("[%s-Models] 渠道无可用: %v", channelType, err) break } upstream := selection.Upstream // 尝试该渠道的第一个 key if len(upstream.APIKeys) == 0 { failedChannels[selection.ChannelIndex] = true continue } url := buildModelsURL(upstream.BaseURL) + suffix client := httpclient.GetManager().GetStandardClient(modelsRequestTimeout, upstream.InsecureSkipVerify) // 获取第一个可用的 key apiKey, err := cfgManager.GetNextAPIKey(upstream, nil, channelType) if err != nil { log.Printf("[%s-Models] 获取 API Key 失败: channel=%s, error=%v", channelType, upstream.Name, err) failedChannels[selection.ChannelIndex] = true continue } req, err := http.NewRequestWithContext(c.Request.Context(), method, url, nil) if err != nil { log.Printf("[%s-Models] 创建请求失败: channel=%s, url=%s, error=%v", channelType, upstream.Name, url, err) failedChannels[selection.ChannelIndex] = true continue } req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { log.Printf("[%s-Models] 请求失败: channel=%s, key=%s, url=%s, error=%v", channelType, upstream.Name, utils.MaskAPIKey(apiKey), url, err) failedChannels[selection.ChannelIndex] = true continue } if resp.StatusCode == http.StatusOK { body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { log.Printf("[%s-Models] 读取响应失败: channel=%s, error=%v", channelType, upstream.Name, err) failedChannels[selection.ChannelIndex] = true continue } log.Printf("[%s-Models] 请求成功: method=%s, channel=%s, key=%s, url=%s, reason=%s", channelType, method, upstream.Name, utils.MaskAPIKey(apiKey), url, selection.Reason) return body, true } log.Printf("[%s-Models] 上游返回非 200: channel=%s, key=%s, status=%d, url=%s", channelType, upstream.Name, utils.MaskAPIKey(apiKey), resp.StatusCode, url) resp.Body.Close() failedChannels[selection.ChannelIndex] = true } log.Printf("[%s-Models] 所有渠道均失败: method=%s, suffix=%s", channelType, method, suffix) return nil, false } // buildModelsURL 构建 models 端点的 URL func buildModelsURL(baseURL string) string { skipVersionPrefix := strings.HasSuffix(baseURL, "#") if skipVersionPrefix { baseURL = strings.TrimSuffix(baseURL, "#") } baseURL = strings.TrimSuffix(baseURL, "/") versionPattern := regexp.MustCompile(`/v\d+[a-z]*$`) hasVersionSuffix := versionPattern.MatchString(baseURL) endpoint := "/models" if !hasVersionSuffix && !skipVersionPrefix { endpoint = "/v1" + endpoint } return baseURL + endpoint } ================================================ FILE: backend-go/internal/handlers/responses/channels.go ================================================ // Package responses 提供 Responses API 的渠道管理 package responses import ( "strconv" "strings" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/scheduler" "github.com/gin-gonic/gin" ) // GetUpstreams 获取 Responses 上游列表 func GetUpstreams(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { cfg := cfgManager.GetConfig() upstreams := make([]gin.H, len(cfg.ResponsesUpstream)) for i, up := range cfg.ResponsesUpstream { status := config.GetChannelStatus(&up) priority := config.GetChannelPriority(&up, i) upstreams[i] = gin.H{ "index": i, "name": up.Name, "serviceType": up.ServiceType, "baseUrl": up.BaseURL, "baseUrls": up.BaseURLs, "apiKeys": up.APIKeys, "description": up.Description, "website": up.Website, "insecureSkipVerify": up.InsecureSkipVerify, "modelMapping": up.ModelMapping, "latency": nil, "status": status, "priority": priority, "promotionUntil": up.PromotionUntil, "lowQuality": up.LowQuality, } } c.JSON(200, gin.H{ "channels": upstreams, "loadBalance": cfg.ResponsesLoadBalance, }) } } // AddUpstream 添加 Responses 上游 func AddUpstream(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { var upstream config.UpstreamConfig if err := c.ShouldBindJSON(&upstream); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } if err := cfgManager.AddResponsesUpstream(upstream); err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{"message": "Responses upstream added successfully"}) } } // UpdateUpstream 更新 Responses 上游 func UpdateUpstream(cfgManager *config.ConfigManager, sch *scheduler.ChannelScheduler) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid upstream ID"}) return } var updates config.UpstreamUpdate if err := c.ShouldBindJSON(&updates); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } shouldResetMetrics, err := cfgManager.UpdateResponsesUpstream(id, updates) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } // 单 key 更换时重置熔断状态 if shouldResetMetrics { sch.ResetChannelMetrics(id, scheduler.ChannelKindResponses) } c.JSON(200, gin.H{"message": "Responses upstream updated successfully"}) } } // DeleteUpstream 删除 Responses 上游 func DeleteUpstream(cfgManager *config.ConfigManager, sch *scheduler.ChannelScheduler) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid upstream ID"}) return } removed, err := cfgManager.RemoveResponsesUpstream(id) if err != nil { if strings.Contains(err.Error(), "无效的") { c.JSON(404, gin.H{"error": "Upstream not found"}) } else { c.JSON(500, gin.H{"error": err.Error()}) } return } // 删除成功后清理指标数据(使用 RemoveResponsesUpstream 返回的渠道信息) sch.DeleteChannelMetrics(removed, scheduler.ChannelKindResponses) c.JSON(200, gin.H{"message": "Responses upstream deleted successfully"}) } } // AddApiKey 添加 Responses 渠道 API 密钥 func AddApiKey(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid upstream ID"}) return } var req struct { APIKey string `json:"apiKey"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "Invalid request body"}) return } if err := cfgManager.AddResponsesAPIKey(id, req.APIKey); err != nil { if strings.Contains(err.Error(), "无效的上游索引") { c.JSON(404, gin.H{"error": "Upstream not found"}) } else if strings.Contains(err.Error(), "API密钥已存在") { c.JSON(400, gin.H{"error": "API密钥已存在"}) } else { c.JSON(500, gin.H{"error": "Failed to save config"}) } return } c.JSON(200, gin.H{ "message": "API密钥已添加", "success": true, }) } } // DeleteApiKey 删除 Responses 渠道 API 密钥 func DeleteApiKey(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid upstream ID"}) return } apiKey := c.Param("apiKey") if apiKey == "" { c.JSON(400, gin.H{"error": "API key is required"}) return } if err := cfgManager.RemoveResponsesAPIKey(id, apiKey); err != nil { if strings.Contains(err.Error(), "无效的上游索引") { c.JSON(404, gin.H{"error": "Upstream not found"}) } else if strings.Contains(err.Error(), "API密钥不存在") { c.JSON(404, gin.H{"error": "API key not found"}) } else { c.JSON(500, gin.H{"error": "Failed to save config"}) } return } c.JSON(200, gin.H{ "message": "API密钥已删除", }) } } // MoveApiKeyToTop 将 Responses 渠道 API 密钥移到最前面 func MoveApiKeyToTop(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) apiKey := c.Param("apiKey") if err := cfgManager.MoveResponsesAPIKeyToTop(id, apiKey); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{"message": "API密钥已置顶"}) } } // MoveApiKeyToBottom 将 Responses 渠道 API 密钥移到最后面 func MoveApiKeyToBottom(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) apiKey := c.Param("apiKey") if err := cfgManager.MoveResponsesAPIKeyToBottom(id, apiKey); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{"message": "API密钥已置底"}) } } // UpdateLoadBalance 更新 Responses 负载均衡策略 func UpdateLoadBalance(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { var req struct { Strategy string `json:"strategy"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "Invalid request body"}) return } if err := cfgManager.SetResponsesLoadBalance(req.Strategy); err != nil { if strings.Contains(err.Error(), "无效的负载均衡策略") { c.JSON(400, gin.H{"error": err.Error()}) } else { c.JSON(500, gin.H{"error": "Failed to save config"}) } return } c.JSON(200, gin.H{ "message": "Responses 负载均衡策略已更新", "strategy": req.Strategy, }) } } // ReorderChannels 重新排序 Responses 渠道优先级 func ReorderChannels(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { var req struct { Order []int `json:"order"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "Invalid request body"}) return } if err := cfgManager.ReorderResponsesUpstreams(req.Order); err != nil { c.JSON(400, gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{ "success": true, "message": "Responses 渠道优先级已更新", }) } } // SetChannelStatus 设置 Responses 渠道状态 func SetChannelStatus(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { idStr := c.Param("id") id, err := strconv.Atoi(idStr) if err != nil { c.JSON(400, gin.H{"error": "Invalid channel ID"}) return } var req struct { Status string `json:"status"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "Invalid request body"}) return } if err := cfgManager.SetResponsesChannelStatus(id, req.Status); err != nil { if strings.Contains(err.Error(), "无效的上游索引") { c.JSON(404, gin.H{"error": "Channel not found"}) } else { c.JSON(400, gin.H{"error": err.Error()}) } return } c.JSON(200, gin.H{ "success": true, "message": "Responses 渠道状态已更新", "status": req.Status, }) } } ================================================ FILE: backend-go/internal/handlers/responses/compact.go ================================================ // Package responses 提供 Responses API 的处理器 package responses import ( "bytes" "io" "log" "net/http" "regexp" "strings" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/handlers/common" "github.com/BenedictKing/claude-proxy/internal/middleware" "github.com/BenedictKing/claude-proxy/internal/scheduler" "github.com/BenedictKing/claude-proxy/internal/session" "github.com/BenedictKing/claude-proxy/internal/utils" "github.com/gin-gonic/gin" ) // compactError 封装 compact 请求错误 type compactError struct { status int body []byte shouldFailover bool } // CompactHandler Responses API compact 端点处理器 // POST /v1/responses/compact - 压缩对话上下文,用于长期代理工作流 func CompactHandler( envCfg *config.EnvConfig, cfgManager *config.ConfigManager, _ *session.SessionManager, channelScheduler *scheduler.ChannelScheduler, ) gin.HandlerFunc { return gin.HandlerFunc(func(c *gin.Context) { // 认证 middleware.ProxyAuthMiddleware(envCfg)(c) if c.IsAborted() { return } // 读取请求体 maxBodySize := envCfg.MaxRequestBodySize bodyBytes, err := common.ReadRequestBody(c, maxBodySize) if err != nil { return } // 提取对话标识用于 Trace 亲和性 userID := common.ExtractConversationID(c, bodyBytes) // 检查是否为多渠道模式 isMultiChannel := channelScheduler.IsMultiChannelMode(scheduler.ChannelKindResponses) if isMultiChannel { handleMultiChannelCompact(c, envCfg, cfgManager, channelScheduler, bodyBytes, userID) } else { handleSingleChannelCompact(c, envCfg, cfgManager, bodyBytes) } }) } // handleSingleChannelCompact 单渠道 compact 请求(带 key 轮转) func handleSingleChannelCompact( c *gin.Context, envCfg *config.EnvConfig, cfgManager *config.ConfigManager, bodyBytes []byte, ) { upstream, err := cfgManager.GetCurrentResponsesUpstream() if err != nil { c.JSON(503, gin.H{"error": "未配置任何 Responses 渠道"}) return } if len(upstream.APIKeys) == 0 { c.JSON(503, gin.H{"error": "当前渠道未配置 API 密钥"}) return } // Key 轮转:尝试所有可用 key failedKeys := make(map[string]bool) var lastErr *compactError for attempt := 0; attempt < len(upstream.APIKeys); attempt++ { apiKey, err := cfgManager.GetNextResponsesAPIKey(upstream, failedKeys) if err != nil { break } success, compactErr := tryCompactWithKey(c, upstream, apiKey, bodyBytes, envCfg, cfgManager) if success { return } if compactErr != nil { lastErr = compactErr if compactErr.shouldFailover { failedKeys[apiKey] = true cfgManager.MarkKeyAsFailed(apiKey, "Responses") continue } // 非故障转移错误,直接返回 c.Data(compactErr.status, "application/json", compactErr.body) return } } // 所有 key 都失败 if cfgManager.GetFuzzyModeEnabled() { c.JSON(503, gin.H{ "type": "error", "error": gin.H{ "type": "service_unavailable", "message": "All upstream channels are currently unavailable", }, }) return } if lastErr != nil { c.Data(lastErr.status, "application/json", lastErr.body) } else { c.JSON(503, gin.H{"error": "所有 API 密钥都不可用"}) } } // handleMultiChannelCompact 多渠道 compact 请求(带故障转移和亲和性) func handleMultiChannelCompact( c *gin.Context, envCfg *config.EnvConfig, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler, bodyBytes []byte, userID string, ) { failedChannels := make(map[int]bool) maxAttempts := channelScheduler.GetActiveChannelCount(scheduler.ChannelKindResponses) var lastErr *compactError for attempt := 0; attempt < maxAttempts; attempt++ { selection, err := channelScheduler.SelectChannel(c.Request.Context(), userID, failedChannels, scheduler.ChannelKindResponses) if err != nil { break } upstream := selection.Upstream channelIndex := selection.ChannelIndex // 每个渠道尝试所有 key success, successKey, compactErr := tryCompactChannelWithAllKeys(c, upstream, cfgManager, channelScheduler, bodyBytes, envCfg) if success { // compact 不产生 usage,但仍需记录成功以更新熔断器/权重 if successKey != "" { channelScheduler.RecordSuccessWithUsage(upstream.BaseURL, successKey, nil, scheduler.ChannelKindResponses) // 只有真正成功的请求才设置 Trace 亲和 channelScheduler.SetTraceAffinity(userID, channelIndex) } return } failedChannels[channelIndex] = true if compactErr != nil { lastErr = compactErr } } // 所有渠道都失败 if cfgManager.GetFuzzyModeEnabled() { c.JSON(503, gin.H{ "type": "error", "error": gin.H{ "type": "service_unavailable", "message": "All upstream channels are currently unavailable", }, }) return } if lastErr != nil { c.Data(lastErr.status, "application/json", lastErr.body) } else { c.JSON(503, gin.H{"error": "所有 Responses 渠道都不可用"}) } } // tryCompactChannelWithAllKeys 尝试渠道的所有 key func tryCompactChannelWithAllKeys( c *gin.Context, upstream *config.UpstreamConfig, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler, bodyBytes []byte, envCfg *config.EnvConfig, ) (bool, string, *compactError) { if len(upstream.APIKeys) == 0 { return false, "", nil } metricsManager := channelScheduler.GetResponsesMetricsManager() failedKeys := make(map[string]bool) var lastErr *compactError // 强制探测模式 forceProbeMode := common.AreAllKeysSuspended(metricsManager, upstream.BaseURL, upstream.APIKeys) if forceProbeMode { log.Printf("[Compact-Probe] 渠道 %s 所有 Key 都被熔断,启用强制探测模式", upstream.Name) } for attempt := 0; attempt < len(upstream.APIKeys); attempt++ { apiKey, err := cfgManager.GetNextResponsesAPIKey(upstream, failedKeys) if err != nil { break } // 检查熔断状态 if !forceProbeMode && metricsManager.ShouldSuspendKey(upstream.BaseURL, apiKey) { failedKeys[apiKey] = true log.Printf("[Compact-Key] 跳过熔断中的 Key: %s", utils.MaskAPIKey(apiKey)) continue } success, compactErr := tryCompactWithKey(c, upstream, apiKey, bodyBytes, envCfg, cfgManager) if success { return true, apiKey, nil } if compactErr != nil { lastErr = compactErr if compactErr.shouldFailover { failedKeys[apiKey] = true cfgManager.MarkKeyAsFailed(apiKey, "Responses") channelScheduler.RecordFailure(upstream.BaseURL, apiKey, scheduler.ChannelKindResponses) continue } // 非故障转移错误,返回但标记渠道成功(请求已处理) c.Data(compactErr.status, "application/json", compactErr.body) return true, "", nil } } return false, "", lastErr } // tryCompactWithKey 使用单个 key 尝试 compact 请求 func tryCompactWithKey( c *gin.Context, upstream *config.UpstreamConfig, apiKey string, bodyBytes []byte, envCfg *config.EnvConfig, cfgManager *config.ConfigManager, ) (bool, *compactError) { targetURL := buildCompactURL(upstream) req, err := http.NewRequestWithContext(c.Request.Context(), "POST", targetURL, bytes.NewReader(bodyBytes)) if err != nil { return false, &compactError{status: 500, body: []byte(`{"error":"创建请求失败"}`), shouldFailover: true} } req.Header = utils.PrepareUpstreamHeaders(c, req.URL.Host) req.Header.Del("authorization") req.Header.Del("x-api-key") utils.SetAuthenticationHeader(req.Header, apiKey) req.Header.Set("Content-Type", "application/json") resp, err := common.SendRequest(req, upstream, envCfg, false, "Responses") if err != nil { return false, &compactError{status: 502, body: []byte(`{"error":"上游请求失败"}`), shouldFailover: true} } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) respBody = utils.DecompressGzipIfNeeded(resp, respBody) // 判断是否需要故障转移 if resp.StatusCode < 200 || resp.StatusCode >= 300 { shouldFailover, _ := common.ShouldRetryWithNextKey(resp.StatusCode, respBody, cfgManager.GetFuzzyModeEnabled(), "Responses") return false, &compactError{status: resp.StatusCode, body: respBody, shouldFailover: shouldFailover} } // 成功 utils.ForwardResponseHeaders(resp.Header, c.Writer) c.Data(resp.StatusCode, "application/json", respBody) return true, nil } // buildCompactURL 构建 compact 端点 URL func buildCompactURL(upstream *config.UpstreamConfig) string { baseURL := strings.TrimSuffix(upstream.BaseURL, "/") versionPattern := regexp.MustCompile(`/v\d+[a-z]*$`) if versionPattern.MatchString(baseURL) { return baseURL + "/responses/compact" } return baseURL + "/v1/responses/compact" } ================================================ FILE: backend-go/internal/handlers/responses/handler.go ================================================ // Package responses 提供 Responses API 的处理器 package responses import ( "bufio" "bytes" "encoding/json" "fmt" "io" "log" "net/http" "strings" "time" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/converters" "github.com/BenedictKing/claude-proxy/internal/handlers/common" "github.com/BenedictKing/claude-proxy/internal/middleware" "github.com/BenedictKing/claude-proxy/internal/providers" "github.com/BenedictKing/claude-proxy/internal/scheduler" "github.com/BenedictKing/claude-proxy/internal/session" "github.com/BenedictKing/claude-proxy/internal/types" "github.com/BenedictKing/claude-proxy/internal/utils" "github.com/gin-gonic/gin" ) // Handler Responses API 代理处理器 // 支持多渠道调度:当配置多个渠道时自动启用 func Handler( envCfg *config.EnvConfig, cfgManager *config.ConfigManager, sessionManager *session.SessionManager, channelScheduler *scheduler.ChannelScheduler, ) gin.HandlerFunc { return gin.HandlerFunc(func(c *gin.Context) { // 先进行认证 middleware.ProxyAuthMiddleware(envCfg)(c) if c.IsAborted() { return } startTime := time.Now() // 读取原始请求体 maxBodySize := envCfg.MaxRequestBodySize bodyBytes, err := common.ReadRequestBody(c, maxBodySize) if err != nil { return } // 解析 Responses 请求 var responsesReq types.ResponsesRequest if len(bodyBytes) > 0 { _ = json.Unmarshal(bodyBytes, &responsesReq) } // 提取对话标识用于 Trace 亲和性 userID := common.ExtractConversationID(c, bodyBytes) // 记录原始请求信息(仅在入口处记录一次) common.LogOriginalRequest(c, bodyBytes, envCfg, "Responses") // 检查是否为多渠道模式 isMultiChannel := channelScheduler.IsMultiChannelMode(scheduler.ChannelKindResponses) if isMultiChannel { handleMultiChannel(c, envCfg, cfgManager, channelScheduler, sessionManager, bodyBytes, responsesReq, userID, startTime) } else { handleSingleChannel(c, envCfg, cfgManager, channelScheduler, sessionManager, bodyBytes, responsesReq, startTime) } }) } // handleMultiChannel 处理多渠道 Responses 请求 func handleMultiChannel( c *gin.Context, envCfg *config.EnvConfig, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler, sessionManager *session.SessionManager, bodyBytes []byte, responsesReq types.ResponsesRequest, userID string, startTime time.Time, ) { provider := &providers.ResponsesProvider{SessionManager: sessionManager} metricsManager := channelScheduler.GetResponsesMetricsManager() common.HandleMultiChannelFailover( c, envCfg, channelScheduler, scheduler.ChannelKindResponses, "Responses", userID, func(selection *scheduler.SelectionResult) common.MultiChannelAttemptResult { upstream := selection.Upstream channelIndex := selection.ChannelIndex if upstream == nil { return common.MultiChannelAttemptResult{} } baseURLs := upstream.GetAllBaseURLs() sortedURLResults := channelScheduler.GetSortedURLsForChannel(scheduler.ChannelKindResponses, channelIndex, baseURLs) handled, successKey, successBaseURLIdx, failoverErr, usage, lastErr := common.TryUpstreamWithAllKeys( c, envCfg, cfgManager, channelScheduler, scheduler.ChannelKindResponses, "Responses", metricsManager, upstream, sortedURLResults, bodyBytes, responsesReq.Stream, func(upstream *config.UpstreamConfig, failedKeys map[string]bool) (string, error) { return cfgManager.GetNextResponsesAPIKey(upstream, failedKeys) }, func(c *gin.Context, upstreamCopy *config.UpstreamConfig, apiKey string) (*http.Request, error) { req, _, err := provider.ConvertToProviderRequest(c, upstreamCopy, apiKey) return req, err }, func(apiKey string) { _ = cfgManager.DeprioritizeAPIKey(apiKey) }, func(url string) { channelScheduler.MarkURLFailure(scheduler.ChannelKindResponses, channelIndex, url) }, func(url string) { channelScheduler.MarkURLSuccess(scheduler.ChannelKindResponses, channelIndex, url) }, func(c *gin.Context, resp *http.Response, upstreamCopy *config.UpstreamConfig, apiKey string) (*types.Usage, error) { return handleSuccess(c, resp, provider, upstream.ServiceType, envCfg, sessionManager, startTime, &responsesReq, bodyBytes) }, ) return common.MultiChannelAttemptResult{ Handled: handled, Attempted: true, SuccessKey: successKey, SuccessBaseURLIdx: successBaseURLIdx, FailoverError: failoverErr, Usage: usage, LastError: lastErr, } }, nil, func(ctx *gin.Context, failoverErr *common.FailoverError, lastError error) { common.HandleAllChannelsFailed(ctx, cfgManager.GetFuzzyModeEnabled(), failoverErr, lastError, "Responses") }, ) } // handleSingleChannel 处理单渠道 Responses 请求 func handleSingleChannel( c *gin.Context, envCfg *config.EnvConfig, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler, sessionManager *session.SessionManager, bodyBytes []byte, responsesReq types.ResponsesRequest, startTime time.Time, ) { upstream, err := cfgManager.GetCurrentResponsesUpstream() if err != nil { c.JSON(503, gin.H{ "error": "未配置任何 Responses 渠道,请先在管理界面添加渠道", "code": "NO_RESPONSES_UPSTREAM", }) return } if len(upstream.APIKeys) == 0 { c.JSON(503, gin.H{ "error": fmt.Sprintf("当前 Responses 渠道 \"%s\" 未配置API密钥", upstream.Name), "code": "NO_API_KEYS", }) return } provider := &providers.ResponsesProvider{SessionManager: sessionManager} metricsManager := channelScheduler.GetResponsesMetricsManager() baseURLs := upstream.GetAllBaseURLs() urlResults := common.BuildDefaultURLResults(baseURLs) handled, _, _, lastFailoverError, _, lastError := common.TryUpstreamWithAllKeys( c, envCfg, cfgManager, channelScheduler, scheduler.ChannelKindResponses, "Responses", metricsManager, upstream, urlResults, bodyBytes, responsesReq.Stream, func(upstream *config.UpstreamConfig, failedKeys map[string]bool) (string, error) { return cfgManager.GetNextResponsesAPIKey(upstream, failedKeys) }, func(c *gin.Context, upstreamCopy *config.UpstreamConfig, apiKey string) (*http.Request, error) { req, _, err := provider.ConvertToProviderRequest(c, upstreamCopy, apiKey) return req, err }, func(apiKey string) { if err := cfgManager.DeprioritizeAPIKey(apiKey); err != nil { log.Printf("[Responses-Key] 警告: 密钥降级失败: %v", err) } }, nil, nil, func(c *gin.Context, resp *http.Response, upstreamCopy *config.UpstreamConfig, apiKey string) (*types.Usage, error) { return handleSuccess(c, resp, provider, upstream.ServiceType, envCfg, sessionManager, startTime, &responsesReq, bodyBytes) }, ) if handled { return } log.Printf("[Responses-Error] 所有 Responses API密钥都失败了") common.HandleAllKeysFailed(c, cfgManager.GetFuzzyModeEnabled(), lastFailoverError, lastError, "Responses") } // handleSuccess 处理成功的 Responses 响应 func handleSuccess( c *gin.Context, resp *http.Response, provider *providers.ResponsesProvider, upstreamType string, envCfg *config.EnvConfig, sessionManager *session.SessionManager, startTime time.Time, originalReq *types.ResponsesRequest, originalRequestJSON []byte, ) (*types.Usage, error) { defer resp.Body.Close() isStream := originalReq != nil && originalReq.Stream if isStream { return handleStreamSuccess(c, resp, upstreamType, envCfg, startTime, originalReq, originalRequestJSON), nil } // 非流式响应处理 bodyBytes, err := io.ReadAll(resp.Body) if err != nil { c.JSON(500, gin.H{"error": "Failed to read response"}) return nil, err } if envCfg.EnableResponseLogs { responseTime := time.Since(startTime).Milliseconds() log.Printf("[Responses-Timing] Responses 响应完成: %dms, 状态: %d", responseTime, resp.StatusCode) if envCfg.IsDevelopment() { respHeaders := make(map[string]string) for key, values := range resp.Header { if len(values) > 0 { respHeaders[key] = values[0] } } var respHeadersJSON []byte if envCfg.RawLogOutput { respHeadersJSON, _ = json.Marshal(respHeaders) } else { respHeadersJSON, _ = json.MarshalIndent(respHeaders, "", " ") } log.Printf("[Responses-Response] 响应头:\n%s", string(respHeadersJSON)) var formattedBody string if envCfg.RawLogOutput { formattedBody = utils.FormatJSONBytesRaw(bodyBytes) } else { formattedBody = utils.FormatJSONBytesForLog(bodyBytes, 500) } log.Printf("[Responses-Response] 响应体:\n%s", formattedBody) } } providerResp := &types.ProviderResponse{ StatusCode: resp.StatusCode, Headers: resp.Header, Body: bodyBytes, Stream: false, } responsesResp, err := provider.ConvertToResponsesResponse(providerResp, upstreamType, "") if err != nil { c.JSON(500, gin.H{"error": "Failed to convert response"}) return nil, err } // Token 补全逻辑 patchResponsesUsage(responsesResp, originalRequestJSON, envCfg) // 更新会话 if originalReq.Store == nil || *originalReq.Store { sess, err := sessionManager.GetOrCreateSession(originalReq.PreviousResponseID) if err == nil { inputItems, _ := parseInputToItems(originalReq.Input) for _, item := range inputItems { sessionManager.AppendMessage(sess.ID, item, 0) } for _, item := range responsesResp.Output { sessionManager.AppendMessage(sess.ID, item, responsesResp.Usage.TotalTokens) } sessionManager.UpdateLastResponseID(sess.ID, responsesResp.ID) sessionManager.RecordResponseMapping(responsesResp.ID, sess.ID) if sess.LastResponseID != "" { responsesResp.PreviousID = sess.LastResponseID } } } utils.ForwardResponseHeaders(resp.Header, c.Writer) c.JSON(200, responsesResp) // 返回 usage 数据用于指标记录 return &types.Usage{ InputTokens: responsesResp.Usage.InputTokens, OutputTokens: responsesResp.Usage.OutputTokens, CacheCreationInputTokens: responsesResp.Usage.CacheCreationInputTokens, CacheReadInputTokens: responsesResp.Usage.CacheReadInputTokens, CacheCreation5mInputTokens: responsesResp.Usage.CacheCreation5mInputTokens, CacheCreation1hInputTokens: responsesResp.Usage.CacheCreation1hInputTokens, CacheTTL: responsesResp.Usage.CacheTTL, }, nil } // patchResponsesUsage 补全 Responses 响应的 Token 统计 func patchResponsesUsage(resp *types.ResponsesResponse, requestBody []byte, envCfg *config.EnvConfig) { // 检查是否有 Claude 原生缓存 token(有时才跳过 input_tokens 修补) // 仅检测 Claude 原生字段:cache_creation_input_tokens, cache_read_input_tokens, // cache_creation_5m_input_tokens, cache_creation_1h_input_tokens // 注意:不检测 input_tokens_details.cached_tokens(OpenAI 格式),避免错误跳过 hasClaudeCache := resp.Usage.CacheCreationInputTokens > 0 || resp.Usage.CacheReadInputTokens > 0 || resp.Usage.CacheCreation5mInputTokens > 0 || resp.Usage.CacheCreation1hInputTokens > 0 // 检查是否需要补全 needInputPatch := resp.Usage.InputTokens <= 1 && !hasClaudeCache needOutputPatch := resp.Usage.OutputTokens <= 1 // 如果 usage 完全为空,进行完整估算 if resp.Usage.InputTokens == 0 && resp.Usage.OutputTokens == 0 && resp.Usage.TotalTokens == 0 { estimatedInput := utils.EstimateResponsesRequestTokens(requestBody) estimatedOutput := estimateResponsesOutputFromItems(resp.Output) resp.Usage.InputTokens = estimatedInput resp.Usage.OutputTokens = estimatedOutput resp.Usage.TotalTokens = estimatedInput + estimatedOutput if envCfg.EnableResponseLogs { log.Printf("[Responses-Token] 上游无Usage, 本地估算: input=%d, output=%d", estimatedInput, estimatedOutput) } return } // 修补虚假值 originalInput := resp.Usage.InputTokens originalOutput := resp.Usage.OutputTokens patched := false if needInputPatch { resp.Usage.InputTokens = utils.EstimateResponsesRequestTokens(requestBody) patched = true } if needOutputPatch { resp.Usage.OutputTokens = estimateResponsesOutputFromItems(resp.Output) patched = true } // 重新计算 TotalTokens(修补时或 total_tokens 为 0 但 input/output 有效时) if patched || (resp.Usage.TotalTokens == 0 && (resp.Usage.InputTokens > 0 || resp.Usage.OutputTokens > 0)) { resp.Usage.TotalTokens = resp.Usage.InputTokens + resp.Usage.OutputTokens } if envCfg.EnableResponseLogs { if patched { log.Printf("[Responses-Token] 虚假值修补: InputTokens=%d->%d, OutputTokens=%d->%d", originalInput, resp.Usage.InputTokens, originalOutput, resp.Usage.OutputTokens) } log.Printf("[Responses-Token] InputTokens=%d, OutputTokens=%d, TotalTokens=%d, CacheCreation=%d, CacheRead=%d, CacheCreation5m=%d, CacheCreation1h=%d, CacheTTL=%s", resp.Usage.InputTokens, resp.Usage.OutputTokens, resp.Usage.TotalTokens, resp.Usage.CacheCreationInputTokens, resp.Usage.CacheReadInputTokens, resp.Usage.CacheCreation5mInputTokens, resp.Usage.CacheCreation1hInputTokens, resp.Usage.CacheTTL) } } // estimateResponsesOutputFromItems 从 ResponsesItem 数组估算输出 token func estimateResponsesOutputFromItems(output []types.ResponsesItem) int { if len(output) == 0 { return 0 } total := 0 for _, item := range output { // 处理 content if item.Content != nil { switch v := item.Content.(type) { case string: total += utils.EstimateTokens(v) case []interface{}: for _, block := range v { if b, ok := block.(map[string]interface{}); ok { if text, ok := b["text"].(string); ok { total += utils.EstimateTokens(text) } } } case []types.ContentBlock: // 处理结构化 ContentBlock 数组 for _, block := range v { if block.Text != "" { total += utils.EstimateTokens(block.Text) } } default: // 回退:序列化后估算 data, _ := json.Marshal(v) total += utils.EstimateTokens(string(data)) } } // 处理 tool_use if item.ToolUse != nil { if item.ToolUse.Name != "" { total += utils.EstimateTokens(item.ToolUse.Name) + 2 } if item.ToolUse.Input != nil { data, _ := json.Marshal(item.ToolUse.Input) total += utils.EstimateTokens(string(data)) } } // 处理 function_call 类型(item.Type == "function_call") if item.Type == "function_call" { // 在转换后的响应中,function_call 的参数可能在 Content 中 if contentStr, ok := item.Content.(string); ok { total += utils.EstimateTokens(contentStr) } } } return total } // handleStreamSuccess 处理流式响应 func handleStreamSuccess( c *gin.Context, resp *http.Response, upstreamType string, envCfg *config.EnvConfig, startTime time.Time, originalReq *types.ResponsesRequest, originalRequestJSON []byte, ) *types.Usage { if envCfg.EnableResponseLogs { responseTime := time.Since(startTime).Milliseconds() log.Printf("[Responses-Stream] Responses 流式响应开始: %dms, 状态: %d", responseTime, resp.StatusCode) } utils.ForwardResponseHeaders(resp.Header, c.Writer) c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") var synthesizer *utils.StreamSynthesizer var logBuffer bytes.Buffer streamLoggingEnabled := envCfg.IsDevelopment() && envCfg.EnableResponseLogs if streamLoggingEnabled { synthesizer = utils.NewStreamSynthesizer(upstreamType) } needConvert := upstreamType != "responses" var converterState any c.Status(resp.StatusCode) flusher, _ := c.Writer.(http.Flusher) scanner := bufio.NewScanner(resp.Body) const maxCapacity = 1024 * 1024 buf := make([]byte, 0, 64*1024) scanner.Buffer(buf, maxCapacity) // Token 统计状态 var outputTextBuffer bytes.Buffer const maxOutputBufferSize = 1024 * 1024 // 1MB 上限,防止内存溢出 var collectedUsage responsesStreamUsage hasUsage := false needTokenPatch := false clientGone := false for scanner.Scan() { line := scanner.Text() if streamLoggingEnabled { logBuffer.WriteString(line + "\n") if synthesizer != nil { synthesizer.ProcessLine(line) } } // 处理转换后的事件 var eventsToProcess []string if needConvert { events := converters.ConvertOpenAIChatToResponses( c.Request.Context(), originalReq.Model, originalRequestJSON, nil, []byte(line), &converterState, ) eventsToProcess = events } else { eventsToProcess = []string{line + "\n"} } for _, event := range eventsToProcess { // 提取文本内容用于估算(限制缓冲区大小) if outputTextBuffer.Len() < maxOutputBufferSize { extractResponsesTextFromEvent(event, &outputTextBuffer) } // 检测并收集 usage detected, needPatch, usageData := checkResponsesEventUsage(event, envCfg.EnableResponseLogs && envCfg.ShouldLog("debug")) if detected { if !hasUsage { hasUsage = true needTokenPatch = needPatch if envCfg.EnableResponseLogs && envCfg.ShouldLog("debug") && needPatch { log.Printf("[Responses-Stream-Token] 检测到虚假值, 延迟到流结束修补") } } updateResponsesStreamUsage(&collectedUsage, usageData) } // 在 response.completed 事件前注入/修补 usage eventToSend := event if isResponsesCompletedEvent(event) { if !hasUsage { // 上游完全没有 usage,注入本地估算 var injectedInput, injectedOutput int eventToSend, injectedInput, injectedOutput = injectResponsesUsageToCompletedEvent(event, originalRequestJSON, outputTextBuffer.String(), envCfg) // 更新 collectedUsage 以便最终日志输出 collectedUsage.InputTokens = injectedInput collectedUsage.OutputTokens = injectedOutput collectedUsage.TotalTokens = injectedInput + injectedOutput if envCfg.EnableResponseLogs && envCfg.ShouldLog("debug") { log.Printf("[Responses-Stream-Token] 上游无usage, 注入本地估算: input=%d, output=%d", injectedInput, injectedOutput) } } else if needTokenPatch { // 需要修补虚假值 eventToSend = patchResponsesCompletedEventUsage(event, originalRequestJSON, outputTextBuffer.String(), &collectedUsage, envCfg) } } // 转发给客户端 if !clientGone { _, err := c.Writer.Write([]byte(eventToSend)) if err != nil { clientGone = true if !isClientDisconnectError(err) { log.Printf("[Responses-Stream] 警告: 流式响应传输错误: %v", err) } else if envCfg.ShouldLog("info") { log.Printf("[Responses-Stream] 客户端中断连接 (正常行为),继续接收上游数据...") } } else if flusher != nil { flusher.Flush() } } } } if err := scanner.Err(); err != nil { log.Printf("[Responses-Stream] 警告: 流式响应读取错误: %v", err) } if envCfg.EnableResponseLogs { responseTime := time.Since(startTime).Milliseconds() log.Printf("[Responses-Stream] Responses 流式响应完成: %dms", responseTime) // 输出 Token 统计 if hasUsage || collectedUsage.InputTokens > 0 || collectedUsage.OutputTokens > 0 { log.Printf("[Responses-Stream-Token] InputTokens=%d, OutputTokens=%d, CacheCreation=%d, CacheRead=%d, CacheCreation5m=%d, CacheCreation1h=%d, CacheTTL=%s", collectedUsage.InputTokens, collectedUsage.OutputTokens, collectedUsage.CacheCreationInputTokens, collectedUsage.CacheReadInputTokens, collectedUsage.CacheCreation5mInputTokens, collectedUsage.CacheCreation1hInputTokens, collectedUsage.CacheTTL) } if envCfg.IsDevelopment() { if synthesizer != nil { synthesizedContent := synthesizer.GetSynthesizedContent() parseFailed := synthesizer.IsParseFailed() if synthesizedContent != "" && !parseFailed { log.Printf("[Responses-Stream] 上游流式响应合成内容:\n%s", strings.TrimSpace(synthesizedContent)) } else if logBuffer.Len() > 0 { log.Printf("[Responses-Stream] 上游流式响应原始内容:\n%s", logBuffer.String()) } } else if logBuffer.Len() > 0 { log.Printf("[Responses-Stream] 上游流式响应原始内容:\n%s", logBuffer.String()) } } } // 返回收集到的 usage 数据 return &types.Usage{ InputTokens: collectedUsage.InputTokens, OutputTokens: collectedUsage.OutputTokens, CacheCreationInputTokens: collectedUsage.CacheCreationInputTokens, CacheReadInputTokens: collectedUsage.CacheReadInputTokens, CacheCreation5mInputTokens: collectedUsage.CacheCreation5mInputTokens, CacheCreation1hInputTokens: collectedUsage.CacheCreation1hInputTokens, CacheTTL: collectedUsage.CacheTTL, } } // responsesStreamUsage 流式响应 usage 收集结构 type responsesStreamUsage struct { InputTokens int OutputTokens int TotalTokens int // 用于检测 total_tokens 是否需要补全 CacheCreationInputTokens int CacheReadInputTokens int CacheCreation5mInputTokens int CacheCreation1hInputTokens int CacheTTL string HasClaudeCache bool // 是否检测到 Claude 原生缓存字段(区别于 OpenAI cached_tokens) } // extractResponsesTextFromEvent 从 Responses SSE 事件中提取文本内容 func extractResponsesTextFromEvent(event string, buf *bytes.Buffer) { for _, line := range strings.Split(event, "\n") { if !strings.HasPrefix(line, "data: ") { continue } jsonStr := strings.TrimPrefix(line, "data: ") var data map[string]interface{} if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { continue } eventType, _ := data["type"].(string) // 处理各种 delta 类型 switch eventType { case "response.output_text.delta": if delta, ok := data["delta"].(string); ok { buf.WriteString(delta) } case "response.function_call_arguments.delta": if delta, ok := data["delta"].(string); ok { buf.WriteString(delta) } case "response.reasoning_summary_text.delta": if text, ok := data["text"].(string); ok { buf.WriteString(text) } case "response.output_json.delta": // JSON 输出增量 if delta, ok := data["delta"].(string); ok { buf.WriteString(delta) } case "response.content_part.delta": // 内容块增量(通用) if delta, ok := data["delta"].(string); ok { buf.WriteString(delta) } else if text, ok := data["text"].(string); ok { buf.WriteString(text) } case "response.audio.delta", "response.audio_transcript.delta": // 音频转录增量 if delta, ok := data["delta"].(string); ok { buf.WriteString(delta) } } } } // checkResponsesEventUsage 检测 Responses 事件是否包含 usage func checkResponsesEventUsage(event string, enableLog bool) (bool, bool, responsesStreamUsage) { lines := strings.Split(event, "\n") for _, line := range lines { // 支持 "data:" 和 "data: " 两种格式(有些上游不带空格) var jsonStr string if strings.HasPrefix(line, "data:") { jsonStr = strings.TrimPrefix(line, "data:") jsonStr = strings.TrimPrefix(jsonStr, " ") // 移除可能的前导空格 } else { continue } var data map[string]interface{} if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { continue } eventType, _ := data["type"].(string) // 检查 response.completed 事件中的 usage if eventType == "response.completed" { if response, ok := data["response"].(map[string]interface{}); ok { if usage, ok := response["usage"].(map[string]interface{}); ok { usageData := extractResponsesUsageFromMap(usage) needPatch := usageData.InputTokens <= 1 || usageData.OutputTokens <= 1 // 仅当检测到 Claude 原生缓存字段时,才跳过 input_tokens 补全 // OpenAI 的 input_tokens_details.cached_tokens 不应阻止补全 if usageData.HasClaudeCache && usageData.InputTokens <= 1 { needPatch = usageData.OutputTokens <= 1 // 有 Claude 缓存时只检查 output } // 检查 total_tokens 是否需要补全(有效 input/output 但 total=0) if !needPatch && usageData.TotalTokens == 0 && (usageData.InputTokens > 0 || usageData.OutputTokens > 0) { needPatch = true } if enableLog { log.Printf("[Responses-Stream-Token] response.completed: InputTokens=%d, OutputTokens=%d, TotalTokens=%d, CacheCreation=%d, CacheRead=%d, HasClaudeCache=%v, 需补全=%v", usageData.InputTokens, usageData.OutputTokens, usageData.TotalTokens, usageData.CacheCreationInputTokens, usageData.CacheReadInputTokens, usageData.HasClaudeCache, needPatch) } return true, needPatch, usageData } else if enableLog { log.Printf("[Responses-Stream-Token] response.completed 事件中无 usage 字段") } } else if enableLog { log.Printf("[Responses-Stream-Token] response.completed 事件中无 response 字段") } } } return false, false, responsesStreamUsage{} } // extractResponsesUsageFromMap 从 usage map 中提取数据 func extractResponsesUsageFromMap(usage map[string]interface{}) responsesStreamUsage { var data responsesStreamUsage if v, ok := usage["input_tokens"].(float64); ok { data.InputTokens = int(v) } if v, ok := usage["output_tokens"].(float64); ok { data.OutputTokens = int(v) } if v, ok := usage["total_tokens"].(float64); ok { data.TotalTokens = int(v) } if v, ok := usage["cache_creation_input_tokens"].(float64); ok { data.CacheCreationInputTokens = int(v) if v > 0 { data.HasClaudeCache = true } } if v, ok := usage["cache_read_input_tokens"].(float64); ok { data.CacheReadInputTokens = int(v) if v > 0 { data.HasClaudeCache = true } } if v, ok := usage["cache_creation_5m_input_tokens"].(float64); ok { data.CacheCreation5mInputTokens = int(v) if v > 0 { data.HasClaudeCache = true } } if v, ok := usage["cache_creation_1h_input_tokens"].(float64); ok { data.CacheCreation1hInputTokens = int(v) if v > 0 { data.HasClaudeCache = true } } // 检查 input_tokens_details.cached_tokens (OpenAI 格式,不设置 HasClaudeCache) if details, ok := usage["input_tokens_details"].(map[string]interface{}); ok { if cached, ok := details["cached_tokens"].(float64); ok && cached > 0 { // 仅当 CacheReadInputTokens 未被设置时才使用 OpenAI 的 cached_tokens if data.CacheReadInputTokens == 0 { data.CacheReadInputTokens = int(cached) } // 注意:不设置 HasClaudeCache,因为这是 OpenAI 格式 } } // 设置 CacheTTL var has5m, has1h bool if data.CacheCreation5mInputTokens > 0 { has5m = true } if data.CacheCreation1hInputTokens > 0 { has1h = true } if has5m && has1h { data.CacheTTL = "mixed" } else if has1h { data.CacheTTL = "1h" } else if has5m { data.CacheTTL = "5m" } return data } // updateResponsesStreamUsage 更新收集的 usage 数据 func updateResponsesStreamUsage(collected *responsesStreamUsage, usageData responsesStreamUsage) { if usageData.InputTokens > collected.InputTokens { collected.InputTokens = usageData.InputTokens } if usageData.OutputTokens > collected.OutputTokens { collected.OutputTokens = usageData.OutputTokens } if usageData.TotalTokens > collected.TotalTokens { collected.TotalTokens = usageData.TotalTokens } if usageData.CacheCreationInputTokens > 0 { collected.CacheCreationInputTokens = usageData.CacheCreationInputTokens } if usageData.CacheReadInputTokens > 0 { collected.CacheReadInputTokens = usageData.CacheReadInputTokens } if usageData.CacheCreation5mInputTokens > 0 { collected.CacheCreation5mInputTokens = usageData.CacheCreation5mInputTokens } if usageData.CacheCreation1hInputTokens > 0 { collected.CacheCreation1hInputTokens = usageData.CacheCreation1hInputTokens } if usageData.CacheTTL != "" { collected.CacheTTL = usageData.CacheTTL } // 传播 HasClaudeCache 标志 if usageData.HasClaudeCache { collected.HasClaudeCache = true } } // isResponsesCompletedEvent 检测是否为 response.completed 事件 func isResponsesCompletedEvent(event string) bool { return strings.Contains(event, `"type":"response.completed"`) || strings.Contains(event, `"type": "response.completed"`) } // isClientDisconnectError 判断是否为客户端断开连接错误 func isClientDisconnectError(err error) bool { msg := err.Error() return strings.Contains(msg, "broken pipe") || strings.Contains(msg, "connection reset") } // injectResponsesUsageToCompletedEvent 向 response.completed 事件注入 usage // 返回: 修改后的事件字符串, 估算的 inputTokens, 估算的 outputTokens func injectResponsesUsageToCompletedEvent(event string, requestBody []byte, outputText string, envCfg *config.EnvConfig) (string, int, int) { inputTokens := utils.EstimateResponsesRequestTokens(requestBody) outputTokens := utils.EstimateTokens(outputText) totalTokens := inputTokens + outputTokens // 调试日志:记录估算开始 if envCfg.EnableResponseLogs && envCfg.ShouldLog("debug") { log.Printf("[Responses-Stream-Token] injectUsage 开始: inputTokens=%d, outputTokens=%d, event长度=%d", inputTokens, outputTokens, len(event)) } var result strings.Builder lines := strings.Split(event, "\n") injected := false for _, line := range lines { // 跳过 event: 行,但保留它 if strings.HasPrefix(line, "event:") { result.WriteString(line) result.WriteString("\n") continue } // 支持 "data:" 和 "data: " 两种格式(有些上游不带空格) var jsonStr string if strings.HasPrefix(line, "data:") { jsonStr = strings.TrimPrefix(line, "data:") jsonStr = strings.TrimPrefix(jsonStr, " ") // 移除可能的前导空格 } else { result.WriteString(line) result.WriteString("\n") continue } var data map[string]interface{} if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { // 调试日志:JSON 解析失败 if envCfg.EnableResponseLogs && envCfg.ShouldLog("debug") { log.Printf("[Responses-Stream-Token] JSON解析失败: %v, 内容前200字符: %.200s", err, jsonStr) } result.WriteString(line) result.WriteString("\n") continue } eventType, _ := data["type"].(string) if eventType == "response.completed" { response, ok := data["response"].(map[string]interface{}) if !ok { // response 字段缺失或类型错误,创建一个新的 if envCfg.EnableResponseLogs && envCfg.ShouldLog("debug") { log.Printf("[Responses-Stream-Token] response字段缺失, 创建新的response对象") } response = make(map[string]interface{}) data["response"] = response } response["usage"] = map[string]interface{}{ "input_tokens": inputTokens, "output_tokens": outputTokens, "total_tokens": totalTokens, } injected = true patchedJSON, err := json.Marshal(data) if err != nil { if envCfg.EnableResponseLogs && envCfg.ShouldLog("debug") { log.Printf("[Responses-Stream-Token] JSON序列化失败: %v", err) } result.WriteString(line) result.WriteString("\n") continue } if envCfg.EnableResponseLogs && envCfg.ShouldLog("debug") { log.Printf("[Responses-Stream-Token] 注入本地估算成功: InputTokens=%d, OutputTokens=%d, TotalTokens=%d", inputTokens, outputTokens, totalTokens) } result.WriteString("data: ") result.Write(patchedJSON) result.WriteString("\n") } else { result.WriteString(line) result.WriteString("\n") } } // 如果没有成功注入,可能是 SSE 格式不同,尝试直接在整个 event 中查找并替换 if !injected { if envCfg.EnableResponseLogs && envCfg.ShouldLog("debug") { log.Printf("[Responses-Stream-Token] 逐行解析未找到, 尝试整体解析 event") } // 尝试从 event 中提取 JSON 部分(可能是多行格式) var jsonStart, jsonEnd int for i, line := range lines { if strings.HasPrefix(line, "data:") { jsonStart = i break } } // 合并所有 data: 行(支持 "data:" 和 "data: " 两种格式) var jsonBuilder strings.Builder for i := jsonStart; i < len(lines); i++ { line := lines[i] if strings.HasPrefix(line, "data:") { jsonData := strings.TrimPrefix(line, "data:") jsonData = strings.TrimPrefix(jsonData, " ") // 移除可能的前导空格 jsonBuilder.WriteString(jsonData) } else if line == "" { jsonEnd = i break } } fullJSON := jsonBuilder.String() if fullJSON != "" { var data map[string]interface{} if err := json.Unmarshal([]byte(fullJSON), &data); err == nil { eventType, _ := data["type"].(string) if eventType == "response.completed" { response, ok := data["response"].(map[string]interface{}) if !ok { response = make(map[string]interface{}) data["response"] = response } response["usage"] = map[string]interface{}{ "input_tokens": inputTokens, "output_tokens": outputTokens, "total_tokens": totalTokens, } patchedJSON, err := json.Marshal(data) if err == nil { injected = true // 重建 event result.Reset() for i := 0; i < jsonStart; i++ { result.WriteString(lines[i]) result.WriteString("\n") } result.WriteString("data: ") result.Write(patchedJSON) result.WriteString("\n") for i := jsonEnd; i < len(lines); i++ { result.WriteString(lines[i]) result.WriteString("\n") } if envCfg.EnableResponseLogs && envCfg.ShouldLog("debug") { log.Printf("[Responses-Stream-Token] 整体解析注入成功: InputTokens=%d, OutputTokens=%d", inputTokens, outputTokens) } } } } } } // 如果仍然没有成功注入,记录警告并打印 event 内容 if !injected { if envCfg.EnableResponseLogs && envCfg.ShouldLog("debug") { // 打印 event 的前500个字符帮助调试 eventPreview := event if len(eventPreview) > 500 { eventPreview = eventPreview[:500] + "..." } log.Printf("[Responses-Stream-Token] 警告: 未找到 response.completed 事件进行注入, event内容: %s", eventPreview) } return event, inputTokens, outputTokens } return result.String(), inputTokens, outputTokens } // patchResponsesCompletedEventUsage 修补 response.completed 事件中的 usage func patchResponsesCompletedEventUsage(event string, requestBody []byte, outputText string, collected *responsesStreamUsage, envCfg *config.EnvConfig) string { var result strings.Builder lines := strings.Split(event, "\n") for _, line := range lines { // 支持 "data:" 和 "data: " 两种格式(有些上游不带空格) var jsonStr string if strings.HasPrefix(line, "data:") { jsonStr = strings.TrimPrefix(line, "data:") jsonStr = strings.TrimPrefix(jsonStr, " ") // 移除可能的前导空格 } else { result.WriteString(line) result.WriteString("\n") continue } var data map[string]interface{} if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { result.WriteString(line) result.WriteString("\n") continue } if data["type"] == "response.completed" { if response, ok := data["response"].(map[string]interface{}); ok { if usage, ok := response["usage"].(map[string]interface{}); ok { originalInput := collected.InputTokens originalOutput := collected.OutputTokens patched := false // 修补 input_tokens(仅当没有 Claude 原生缓存时) // OpenAI 的 cached_tokens 不应阻止 input_tokens 补全 if collected.InputTokens <= 1 && !collected.HasClaudeCache { estimatedInput := utils.EstimateResponsesRequestTokens(requestBody) usage["input_tokens"] = estimatedInput collected.InputTokens = estimatedInput patched = true } // 修补 output_tokens if collected.OutputTokens <= 1 { estimatedOutput := utils.EstimateTokens(outputText) usage["output_tokens"] = estimatedOutput collected.OutputTokens = estimatedOutput patched = true } // 重新计算 total_tokens(修补时或 total_tokens 为 0 但 input/output 有效时) currentTotal := 0 if t, ok := usage["total_tokens"].(float64); ok { currentTotal = int(t) } if patched || (currentTotal == 0 && (collected.InputTokens > 0 || collected.OutputTokens > 0)) { usage["total_tokens"] = collected.InputTokens + collected.OutputTokens } if envCfg.EnableResponseLogs && envCfg.ShouldLog("debug") && patched { log.Printf("[Responses-Stream-Token] 虚假值修补: InputTokens=%d->%d, OutputTokens=%d->%d", originalInput, collected.InputTokens, originalOutput, collected.OutputTokens) } } } patchedJSON, err := json.Marshal(data) if err != nil { result.WriteString(line) result.WriteString("\n") continue } result.WriteString("data: ") result.Write(patchedJSON) result.WriteString("\n") } else { result.WriteString(line) result.WriteString("\n") } } return result.String() } // parseInputToItems 解析 input 为 ResponsesItem 数组 func parseInputToItems(input interface{}) ([]types.ResponsesItem, error) { switch v := input.(type) { case string: return []types.ResponsesItem{{Type: "text", Content: v}}, nil case []interface{}: items := []types.ResponsesItem{} for _, item := range v { itemMap, ok := item.(map[string]interface{}) if !ok { continue } itemType, _ := itemMap["type"].(string) content := itemMap["content"] items = append(items, types.ResponsesItem{Type: itemType, Content: content}) } return items, nil default: return nil, fmt.Errorf("unsupported input type") } } ================================================ FILE: backend-go/internal/handlers/settings.go ================================================ // Package handlers 提供 HTTP 处理器 package handlers import ( "github.com/BenedictKing/claude-proxy/internal/config" "github.com/gin-gonic/gin" ) // GetFuzzyMode 获取 Fuzzy 模式状态 func GetFuzzyMode(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { c.JSON(200, gin.H{ "fuzzyModeEnabled": cfgManager.GetFuzzyModeEnabled(), }) } } // SetFuzzyMode 设置 Fuzzy 模式状态 func SetFuzzyMode(cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { var req struct { Enabled bool `json:"enabled"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "Invalid request body"}) return } if err := cfgManager.SetFuzzyModeEnabled(req.Enabled); err != nil { c.JSON(500, gin.H{"error": "Failed to save config"}) return } c.JSON(200, gin.H{ "success": true, "fuzzyModeEnabled": req.Enabled, }) } } ================================================ FILE: backend-go/internal/httpclient/client.go ================================================ package httpclient import ( "crypto/tls" "fmt" "net/http" "sync" "time" "github.com/BenedictKing/claude-proxy/internal/config" ) // ClientManager HTTP 客户端管理器 type ClientManager struct { mu sync.RWMutex clients map[string]*http.Client } var globalManager = &ClientManager{ clients: make(map[string]*http.Client), } // GetManager 获取全局客户端管理器 func GetManager() *ClientManager { return globalManager } // GetStandardClient 获取标准客户端(有超时,用于普通请求) // 注意:启用自动压缩让Go处理gzip,配合请求头清理确保正确解压 func (cm *ClientManager) GetStandardClient(timeout time.Duration, insecure bool) *http.Client { // 从配置获取响应头超时时间 envConfig := config.NewEnvConfig() responseHeaderTimeout := time.Duration(envConfig.ResponseHeaderTimeout) * time.Second key := fmt.Sprintf("standard-%d-%t-%d", timeout, insecure, envConfig.ResponseHeaderTimeout) cm.mu.RLock() if client, ok := cm.clients[key]; ok { cm.mu.RUnlock() return client } cm.mu.RUnlock() cm.mu.Lock() defer cm.mu.Unlock() // 双重检查,避免重复创建 if client, ok := cm.clients[key]; ok { return client } transport := &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, DisableCompression: false, // 启用自动压缩,让Go处理gzip TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: responseHeaderTimeout, ExpectContinueTimeout: 1 * time.Second, ForceAttemptHTTP2: true, } if insecure { transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} } client := &http.Client{ Transport: transport, Timeout: timeout, } cm.clients[key] = client return client } // GetStreamClient 获取流式客户端(无超时,用于 SSE 流式响应) func (cm *ClientManager) GetStreamClient(insecure bool) *http.Client { // 从配置获取响应头超时时间 envConfig := config.NewEnvConfig() responseHeaderTimeout := time.Duration(envConfig.ResponseHeaderTimeout) * time.Second key := fmt.Sprintf("stream-%t-%d", insecure, envConfig.ResponseHeaderTimeout) cm.mu.RLock() if client, ok := cm.clients[key]; ok { cm.mu.RUnlock() return client } cm.mu.RUnlock() cm.mu.Lock() defer cm.mu.Unlock() // 双重检查 if client, ok := cm.clients[key]; ok { return client } transport := &http.Transport{ MaxIdleConns: 200, // 流式连接池更大 MaxIdleConnsPerHost: 20, IdleConnTimeout: 120 * time.Second, DisableCompression: true, // 流式响应禁用压缩 TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: responseHeaderTimeout, ExpectContinueTimeout: 1 * time.Second, ForceAttemptHTTP2: true, } if insecure { transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} } client := &http.Client{ Transport: transport, Timeout: 0, // 流式请求无超时 } cm.clients[key] = client return client } ================================================ FILE: backend-go/internal/logger/logger.go ================================================ package logger import ( "fmt" "io" "log" "os" "path/filepath" "gopkg.in/natefinch/lumberjack.v2" ) // Config 日志配置 type Config struct { // 日志目录 LogDir string // 日志文件名 LogFile string // 单个日志文件最大大小 (MB) MaxSize int // 保留的旧日志文件最大数量 MaxBackups int // 保留的旧日志文件最大天数 MaxAge int // 是否压缩旧日志文件 Compress bool // 是否同时输出到控制台 Console bool } // DefaultConfig 返回默认配置 func DefaultConfig() *Config { return &Config{ LogDir: "logs", LogFile: "app.log", MaxSize: 100, // 100MB MaxBackups: 10, MaxAge: 30, // 30 days Compress: true, Console: true, } } // Setup 初始化日志系统 func Setup(cfg *Config) error { if cfg == nil { cfg = DefaultConfig() } // 确保日志目录存在 if err := os.MkdirAll(cfg.LogDir, 0755); err != nil { return fmt.Errorf("创建日志目录失败: %w", err) } logPath := filepath.Join(cfg.LogDir, cfg.LogFile) // 配置 lumberjack 日志轮转 lumberLogger := &lumberjack.Logger{ Filename: logPath, MaxSize: cfg.MaxSize, MaxBackups: cfg.MaxBackups, MaxAge: cfg.MaxAge, Compress: cfg.Compress, LocalTime: true, } var writer io.Writer if cfg.Console { // 同时输出到控制台和文件 writer = io.MultiWriter(os.Stdout, lumberLogger) } else { // 仅输出到文件 writer = lumberLogger } // 设置标准库 log 的输出 log.SetOutput(writer) log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds) log.Printf("[Logger-Init] 日志系统已初始化") log.Printf("[Logger-Init] 日志文件: %s", logPath) log.Printf("[Logger-Init] 轮转配置: 最大 %dMB, 保留 %d 个备份, %d 天", cfg.MaxSize, cfg.MaxBackups, cfg.MaxAge) return nil } ================================================ FILE: backend-go/internal/metrics/channel_metrics.go ================================================ package metrics import ( "crypto/sha256" "encoding/hex" "log" "sort" "sync" "time" "github.com/BenedictKing/claude-proxy/internal/types" "github.com/BenedictKing/claude-proxy/internal/utils" ) // RequestRecord 带时间戳的请求记录(扩展版,支持 Token 和 Cache 数据) type RequestRecord struct { Timestamp time.Time Success bool InputTokens int64 OutputTokens int64 CacheCreationInputTokens int64 CacheReadInputTokens int64 } // KeyMetrics 单个 Key 的指标(绑定到 BaseURL + Key 组合) type KeyMetrics struct { MetricsKey string `json:"metricsKey"` // hash(baseURL + apiKey) BaseURL string `json:"baseUrl"` // 用于显示 KeyMask string `json:"keyMask"` // 脱敏的 key(用于显示) RequestCount int64 `json:"requestCount"` // 总请求数 SuccessCount int64 `json:"successCount"` // 成功数 FailureCount int64 `json:"failureCount"` // 失败数 ConsecutiveFailures int64 `json:"consecutiveFailures"` // 连续失败数 ActiveRequests int64 `json:"activeRequests"` // 进行中的请求数 LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"` LastFailureAt *time.Time `json:"lastFailureAt,omitempty"` CircuitBrokenAt *time.Time `json:"circuitBrokenAt,omitempty"` // 熔断开始时间 // 滑动窗口记录(最近 N 次请求的结果) recentResults []bool // true=success, false=failure // 带时间戳的请求记录(用于分时段统计,保留24小时) requestHistory []RequestRecord // 进行中请求在 requestHistory 中的索引(用于“连接即计数”,结束后回写成功/失败与 token) pendingHistoryIdx map[uint64]int } // ChannelMetrics 渠道聚合指标(用于 API 返回,兼容旧结构) type ChannelMetrics struct { ChannelIndex int `json:"channelIndex"` RequestCount int64 `json:"requestCount"` SuccessCount int64 `json:"successCount"` FailureCount int64 `json:"failureCount"` ConsecutiveFailures int64 `json:"consecutiveFailures"` LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"` LastFailureAt *time.Time `json:"lastFailureAt,omitempty"` CircuitBrokenAt *time.Time `json:"circuitBrokenAt,omitempty"` // 滑动窗口记录(兼容旧代码) recentResults []bool // 带时间戳的请求记录 requestHistory []RequestRecord } // TimeWindowStats 分时段统计 type TimeWindowStats struct { RequestCount int64 `json:"requestCount"` SuccessCount int64 `json:"successCount"` FailureCount int64 `json:"failureCount"` SuccessRate float64 `json:"successRate"` // Token 统计(按时间窗口聚合) InputTokens int64 `json:"inputTokens,omitempty"` OutputTokens int64 `json:"outputTokens,omitempty"` CacheCreationTokens int64 `json:"cacheCreationTokens,omitempty"` CacheReadTokens int64 `json:"cacheReadTokens,omitempty"` // CacheHitRate 缓存命中率(Token口径),范围 0-100 // 定义:cacheReadTokens / (cacheReadTokens + inputTokens) * 100 CacheHitRate float64 `json:"cacheHitRate,omitempty"` } // MetricsManager 指标管理器 type MetricsManager struct { mu sync.RWMutex keyMetrics map[string]*KeyMetrics // key: hash(baseURL + apiKey) windowSize int // 滑动窗口大小 failureThreshold float64 // 失败率阈值 circuitRecoveryTime time.Duration // 熔断恢复时间 stopCh chan struct{} // 用于停止清理 goroutine nextRequestID uint64 // 单进程递增请求ID(用于 pendingHistoryIdx) // 持久化存储(可选) store PersistenceStore apiType string // "messages"、"responses" 或 "gemini" } // NewMetricsManager 创建指标管理器 func NewMetricsManager() *MetricsManager { m := &MetricsManager{ keyMetrics: make(map[string]*KeyMetrics), windowSize: 10, // 默认基于最近 10 次请求计算失败率 failureThreshold: 0.5, // 默认 50% 失败率阈值 circuitRecoveryTime: 15 * time.Minute, // 默认 15 分钟自动恢复 stopCh: make(chan struct{}), } // 启动后台熔断恢复任务 go m.cleanupCircuitBreakers() return m } // NewMetricsManagerWithConfig 创建带配置的指标管理器 func NewMetricsManagerWithConfig(windowSize int, failureThreshold float64) *MetricsManager { if windowSize < 3 { windowSize = 3 // 最小 3 } if failureThreshold <= 0 || failureThreshold > 1 { failureThreshold = 0.5 } m := &MetricsManager{ keyMetrics: make(map[string]*KeyMetrics), windowSize: windowSize, failureThreshold: failureThreshold, circuitRecoveryTime: 15 * time.Minute, stopCh: make(chan struct{}), } // 启动后台熔断恢复任务 go m.cleanupCircuitBreakers() return m } // NewMetricsManagerWithPersistence 创建带持久化的指标管理器 func NewMetricsManagerWithPersistence(windowSize int, failureThreshold float64, store PersistenceStore, apiType string) *MetricsManager { if windowSize < 3 { windowSize = 3 } if failureThreshold <= 0 || failureThreshold > 1 { failureThreshold = 0.5 } m := &MetricsManager{ keyMetrics: make(map[string]*KeyMetrics), windowSize: windowSize, failureThreshold: failureThreshold, circuitRecoveryTime: 15 * time.Minute, stopCh: make(chan struct{}), store: store, apiType: apiType, } // 从持久化存储加载历史数据 if store != nil { if err := m.loadFromStore(); err != nil { log.Printf("[Metrics-Load] 警告: [%s] 加载历史指标数据失败: %v", apiType, err) } } // 启动后台熔断恢复任务 go m.cleanupCircuitBreakers() return m } // loadFromStore 从持久化存储加载数据 func (m *MetricsManager) loadFromStore() error { if m.store == nil { return nil } // 加载最近 24 小时的数据 since := time.Now().Add(-24 * time.Hour) records, err := m.store.LoadRecords(since, m.apiType) if err != nil { return err } if len(records) == 0 { log.Printf("[Metrics-Load] [%s] 无历史指标数据需要加载", m.apiType) return nil } m.mu.Lock() defer m.mu.Unlock() // 重建内存中的 KeyMetrics for _, r := range records { metrics := m.getOrCreateKeyLocked(r.BaseURL, r.MetricsKey, r.KeyMask) // 重建请求历史 metrics.requestHistory = append(metrics.requestHistory, RequestRecord{ Timestamp: r.Timestamp, Success: r.Success, InputTokens: r.InputTokens, OutputTokens: r.OutputTokens, CacheCreationInputTokens: r.CacheCreationTokens, CacheReadInputTokens: r.CacheReadTokens, }) // 更新聚合计数 metrics.RequestCount++ if r.Success { metrics.SuccessCount++ if metrics.LastSuccessAt == nil || r.Timestamp.After(*metrics.LastSuccessAt) { t := r.Timestamp metrics.LastSuccessAt = &t } } else { metrics.FailureCount++ if metrics.LastFailureAt == nil || r.Timestamp.After(*metrics.LastFailureAt) { t := r.Timestamp metrics.LastFailureAt = &t } } } // 重建滑动窗口(只从最近 15 分钟的记录中取最近 windowSize 条) // 避免历史失败记录导致渠道长期处于不健康状态 windowCutoff := time.Now().Add(-15 * time.Minute) for _, metrics := range m.keyMetrics { metrics.recentResults = make([]bool, 0, m.windowSize) // 从历史记录中筛选最近 15 分钟内的记录 var recentRecords []bool for _, record := range metrics.requestHistory { if record.Timestamp.After(windowCutoff) { recentRecords = append(recentRecords, record.Success) } } // 取最近 windowSize 条 n := len(recentRecords) start := 0 if n > m.windowSize { start = n - m.windowSize } for i := start; i < n; i++ { metrics.recentResults = append(metrics.recentResults, recentRecords[i]) } } log.Printf("[Metrics-Load] [%s] 已从持久化存储加载 %d 条历史记录,重建 %d 个 Key 指标", m.apiType, len(records), len(m.keyMetrics)) return nil } // getOrCreateKeyLocked 获取或创建 Key 指标(用于加载时,已知 metricsKey 和 keyMask) func (m *MetricsManager) getOrCreateKeyLocked(baseURL, metricsKey, keyMask string) *KeyMetrics { if metrics, exists := m.keyMetrics[metricsKey]; exists { return metrics } metrics := &KeyMetrics{ MetricsKey: metricsKey, BaseURL: baseURL, KeyMask: keyMask, recentResults: make([]bool, 0, m.windowSize), pendingHistoryIdx: make(map[uint64]int), } m.keyMetrics[metricsKey] = metrics return metrics } // generateMetricsKey 生成指标键 hash(baseURL + apiKey)(内部使用) func generateMetricsKey(baseURL, apiKey string) string { h := sha256.New() h.Write([]byte(baseURL + "|" + apiKey)) return hex.EncodeToString(h.Sum(nil))[:16] // 取前16位作为键 } // GenerateMetricsKey 生成指标键 hash(baseURL + apiKey)(导出供外部使用) func GenerateMetricsKey(baseURL, apiKey string) string { return generateMetricsKey(baseURL, apiKey) } // getOrCreateKey 获取或创建 Key 指标 func (m *MetricsManager) getOrCreateKey(baseURL, apiKey string) *KeyMetrics { metricsKey := generateMetricsKey(baseURL, apiKey) if metrics, exists := m.keyMetrics[metricsKey]; exists { return metrics } metrics := &KeyMetrics{ MetricsKey: metricsKey, BaseURL: baseURL, KeyMask: utils.MaskAPIKey(apiKey), recentResults: make([]bool, 0, m.windowSize), pendingHistoryIdx: make(map[uint64]int), } m.keyMetrics[metricsKey] = metrics return metrics } // RecordSuccess 记录成功请求(新方法,使用 baseURL + apiKey) func (m *MetricsManager) RecordSuccess(baseURL, apiKey string) { m.RecordSuccessWithUsage(baseURL, apiKey, nil) } // RecordSuccessWithUsage 记录成功请求(带 Usage 数据) func (m *MetricsManager) RecordSuccessWithUsage(baseURL, apiKey string, usage *types.Usage) { m.mu.Lock() defer m.mu.Unlock() m.recordSuccessWithUsageLocked(baseURL, apiKey, usage, time.Now()) } func (m *MetricsManager) recordSuccessWithUsageLocked(baseURL, apiKey string, usage *types.Usage, now time.Time) { metrics := m.getOrCreateKey(baseURL, apiKey) metrics.RequestCount++ metrics.SuccessCount++ metrics.ConsecutiveFailures = 0 metrics.LastSuccessAt = &now // 成功后清除熔断标记 if metrics.CircuitBrokenAt != nil { metrics.CircuitBrokenAt = nil log.Printf("[Metrics-Circuit] Key [%s] (%s) 因请求成功退出熔断状态", metrics.KeyMask, metrics.BaseURL) } // 更新滑动窗口 m.appendToWindowKey(metrics, true) // 提取 Token 数据(如果有) var inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens int64 if usage != nil { inputTokens = int64(usage.InputTokens) outputTokens = int64(usage.OutputTokens) // cache_creation_input_tokens 有时不会返回(只返回 5m/1h 细分字段),这里做兜底汇总。 cacheCreationTokens = int64(usage.CacheCreationInputTokens) if cacheCreationTokens <= 0 { cacheCreationTokens = int64(usage.CacheCreation5mInputTokens + usage.CacheCreation1hInputTokens) } cacheReadTokens = int64(usage.CacheReadInputTokens) } // 记录带时间戳的请求 m.appendToHistoryKeyWithUsage(metrics, now, true, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens) // 写入持久化存储(异步,不阻塞) if m.store != nil { m.store.AddRecord(PersistentRecord{ MetricsKey: metrics.MetricsKey, BaseURL: baseURL, KeyMask: metrics.KeyMask, Timestamp: now, Success: true, InputTokens: inputTokens, OutputTokens: outputTokens, CacheCreationTokens: cacheCreationTokens, CacheReadTokens: cacheReadTokens, APIType: m.apiType, }) } } // RecordFailure 记录失败请求(新方法,使用 baseURL + apiKey) func (m *MetricsManager) RecordFailure(baseURL, apiKey string) { m.mu.Lock() defer m.mu.Unlock() m.recordFailureLocked(baseURL, apiKey, time.Now()) } func (m *MetricsManager) recordFailureLocked(baseURL, apiKey string, now time.Time) { metrics := m.getOrCreateKey(baseURL, apiKey) metrics.RequestCount++ metrics.FailureCount++ metrics.ConsecutiveFailures++ metrics.LastFailureAt = &now // 更新滑动窗口 m.appendToWindowKey(metrics, false) // 检查是否刚进入熔断状态 if metrics.CircuitBrokenAt == nil && m.isKeyCircuitBroken(metrics) { metrics.CircuitBrokenAt = &now log.Printf("[Metrics-Circuit] Key [%s] (%s) 进入熔断状态(失败率: %.1f%%)", metrics.KeyMask, metrics.BaseURL, m.calculateKeyFailureRateInternal(metrics)*100) } // 记录带时间戳的请求 m.appendToHistoryKey(metrics, now, false) // 写入持久化存储(异步,不阻塞) if m.store != nil { m.store.AddRecord(PersistentRecord{ MetricsKey: metrics.MetricsKey, BaseURL: baseURL, KeyMask: metrics.KeyMask, Timestamp: now, Success: false, InputTokens: 0, OutputTokens: 0, CacheCreationTokens: 0, CacheReadTokens: 0, APIType: m.apiType, }) } } // RecordRequestConnected 记录“开始发起上游请求(TCP 建连阶段)”的请求(用于更实时的活跃度统计)。 // 返回 requestID,用于后续在请求结束时回写成功/失败与 token。 func (m *MetricsManager) RecordRequestConnected(baseURL, apiKey string) uint64 { return m.RecordRequestConnectedAt(baseURL, apiKey, time.Now()) } // RecordRequestConnectedAt 与 RecordRequestConnected 相同,但允许注入时间戳(用于测试)。 func (m *MetricsManager) RecordRequestConnectedAt(baseURL, apiKey string, timestamp time.Time) uint64 { m.mu.Lock() defer m.mu.Unlock() metrics := m.getOrCreateKey(baseURL, apiKey) // RequestCount 改为在 finalize 阶段统一增加,避免 fallback 路径二次计数 m.nextRequestID++ requestID := m.nextRequestID if metrics.pendingHistoryIdx == nil { metrics.pendingHistoryIdx = make(map[uint64]int) } metrics.requestHistory = append(metrics.requestHistory, RequestRecord{ Timestamp: timestamp, Success: true, // 先按成功计数;结束时会回写真实结果 }) metrics.pendingHistoryIdx[requestID] = len(metrics.requestHistory) - 1 // 清理历史并同步修正索引 m.cleanupHistoryLocked(metrics) return requestID } // RecordRequestFinalizeSuccess 回写成功结果与 token(requestID 来自 RecordRequestConnected)。 func (m *MetricsManager) RecordRequestFinalizeSuccess(baseURL, apiKey string, requestID uint64, usage *types.Usage) { m.mu.Lock() defer m.mu.Unlock() metricsKey := generateMetricsKey(baseURL, apiKey) metrics, exists := m.keyMetrics[metricsKey] if !exists { m.recordSuccessWithUsageLocked(baseURL, apiKey, usage, time.Now()) return } idx, ok := metrics.pendingHistoryIdx[requestID] if !ok || idx < 0 || idx >= len(metrics.requestHistory) { m.recordSuccessWithUsageLocked(baseURL, apiKey, usage, time.Now()) return } delete(metrics.pendingHistoryIdx, requestID) // 正常路径:在此统一增加 RequestCount metrics.RequestCount++ metrics.SuccessCount++ metrics.ConsecutiveFailures = 0 now := time.Now() metrics.LastSuccessAt = &now // 成功后清除熔断标记 if metrics.CircuitBrokenAt != nil { metrics.CircuitBrokenAt = nil log.Printf("[Metrics-Circuit] Key [%s] (%s) 因请求成功退出熔断状态", metrics.KeyMask, metrics.BaseURL) } // 更新滑动窗口 m.appendToWindowKey(metrics, true) // 提取 Token 数据(如果有) var inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens int64 if usage != nil { inputTokens = int64(usage.InputTokens) outputTokens = int64(usage.OutputTokens) // cache_creation_input_tokens 有时不会返回(只返回 5m/1h 细分字段),这里做兜底汇总。 cacheCreationTokens = int64(usage.CacheCreationInputTokens) if cacheCreationTokens <= 0 { cacheCreationTokens = int64(usage.CacheCreation5mInputTokens + usage.CacheCreation1hInputTokens) } cacheReadTokens = int64(usage.CacheReadInputTokens) } // 回写历史记录(时间戳保持为“请求开始(TCP 建连阶段)”时刻) record := &metrics.requestHistory[idx] record.Success = true record.InputTokens = inputTokens record.OutputTokens = outputTokens record.CacheCreationInputTokens = cacheCreationTokens record.CacheReadInputTokens = cacheReadTokens // 写入持久化存储(异步,不阻塞) if m.store != nil { m.store.AddRecord(PersistentRecord{ MetricsKey: metrics.MetricsKey, BaseURL: baseURL, KeyMask: metrics.KeyMask, Timestamp: record.Timestamp, Success: true, InputTokens: inputTokens, OutputTokens: outputTokens, CacheCreationTokens: cacheCreationTokens, CacheReadTokens: cacheReadTokens, APIType: m.apiType, }) } } // RecordRequestFinalizeFailure 回写失败结果(requestID 来自 RecordRequestConnected)。 func (m *MetricsManager) RecordRequestFinalizeFailure(baseURL, apiKey string, requestID uint64) { m.mu.Lock() defer m.mu.Unlock() metricsKey := generateMetricsKey(baseURL, apiKey) metrics, exists := m.keyMetrics[metricsKey] if !exists { m.recordFailureLocked(baseURL, apiKey, time.Now()) return } idx, ok := metrics.pendingHistoryIdx[requestID] if !ok || idx < 0 || idx >= len(metrics.requestHistory) { m.recordFailureLocked(baseURL, apiKey, time.Now()) return } delete(metrics.pendingHistoryIdx, requestID) // 正常路径:在此统一增加 RequestCount metrics.RequestCount++ metrics.FailureCount++ metrics.ConsecutiveFailures++ now := time.Now() metrics.LastFailureAt = &now // 更新滑动窗口 m.appendToWindowKey(metrics, false) // 检查是否刚进入熔断状态 if metrics.CircuitBrokenAt == nil && m.isKeyCircuitBroken(metrics) { metrics.CircuitBrokenAt = &now log.Printf("[Metrics-Circuit] Key [%s] (%s) 进入熔断状态(失败率: %.1f%%)", metrics.KeyMask, metrics.BaseURL, m.calculateKeyFailureRateInternal(metrics)*100) } // 回写历史记录(时间戳保持为“请求开始(TCP 建连阶段)”时刻) record := &metrics.requestHistory[idx] record.Success = false record.InputTokens = 0 record.OutputTokens = 0 record.CacheCreationInputTokens = 0 record.CacheReadInputTokens = 0 // 写入持久化存储(异步,不阻塞) if m.store != nil { m.store.AddRecord(PersistentRecord{ MetricsKey: metrics.MetricsKey, BaseURL: baseURL, KeyMask: metrics.KeyMask, Timestamp: record.Timestamp, Success: false, InputTokens: 0, OutputTokens: 0, CacheCreationTokens: 0, CacheReadTokens: 0, APIType: m.apiType, }) } } // RecordRequestFinalizeClientCancel 记录客户端取消的请求(计入总请求数但不计入失败) func (m *MetricsManager) RecordRequestFinalizeClientCancel(baseURL, apiKey string, requestID uint64) { m.mu.Lock() defer m.mu.Unlock() metricsKey := generateMetricsKey(baseURL, apiKey) metrics, exists := m.keyMetrics[metricsKey] if !exists { return } idx, ok := metrics.pendingHistoryIdx[requestID] if !ok || idx < 0 || idx >= len(metrics.requestHistory) { return } delete(metrics.pendingHistoryIdx, requestID) // 仅计入总请求数,不计入失败数 metrics.RequestCount++ // 注意:不重置 ConsecutiveFailures,客户端取消不应影响连续失败计数 // 不更新滑动窗口(不影响失败率计算) // 不检查熔断状态(客户端取消不应触发熔断) // 从历史记录中移除(客户端取消不记录) metrics.requestHistory = append(metrics.requestHistory[:idx], metrics.requestHistory[idx+1:]...) // 更新后续索引 for rid, ridx := range metrics.pendingHistoryIdx { if ridx > idx { metrics.pendingHistoryIdx[rid] = ridx - 1 } } } // RecordRequestStart 记录请求开始(增加进行中计数) func (m *MetricsManager) RecordRequestStart(baseURL, apiKey string) { m.mu.Lock() defer m.mu.Unlock() metrics := m.getOrCreateKey(baseURL, apiKey) metrics.ActiveRequests++ } // RecordRequestEnd 记录请求结束(减少进行中计数) func (m *MetricsManager) RecordRequestEnd(baseURL, apiKey string) { m.mu.Lock() defer m.mu.Unlock() metricsKey := generateMetricsKey(baseURL, apiKey) if metrics, exists := m.keyMetrics[metricsKey]; exists { if metrics.ActiveRequests > 0 { metrics.ActiveRequests-- } } } // isKeyCircuitBroken 判断 Key 是否达到熔断条件(内部方法,调用前需持有锁) func (m *MetricsManager) isKeyCircuitBroken(metrics *KeyMetrics) bool { // 最小请求数保护:至少 max(3, windowSize/2) 次请求才判断熔断 minRequests := max(3, m.windowSize/2) if len(metrics.recentResults) < minRequests { return false } return m.calculateKeyFailureRateInternal(metrics) >= m.failureThreshold } // calculateKeyFailureRateInternal 计算 Key 失败率(内部方法,调用前需持有锁) func (m *MetricsManager) calculateKeyFailureRateInternal(metrics *KeyMetrics) float64 { if len(metrics.recentResults) == 0 { return 0 } failures := 0 for _, success := range metrics.recentResults { if !success { failures++ } } return float64(failures) / float64(len(metrics.recentResults)) } // appendToWindowKey 向 Key 滑动窗口添加记录 func (m *MetricsManager) appendToWindowKey(metrics *KeyMetrics, success bool) { metrics.recentResults = append(metrics.recentResults, success) // 保持窗口大小 if len(metrics.recentResults) > m.windowSize { metrics.recentResults = metrics.recentResults[1:] } } // appendToHistoryKey 向 Key 历史记录添加请求(保留24小时) func (m *MetricsManager) appendToHistoryKey(metrics *KeyMetrics, timestamp time.Time, success bool) { m.appendToHistoryKeyWithUsage(metrics, timestamp, success, 0, 0, 0, 0) } // cleanupHistoryLocked 清理超过 24 小时的历史记录,并同步修正 pendingHistoryIdx 索引。 // 注意:调用方需要持有写锁。 func (m *MetricsManager) cleanupHistoryLocked(metrics *KeyMetrics) { if metrics == nil || len(metrics.requestHistory) == 0 { return } cutoff := time.Now().Add(-24 * time.Hour) newStart := -1 for i, record := range metrics.requestHistory { if record.Timestamp.After(cutoff) { newStart = i break } } if newStart > 0 { metrics.requestHistory = metrics.requestHistory[newStart:] // 索引平移:老数据被切走后,pending 索引需要整体减去 newStart if metrics.pendingHistoryIdx != nil && len(metrics.pendingHistoryIdx) > 0 { for id, idx := range metrics.pendingHistoryIdx { if idx < newStart { delete(metrics.pendingHistoryIdx, id) continue } metrics.pendingHistoryIdx[id] = idx - newStart } } return } if newStart == -1 { // 所有记录都过期,清空切片 metrics.requestHistory = metrics.requestHistory[:0] if metrics.pendingHistoryIdx != nil { for id := range metrics.pendingHistoryIdx { delete(metrics.pendingHistoryIdx, id) } } } } // appendToHistoryKeyWithUsage 向 Key 历史记录添加请求(带 Usage 数据) func (m *MetricsManager) appendToHistoryKeyWithUsage(metrics *KeyMetrics, timestamp time.Time, success bool, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens int64) { metrics.requestHistory = append(metrics.requestHistory, RequestRecord{ Timestamp: timestamp, Success: success, InputTokens: inputTokens, OutputTokens: outputTokens, CacheCreationInputTokens: cacheCreationTokens, CacheReadInputTokens: cacheReadTokens, }) // 清理超过 24 小时的记录 m.cleanupHistoryLocked(metrics) } // IsKeyHealthy 判断单个 Key 是否健康 func (m *MetricsManager) IsKeyHealthy(baseURL, apiKey string) bool { m.mu.RLock() defer m.mu.RUnlock() metricsKey := generateMetricsKey(baseURL, apiKey) metrics, exists := m.keyMetrics[metricsKey] if !exists || len(metrics.recentResults) == 0 { return true // 没有记录,默认健康 } return m.calculateKeyFailureRateInternal(metrics) < m.failureThreshold } // IsChannelHealthy 判断渠道是否健康(基于当前活跃 Keys 聚合计算) // activeKeys: 当前渠道配置的所有活跃 API Keys func (m *MetricsManager) IsChannelHealthyWithKeys(baseURL string, activeKeys []string) bool { if len(activeKeys) == 0 { return false // 没有 Key,不健康 } m.mu.RLock() defer m.mu.RUnlock() // 聚合所有活跃 Key 的指标 var totalResults []bool for _, apiKey := range activeKeys { metricsKey := generateMetricsKey(baseURL, apiKey) if metrics, exists := m.keyMetrics[metricsKey]; exists { totalResults = append(totalResults, metrics.recentResults...) } } // 没有任何记录,默认健康 if len(totalResults) == 0 { return true } // 最小请求数保护:至少 max(3, windowSize/2) 次请求才判断健康状态 minRequests := max(3, m.windowSize/2) if len(totalResults) < minRequests { return true // 请求数不足,默认健康 } // 计算聚合失败率 failures := 0 for _, success := range totalResults { if !success { failures++ } } failureRate := float64(failures) / float64(len(totalResults)) return failureRate < m.failureThreshold } // CalculateKeyFailureRate 计算单个 Key 的失败率 func (m *MetricsManager) CalculateKeyFailureRate(baseURL, apiKey string) float64 { m.mu.RLock() defer m.mu.RUnlock() metricsKey := generateMetricsKey(baseURL, apiKey) metrics, exists := m.keyMetrics[metricsKey] if !exists || len(metrics.recentResults) == 0 { return 0 } return m.calculateKeyFailureRateInternal(metrics) } // CalculateChannelFailureRate 计算渠道聚合失败率 func (m *MetricsManager) CalculateChannelFailureRate(baseURL string, activeKeys []string) float64 { if len(activeKeys) == 0 { return 0 } m.mu.RLock() defer m.mu.RUnlock() var totalResults []bool for _, apiKey := range activeKeys { metricsKey := generateMetricsKey(baseURL, apiKey) if metrics, exists := m.keyMetrics[metricsKey]; exists { totalResults = append(totalResults, metrics.recentResults...) } } if len(totalResults) == 0 { return 0 } failures := 0 for _, success := range totalResults { if !success { failures++ } } return float64(failures) / float64(len(totalResults)) } // GetKeyMetrics 获取单个 Key 的指标 func (m *MetricsManager) GetKeyMetrics(baseURL, apiKey string) *KeyMetrics { m.mu.RLock() defer m.mu.RUnlock() metricsKey := generateMetricsKey(baseURL, apiKey) if metrics, exists := m.keyMetrics[metricsKey]; exists { // 返回副本 return &KeyMetrics{ MetricsKey: metrics.MetricsKey, BaseURL: metrics.BaseURL, KeyMask: metrics.KeyMask, RequestCount: metrics.RequestCount, SuccessCount: metrics.SuccessCount, FailureCount: metrics.FailureCount, ConsecutiveFailures: metrics.ConsecutiveFailures, LastSuccessAt: metrics.LastSuccessAt, LastFailureAt: metrics.LastFailureAt, CircuitBrokenAt: metrics.CircuitBrokenAt, } } return nil } // GetChannelAggregatedMetrics 获取渠道聚合指标(基于活跃 Keys) func (m *MetricsManager) GetChannelAggregatedMetrics(channelIndex int, baseURL string, activeKeys []string) *ChannelMetrics { m.mu.RLock() defer m.mu.RUnlock() aggregated := &ChannelMetrics{ ChannelIndex: channelIndex, } var latestSuccess, latestFailure, latestCircuitBroken *time.Time var maxConsecutiveFailures int64 for _, apiKey := range activeKeys { metricsKey := generateMetricsKey(baseURL, apiKey) if metrics, exists := m.keyMetrics[metricsKey]; exists { aggregated.RequestCount += metrics.RequestCount aggregated.SuccessCount += metrics.SuccessCount aggregated.FailureCount += metrics.FailureCount if metrics.ConsecutiveFailures > maxConsecutiveFailures { maxConsecutiveFailures = metrics.ConsecutiveFailures } aggregated.recentResults = append(aggregated.recentResults, metrics.recentResults...) aggregated.requestHistory = append(aggregated.requestHistory, metrics.requestHistory...) // 取最新的时间戳 if metrics.LastSuccessAt != nil && (latestSuccess == nil || metrics.LastSuccessAt.After(*latestSuccess)) { latestSuccess = metrics.LastSuccessAt } if metrics.LastFailureAt != nil && (latestFailure == nil || metrics.LastFailureAt.After(*latestFailure)) { latestFailure = metrics.LastFailureAt } if metrics.CircuitBrokenAt != nil && (latestCircuitBroken == nil || metrics.CircuitBrokenAt.After(*latestCircuitBroken)) { latestCircuitBroken = metrics.CircuitBrokenAt } } } aggregated.LastSuccessAt = latestSuccess aggregated.LastFailureAt = latestFailure aggregated.CircuitBrokenAt = latestCircuitBroken aggregated.ConsecutiveFailures = maxConsecutiveFailures return aggregated } // KeyUsageInfo Key 使用信息(用于排序筛选) type KeyUsageInfo struct { APIKey string KeyMask string RequestCount int64 LastUsedAt *time.Time } // GetChannelKeyUsageInfo 获取渠道下所有 Key 的使用信息(用于排序筛选) // 返回的 keys 已按最近使用时间排序 func (m *MetricsManager) GetChannelKeyUsageInfo(baseURL string, apiKeys []string) []KeyUsageInfo { m.mu.RLock() defer m.mu.RUnlock() infos := make([]KeyUsageInfo, 0, len(apiKeys)) for _, apiKey := range apiKeys { metricsKey := generateMetricsKey(baseURL, apiKey) metrics, exists := m.keyMetrics[metricsKey] var keyMask string var requestCount int64 var lastUsedAt *time.Time if exists { keyMask = metrics.KeyMask requestCount = metrics.RequestCount lastUsedAt = metrics.LastSuccessAt if lastUsedAt == nil { lastUsedAt = metrics.LastFailureAt } } else { // Key 还没有指标记录,使用默认脱敏 keyMask = utils.MaskAPIKey(apiKey) requestCount = 0 } infos = append(infos, KeyUsageInfo{ APIKey: apiKey, KeyMask: keyMask, RequestCount: requestCount, LastUsedAt: lastUsedAt, }) } // 按最近使用时间排序(最近的在前面) sort.Slice(infos, func(i, j int) bool { if infos[i].LastUsedAt == nil && infos[j].LastUsedAt == nil { return infos[i].RequestCount > infos[j].RequestCount // 都未使用时,按访问量排序 } if infos[i].LastUsedAt == nil { return false // i 未使用,排后面 } if infos[j].LastUsedAt == nil { return true // j 未使用,i 排前面 } return infos[i].LastUsedAt.After(*infos[j].LastUsedAt) }) return infos } // GetChannelKeyUsageInfoMultiURL 获取渠道 Key 使用信息(支持多 URL 聚合) func (m *MetricsManager) GetChannelKeyUsageInfoMultiURL(baseURLs []string, apiKeys []string) []KeyUsageInfo { if len(baseURLs) == 0 { return []KeyUsageInfo{} } m.mu.RLock() defer m.mu.RUnlock() infos := make([]KeyUsageInfo, 0, len(apiKeys)) for _, apiKey := range apiKeys { var keyMask string var requestCount int64 var lastUsedAt *time.Time hasMetrics := false // 遍历所有 BaseURL 聚合同一 Key 的指标 for _, baseURL := range baseURLs { metricsKey := generateMetricsKey(baseURL, apiKey) if metrics, exists := m.keyMetrics[metricsKey]; exists { hasMetrics = true if keyMask == "" { keyMask = metrics.KeyMask } requestCount += metrics.RequestCount // 取最近的使用时间 var usedAt *time.Time if metrics.LastSuccessAt != nil { usedAt = metrics.LastSuccessAt } if usedAt == nil { usedAt = metrics.LastFailureAt } if usedAt != nil && (lastUsedAt == nil || usedAt.After(*lastUsedAt)) { lastUsedAt = usedAt } } } if !hasMetrics { // Key 还没有指标记录,使用默认脱敏 keyMask = utils.MaskAPIKey(apiKey) requestCount = 0 } infos = append(infos, KeyUsageInfo{ APIKey: apiKey, KeyMask: keyMask, RequestCount: requestCount, LastUsedAt: lastUsedAt, }) } // 按最近使用时间排序(最近的在前面) sort.Slice(infos, func(i, j int) bool { if infos[i].LastUsedAt == nil && infos[j].LastUsedAt == nil { return infos[i].RequestCount > infos[j].RequestCount // 都未使用时,按访问量排序 } if infos[i].LastUsedAt == nil { return false // i 未使用,排后面 } if infos[j].LastUsedAt == nil { return true // j 未使用,i 排前面 } return infos[i].LastUsedAt.After(*infos[j].LastUsedAt) }) return infos } // SelectTopKeys 筛选展示的 Key // 策略:先取最近使用的 5 个,再从其他 Key 中按访问量补全到 10 个 func SelectTopKeys(infos []KeyUsageInfo, maxDisplay int) []KeyUsageInfo { if len(infos) <= maxDisplay { return infos } // 分离:最近使用的和未使用的 var recentKeys []KeyUsageInfo var otherKeys []KeyUsageInfo for i, info := range infos { if i < 5 { recentKeys = append(recentKeys, info) } else { otherKeys = append(otherKeys, info) } } // 其他 Key 按访问量排序(降序) sort.Slice(otherKeys, func(i, j int) bool { return otherKeys[i].RequestCount > otherKeys[j].RequestCount }) // 补全到 maxDisplay 个 result := make([]KeyUsageInfo, 0, maxDisplay) result = append(result, recentKeys...) needCount := maxDisplay - len(recentKeys) if needCount > 0 && len(otherKeys) > 0 { if len(otherKeys) > needCount { otherKeys = otherKeys[:needCount] } result = append(result, otherKeys...) } return result } // GetAllKeyMetrics 获取所有 Key 的指标 func (m *MetricsManager) GetAllKeyMetrics() []*KeyMetrics { m.mu.RLock() defer m.mu.RUnlock() result := make([]*KeyMetrics, 0, len(m.keyMetrics)) for _, metrics := range m.keyMetrics { result = append(result, &KeyMetrics{ MetricsKey: metrics.MetricsKey, BaseURL: metrics.BaseURL, KeyMask: metrics.KeyMask, RequestCount: metrics.RequestCount, SuccessCount: metrics.SuccessCount, FailureCount: metrics.FailureCount, ConsecutiveFailures: metrics.ConsecutiveFailures, LastSuccessAt: metrics.LastSuccessAt, LastFailureAt: metrics.LastFailureAt, CircuitBrokenAt: metrics.CircuitBrokenAt, }) } return result } // GetTimeWindowStatsForKey 获取指定 Key 在时间窗口内的统计 func (m *MetricsManager) GetTimeWindowStatsForKey(baseURL, apiKey string, duration time.Duration) TimeWindowStats { m.mu.RLock() defer m.mu.RUnlock() metricsKey := generateMetricsKey(baseURL, apiKey) metrics, exists := m.keyMetrics[metricsKey] if !exists { return TimeWindowStats{SuccessRate: 100} } cutoff := time.Now().Add(-duration) var requestCount, successCount, failureCount int64 for _, record := range metrics.requestHistory { if record.Timestamp.After(cutoff) { requestCount++ if record.Success { successCount++ } else { failureCount++ } } } successRate := float64(100) if requestCount > 0 { successRate = float64(successCount) / float64(requestCount) * 100 } return TimeWindowStats{ RequestCount: requestCount, SuccessCount: successCount, FailureCount: failureCount, SuccessRate: successRate, } } // GetAllTimeWindowStatsForKey 获取单个 Key 所有时间窗口的统计 func (m *MetricsManager) GetAllTimeWindowStatsForKey(baseURL, apiKey string) map[string]TimeWindowStats { return map[string]TimeWindowStats{ "15m": m.GetTimeWindowStatsForKey(baseURL, apiKey, 15*time.Minute), "1h": m.GetTimeWindowStatsForKey(baseURL, apiKey, 1*time.Hour), "6h": m.GetTimeWindowStatsForKey(baseURL, apiKey, 6*time.Hour), "24h": m.GetTimeWindowStatsForKey(baseURL, apiKey, 24*time.Hour), } } // ResetKeyFailureState 重置单个 Key 的熔断/失败状态(保留历史统计与总量计数)。 // 用于“恢复熔断”场景:清零连续失败、清空滑动窗口、解除熔断标记。 func (m *MetricsManager) ResetKeyFailureState(baseURL, apiKey string) { m.mu.Lock() defer m.mu.Unlock() metricsKey := generateMetricsKey(baseURL, apiKey) if metrics, exists := m.keyMetrics[metricsKey]; exists { metrics.ConsecutiveFailures = 0 metrics.recentResults = make([]bool, 0, m.windowSize) metrics.CircuitBrokenAt = nil log.Printf("[Metrics-Reset] Key [%s] (%s) 熔断状态已重置(保留历史统计)", metrics.KeyMask, metrics.BaseURL) } } // ResetKey 重置单个 Key 的指标 func (m *MetricsManager) ResetKey(baseURL, apiKey string) { m.mu.Lock() defer m.mu.Unlock() metricsKey := generateMetricsKey(baseURL, apiKey) if metrics, exists := m.keyMetrics[metricsKey]; exists { // 完全重置所有字段 metrics.RequestCount = 0 metrics.SuccessCount = 0 metrics.FailureCount = 0 metrics.ConsecutiveFailures = 0 metrics.ActiveRequests = 0 metrics.LastSuccessAt = nil metrics.LastFailureAt = nil metrics.CircuitBrokenAt = nil metrics.recentResults = make([]bool, 0, m.windowSize) metrics.requestHistory = nil if metrics.pendingHistoryIdx != nil { for id := range metrics.pendingHistoryIdx { delete(metrics.pendingHistoryIdx, id) } } log.Printf("[Metrics-Reset] Key [%s] (%s) 指标已完全重置", metrics.KeyMask, metrics.BaseURL) } } // ResetAll 重置所有指标 func (m *MetricsManager) ResetAll() { m.mu.Lock() defer m.mu.Unlock() m.keyMetrics = make(map[string]*KeyMetrics) } // Stop 停止后台清理任务 func (m *MetricsManager) Stop() { close(m.stopCh) } // DeleteKeysForChannel 删除指定渠道的所有内存指标 // baseURLs: 渠道的所有 BaseURL(支持多端点 failover) // apiKeys: 渠道的所有 API Key // 返回所有可能的 metricsKey 列表(无论内存中是否存在,用于后续清理持久化数据) func (m *MetricsManager) DeleteKeysForChannel(baseURLs, apiKeys []string) []string { m.mu.Lock() defer m.mu.Unlock() var allKeys []string var deletedFromMemory int for _, baseURL := range baseURLs { for _, apiKey := range apiKeys { metricsKey := generateMetricsKey(baseURL, apiKey) allKeys = append(allKeys, metricsKey) if _, exists := m.keyMetrics[metricsKey]; exists { delete(m.keyMetrics, metricsKey) deletedFromMemory++ } } } if deletedFromMemory > 0 { log.Printf("[Metrics-Delete] 已删除 %d 个内存指标记录", deletedFromMemory) } return allKeys } // DeleteChannelMetrics 删除渠道的所有指标数据(内存 + 持久化) // baseURLs: 渠道的所有 BaseURL(支持多端点 failover) // apiKeys: 渠道的所有 API Key // 返回被删除的持久化记录数 func (m *MetricsManager) DeleteChannelMetrics(baseURLs, apiKeys []string) int64 { // 1. 删除内存指标,获取 metricsKey 列表 deletedKeys := m.DeleteKeysForChannel(baseURLs, apiKeys) // 2. 删除持久化数据(使用内部 apiType,避免外部误传) if m.store != nil && len(deletedKeys) > 0 { deleted, err := m.store.DeleteRecordsByMetricsKeys(deletedKeys, m.apiType) if err != nil { log.Printf("[Metrics-Delete] 警告: 删除持久化指标记录失败: %v", err) return 0 } if deleted > 0 { log.Printf("[Metrics-Delete] 已删除 %d 条 %s 持久化指标记录", deleted, m.apiType) } return deleted } return 0 } // cleanupCircuitBreakers 后台任务:定期检查并恢复超时的熔断 Key,清理过期指标 func (m *MetricsManager) cleanupCircuitBreakers() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() // 每小时清理一次过期 Key cleanupTicker := time.NewTicker(1 * time.Hour) defer cleanupTicker.Stop() for { select { case <-ticker.C: m.recoverExpiredCircuitBreakers() case <-cleanupTicker.C: m.cleanupStaleKeys() case <-m.stopCh: return } } } // recoverExpiredCircuitBreakers 恢复超时的熔断 Key func (m *MetricsManager) recoverExpiredCircuitBreakers() { m.mu.Lock() defer m.mu.Unlock() now := time.Now() for _, metrics := range m.keyMetrics { if metrics.CircuitBrokenAt != nil { elapsed := now.Sub(*metrics.CircuitBrokenAt) if elapsed > m.circuitRecoveryTime { // 重置熔断状态 metrics.ConsecutiveFailures = 0 metrics.recentResults = make([]bool, 0, m.windowSize) metrics.CircuitBrokenAt = nil log.Printf("[Metrics-Circuit] Key [%s] (%s) 熔断自动恢复(已超过 %v)", metrics.KeyMask, metrics.BaseURL, m.circuitRecoveryTime) } } } } // cleanupStaleKeys 清理过期的 Key 指标(超过 48 小时无活动) func (m *MetricsManager) cleanupStaleKeys() { m.mu.Lock() defer m.mu.Unlock() now := time.Now() staleThreshold := 48 * time.Hour var removed []string for key, metrics := range m.keyMetrics { // 判断最后活动时间 var lastActivity time.Time if metrics.LastSuccessAt != nil { lastActivity = *metrics.LastSuccessAt } if metrics.LastFailureAt != nil && metrics.LastFailureAt.After(lastActivity) { lastActivity = *metrics.LastFailureAt } // 如果从未有活动或超过阈值,删除 if lastActivity.IsZero() || now.Sub(lastActivity) > staleThreshold { delete(m.keyMetrics, key) removed = append(removed, metrics.KeyMask) } } if len(removed) > 0 { log.Printf("[Metrics-Cleanup] 清理了 %d 个过期 Key 指标: %v", len(removed), removed) } } // GetCircuitRecoveryTime 获取熔断恢复时间 func (m *MetricsManager) GetCircuitRecoveryTime() time.Duration { return m.circuitRecoveryTime } // GetFailureThreshold 获取失败率阈值 func (m *MetricsManager) GetFailureThreshold() float64 { return m.failureThreshold } // GetWindowSize 获取滑动窗口大小 func (m *MetricsManager) GetWindowSize() int { return m.windowSize } // ============ 兼容旧 API 的方法(基于 channelIndex,需要调用方提供 baseURL 和 keys)============ // MetricsResponse API 响应结构 type MetricsResponse struct { ChannelIndex int `json:"channelIndex"` RequestCount int64 `json:"requestCount"` SuccessCount int64 `json:"successCount"` FailureCount int64 `json:"failureCount"` SuccessRate float64 `json:"successRate"` ErrorRate float64 `json:"errorRate"` ConsecutiveFailures int64 `json:"consecutiveFailures"` ActiveRequests int64 `json:"activeRequests"` // 进行中请求数 Latency int64 `json:"latency"` LastSuccessAt *string `json:"lastSuccessAt,omitempty"` LastFailureAt *string `json:"lastFailureAt,omitempty"` CircuitBrokenAt *string `json:"circuitBrokenAt,omitempty"` TimeWindows map[string]TimeWindowStats `json:"timeWindows,omitempty"` KeyMetrics []*KeyMetricsResponse `json:"keyMetrics,omitempty"` // 各 Key 的详细指标 } // KeyMetricsResponse 单个 Key 的 API 响应 type KeyMetricsResponse struct { KeyMask string `json:"keyMask"` RequestCount int64 `json:"requestCount"` SuccessCount int64 `json:"successCount"` FailureCount int64 `json:"failureCount"` SuccessRate float64 `json:"successRate"` ConsecutiveFailures int64 `json:"consecutiveFailures"` CircuitBroken bool `json:"circuitBroken"` } // ToResponseMultiURL 转换为 API 响应格式(支持多 BaseURL 聚合) // baseURLs: 渠道配置的所有 BaseURL(用于多端点 failover 场景) // historicalKeys: 历史 API Key(用于统计聚合,只计入总数不显示在 KeyMetrics 中) func (m *MetricsManager) ToResponseMultiURL(channelIndex int, baseURLs []string, activeKeys []string, latency int64, historicalKeys ...[]string) *MetricsResponse { // 如果没有配置 BaseURL,返回空响应 if len(baseURLs) == 0 { return &MetricsResponse{ ChannelIndex: channelIndex, Latency: latency, SuccessRate: 100, ErrorRate: 0, } } m.mu.RLock() defer m.mu.RUnlock() resp := &MetricsResponse{ ChannelIndex: channelIndex, Latency: latency, } if len(activeKeys) == 0 { resp.SuccessRate = 100 resp.ErrorRate = 0 return resp } // 用于按 API Key 聚合的临时结构 type keyAggregation struct { keyMask string requestCount int64 successCount int64 failureCount int64 consecutiveFailures int64 circuitBroken bool } keyAggMap := make(map[string]*keyAggregation) // key: apiKey var latestSuccess, latestFailure, latestCircuitBroken *time.Time var totalResults []bool var maxConsecutiveFailures int64 // 遍历所有 BaseURL 和 Key 的组合 for _, baseURL := range baseURLs { for _, apiKey := range activeKeys { metricsKey := generateMetricsKey(baseURL, apiKey) if metrics, exists := m.keyMetrics[metricsKey]; exists { resp.RequestCount += metrics.RequestCount resp.SuccessCount += metrics.SuccessCount resp.FailureCount += metrics.FailureCount resp.ActiveRequests += metrics.ActiveRequests if metrics.ConsecutiveFailures > maxConsecutiveFailures { maxConsecutiveFailures = metrics.ConsecutiveFailures } totalResults = append(totalResults, metrics.recentResults...) // 取最新的时间戳 if metrics.LastSuccessAt != nil && (latestSuccess == nil || metrics.LastSuccessAt.After(*latestSuccess)) { latestSuccess = metrics.LastSuccessAt } if metrics.LastFailureAt != nil && (latestFailure == nil || metrics.LastFailureAt.After(*latestFailure)) { latestFailure = metrics.LastFailureAt } if metrics.CircuitBrokenAt != nil && (latestCircuitBroken == nil || metrics.CircuitBrokenAt.After(*latestCircuitBroken)) { latestCircuitBroken = metrics.CircuitBrokenAt } // 按 API Key 聚合(同一 Key 在不同 URL 的指标合并) if agg, ok := keyAggMap[apiKey]; ok { agg.requestCount += metrics.RequestCount agg.successCount += metrics.SuccessCount agg.failureCount += metrics.FailureCount if metrics.ConsecutiveFailures > agg.consecutiveFailures { agg.consecutiveFailures = metrics.ConsecutiveFailures } if metrics.CircuitBrokenAt != nil { agg.circuitBroken = true } } else { keyAggMap[apiKey] = &keyAggregation{ keyMask: metrics.KeyMask, requestCount: metrics.RequestCount, successCount: metrics.SuccessCount, failureCount: metrics.FailureCount, consecutiveFailures: metrics.ConsecutiveFailures, circuitBroken: metrics.CircuitBrokenAt != nil, } } } } } // 聚合历史 Key 的指标(只计入总数,不显示在 KeyMetrics 中) if len(historicalKeys) > 0 && len(historicalKeys[0]) > 0 { for _, baseURL := range baseURLs { for _, apiKey := range historicalKeys[0] { metricsKey := generateMetricsKey(baseURL, apiKey) if metrics, exists := m.keyMetrics[metricsKey]; exists { resp.RequestCount += metrics.RequestCount resp.SuccessCount += metrics.SuccessCount resp.FailureCount += metrics.FailureCount // 历史 Key 不计入 totalResults(不影响实时失败率计算) // 历史 Key 不计入 maxConsecutiveFailures(不影响熔断判断) } } } } // 构建按 Key 聚合后的响应(保持 activeKeys 顺序) var keyResponses []*KeyMetricsResponse for _, apiKey := range activeKeys { if agg, ok := keyAggMap[apiKey]; ok { keySuccessRate := float64(100) if agg.requestCount > 0 { keySuccessRate = float64(agg.successCount) / float64(agg.requestCount) * 100 } keyResponses = append(keyResponses, &KeyMetricsResponse{ KeyMask: agg.keyMask, RequestCount: agg.requestCount, SuccessCount: agg.successCount, FailureCount: agg.failureCount, SuccessRate: keySuccessRate, ConsecutiveFailures: agg.consecutiveFailures, CircuitBroken: agg.circuitBroken, }) } } // 计算聚合失败率 resp.ConsecutiveFailures = maxConsecutiveFailures if len(totalResults) > 0 { failures := 0 for _, success := range totalResults { if !success { failures++ } } failureRate := float64(failures) / float64(len(totalResults)) resp.SuccessRate = (1 - failureRate) * 100 resp.ErrorRate = failureRate * 100 } else { resp.SuccessRate = 100 resp.ErrorRate = 0 } if latestSuccess != nil { t := latestSuccess.Format(time.RFC3339) resp.LastSuccessAt = &t } if latestFailure != nil { t := latestFailure.Format(time.RFC3339) resp.LastFailureAt = &t } if latestCircuitBroken != nil { t := latestCircuitBroken.Format(time.RFC3339) resp.CircuitBrokenAt = &t } resp.KeyMetrics = keyResponses // 计算聚合的时间窗口统计(多 URL 版本) resp.TimeWindows = m.calculateAggregatedTimeWindowsMultiURL(baseURLs, activeKeys) return resp } // ToResponse 转换为 API 响应格式(需要提供 baseURL 和 activeKeys) func (m *MetricsManager) ToResponse(channelIndex int, baseURL string, activeKeys []string, latency int64) *MetricsResponse { m.mu.RLock() defer m.mu.RUnlock() resp := &MetricsResponse{ ChannelIndex: channelIndex, Latency: latency, } if len(activeKeys) == 0 { resp.SuccessRate = 100 resp.ErrorRate = 0 return resp } var keyResponses []*KeyMetricsResponse var latestSuccess, latestFailure, latestCircuitBroken *time.Time var totalResults []bool var maxConsecutiveFailures int64 for _, apiKey := range activeKeys { metricsKey := generateMetricsKey(baseURL, apiKey) if metrics, exists := m.keyMetrics[metricsKey]; exists { resp.RequestCount += metrics.RequestCount resp.SuccessCount += metrics.SuccessCount resp.FailureCount += metrics.FailureCount resp.ActiveRequests += metrics.ActiveRequests if metrics.ConsecutiveFailures > maxConsecutiveFailures { maxConsecutiveFailures = metrics.ConsecutiveFailures } totalResults = append(totalResults, metrics.recentResults...) // 取最新的时间戳 if metrics.LastSuccessAt != nil && (latestSuccess == nil || metrics.LastSuccessAt.After(*latestSuccess)) { latestSuccess = metrics.LastSuccessAt } if metrics.LastFailureAt != nil && (latestFailure == nil || metrics.LastFailureAt.After(*latestFailure)) { latestFailure = metrics.LastFailureAt } if metrics.CircuitBrokenAt != nil && (latestCircuitBroken == nil || metrics.CircuitBrokenAt.After(*latestCircuitBroken)) { latestCircuitBroken = metrics.CircuitBrokenAt } // 单个 Key 的指标 keySuccessRate := float64(100) if metrics.RequestCount > 0 { keySuccessRate = float64(metrics.SuccessCount) / float64(metrics.RequestCount) * 100 } keyResponses = append(keyResponses, &KeyMetricsResponse{ KeyMask: metrics.KeyMask, RequestCount: metrics.RequestCount, SuccessCount: metrics.SuccessCount, FailureCount: metrics.FailureCount, SuccessRate: keySuccessRate, ConsecutiveFailures: metrics.ConsecutiveFailures, CircuitBroken: metrics.CircuitBrokenAt != nil, }) } } // 计算聚合失败率 resp.ConsecutiveFailures = maxConsecutiveFailures if len(totalResults) > 0 { failures := 0 for _, success := range totalResults { if !success { failures++ } } failureRate := float64(failures) / float64(len(totalResults)) resp.SuccessRate = (1 - failureRate) * 100 resp.ErrorRate = failureRate * 100 } else { resp.SuccessRate = 100 resp.ErrorRate = 0 } if latestSuccess != nil { t := latestSuccess.Format(time.RFC3339) resp.LastSuccessAt = &t } if latestFailure != nil { t := latestFailure.Format(time.RFC3339) resp.LastFailureAt = &t } if latestCircuitBroken != nil { t := latestCircuitBroken.Format(time.RFC3339) resp.CircuitBrokenAt = &t } resp.KeyMetrics = keyResponses // 计算聚合的时间窗口统计 resp.TimeWindows = m.calculateAggregatedTimeWindowsInternal(baseURL, activeKeys) return resp } // calculateAggregatedTimeWindowsInternal 计算聚合的时间窗口统计(内部方法,调用前需持有锁) func (m *MetricsManager) calculateAggregatedTimeWindowsInternal(baseURL string, activeKeys []string) map[string]TimeWindowStats { windows := map[string]time.Duration{ "15m": 15 * time.Minute, "1h": 1 * time.Hour, "6h": 6 * time.Hour, "24h": 24 * time.Hour, } result := make(map[string]TimeWindowStats) now := time.Now() for label, duration := range windows { cutoff := now.Add(-duration) var requestCount, successCount, failureCount int64 var inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens int64 for _, apiKey := range activeKeys { metricsKey := generateMetricsKey(baseURL, apiKey) if metrics, exists := m.keyMetrics[metricsKey]; exists { for _, record := range metrics.requestHistory { if record.Timestamp.After(cutoff) { requestCount++ if record.Success { successCount++ } else { failureCount++ } inputTokens += record.InputTokens outputTokens += record.OutputTokens cacheCreationTokens += record.CacheCreationInputTokens cacheReadTokens += record.CacheReadInputTokens } } } } successRate := float64(100) if requestCount > 0 { successRate = float64(successCount) / float64(requestCount) * 100 } cacheHitRate := float64(0) denom := cacheReadTokens + inputTokens if denom > 0 { cacheHitRate = float64(cacheReadTokens) / float64(denom) * 100 } result[label] = TimeWindowStats{ RequestCount: requestCount, SuccessCount: successCount, FailureCount: failureCount, SuccessRate: successRate, InputTokens: inputTokens, OutputTokens: outputTokens, CacheCreationTokens: cacheCreationTokens, CacheReadTokens: cacheReadTokens, CacheHitRate: cacheHitRate, } } return result } // calculateAggregatedTimeWindowsMultiURL 计算聚合的时间窗口统计(多 URL 版本,内部方法,调用前需持有锁) func (m *MetricsManager) calculateAggregatedTimeWindowsMultiURL(baseURLs []string, activeKeys []string) map[string]TimeWindowStats { windows := map[string]time.Duration{ "15m": 15 * time.Minute, "1h": 1 * time.Hour, "6h": 6 * time.Hour, "24h": 24 * time.Hour, } result := make(map[string]TimeWindowStats) now := time.Now() for label, duration := range windows { cutoff := now.Add(-duration) var requestCount, successCount, failureCount int64 var inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens int64 // 遍历所有 BaseURL 和 Key 的组合 for _, baseURL := range baseURLs { for _, apiKey := range activeKeys { metricsKey := generateMetricsKey(baseURL, apiKey) if metrics, exists := m.keyMetrics[metricsKey]; exists { for _, record := range metrics.requestHistory { if record.Timestamp.After(cutoff) { requestCount++ if record.Success { successCount++ } else { failureCount++ } inputTokens += record.InputTokens outputTokens += record.OutputTokens cacheCreationTokens += record.CacheCreationInputTokens cacheReadTokens += record.CacheReadInputTokens } } } } } successRate := float64(100) if requestCount > 0 { successRate = float64(successCount) / float64(requestCount) * 100 } cacheHitRate := float64(0) denom := cacheReadTokens + inputTokens if denom > 0 { cacheHitRate = float64(cacheReadTokens) / float64(denom) * 100 } result[label] = TimeWindowStats{ RequestCount: requestCount, SuccessCount: successCount, FailureCount: failureCount, SuccessRate: successRate, InputTokens: inputTokens, OutputTokens: outputTokens, CacheCreationTokens: cacheCreationTokens, CacheReadTokens: cacheReadTokens, CacheHitRate: cacheHitRate, } } return result } // ============ 废弃的旧方法(保留签名以便编译,但标记为废弃)============ // Deprecated: 使用 IsChannelHealthyWithKeys 代替 // IsChannelHealthy 判断渠道是否健康(旧方法,不再使用 channelIndex) // 此方法保留是为了兼容,但始终返回 true,调用方应迁移到新方法 func (m *MetricsManager) IsChannelHealthy(channelIndex int) bool { log.Printf("[Metrics-Deprecated] 警告: 调用了废弃的 IsChannelHealthy(channelIndex=%d),请迁移到 IsChannelHealthyWithKeys", channelIndex) return true // 默认健康,避免影响现有逻辑 } // Deprecated: 使用 CalculateChannelFailureRate 代替 func (m *MetricsManager) CalculateFailureRate(channelIndex int) float64 { return 0 } // Deprecated: 使用 CalculateChannelFailureRate 代替 func (m *MetricsManager) CalculateSuccessRate(channelIndex int) float64 { return 1 } // Deprecated: 使用 ResetKey 代替 func (m *MetricsManager) Reset(channelIndex int) { log.Printf("[Metrics-Deprecated] 警告: 调用了废弃的 Reset(channelIndex=%d),请迁移到 ResetKey", channelIndex) } // Deprecated: 使用 GetChannelAggregatedMetrics 代替 func (m *MetricsManager) GetMetrics(channelIndex int) *ChannelMetrics { return nil } // Deprecated: 使用 GetAllKeyMetrics 代替 func (m *MetricsManager) GetAllMetrics() []*ChannelMetrics { return nil } // Deprecated: 使用 GetTimeWindowStatsForKey 代替 func (m *MetricsManager) GetTimeWindowStats(channelIndex int, duration time.Duration) TimeWindowStats { return TimeWindowStats{SuccessRate: 100} } // Deprecated: 使用 GetAllTimeWindowStatsForKey 代替 func (m *MetricsManager) GetAllTimeWindowStats(channelIndex int) map[string]TimeWindowStats { return map[string]TimeWindowStats{ "15m": {SuccessRate: 100}, "1h": {SuccessRate: 100}, "6h": {SuccessRate: 100}, "24h": {SuccessRate: 100}, } } // Deprecated: 使用新的 ShouldSuspendKey 代替 func (m *MetricsManager) ShouldSuspend(channelIndex int) bool { return false } // ShouldSuspendKey 判断单个 Key 是否应该熔断 func (m *MetricsManager) ShouldSuspendKey(baseURL, apiKey string) bool { m.mu.RLock() defer m.mu.RUnlock() metricsKey := generateMetricsKey(baseURL, apiKey) metrics, exists := m.keyMetrics[metricsKey] if !exists { return false } // 最小请求数保护:至少 max(3, windowSize/2) 次请求才判断 minRequests := max(3, m.windowSize/2) if len(metrics.recentResults) < minRequests { return false } return m.calculateKeyFailureRateInternal(metrics) >= m.failureThreshold } // ============ 历史数据查询方法(用于图表可视化)============ // HistoryDataPoint 历史数据点(用于时间序列图表) type HistoryDataPoint struct { Timestamp time.Time `json:"timestamp"` RequestCount int64 `json:"requestCount"` SuccessCount int64 `json:"successCount"` FailureCount int64 `json:"failureCount"` SuccessRate float64 `json:"successRate"` } // KeyHistoryDataPoint Key 级别历史数据点(包含 Token 和 Cache 数据) type KeyHistoryDataPoint struct { Timestamp time.Time `json:"timestamp"` RequestCount int64 `json:"requestCount"` SuccessCount int64 `json:"successCount"` FailureCount int64 `json:"failureCount"` SuccessRate float64 `json:"successRate"` InputTokens int64 `json:"inputTokens"` OutputTokens int64 `json:"outputTokens"` CacheCreationInputTokens int64 `json:"cacheCreationTokens"` CacheReadInputTokens int64 `json:"cacheReadTokens"` } // GetHistoricalStats 获取历史统计数据(按时间间隔聚合) // duration: 查询时间范围 (如 1h, 6h, 24h) // interval: 聚合间隔 (如 5m, 15m, 1h) func (m *MetricsManager) GetHistoricalStats(baseURL string, activeKeys []string, duration, interval time.Duration) []HistoryDataPoint { // 参数验证 if interval <= 0 || duration <= 0 { return []HistoryDataPoint{} } m.mu.RLock() defer m.mu.RUnlock() now := time.Now() // 时间对齐到 interval 边界 startTime := now.Add(-duration).Truncate(interval) // endTime 延伸一个 interval,确保当前时间段的请求也被包含 endTime := now.Truncate(interval).Add(interval) // 计算需要多少个数据点(+1 用于包含延伸的当前时间段) numPoints := int(duration / interval) if numPoints <= 0 { numPoints = 1 } numPoints++ // 额外的一个桶用于当前时间段 // 使用 map 按时间分桶,优化性能:O(records) 而不是 O(records * numPoints) buckets := make(map[int64]*bucketData) for i := 0; i < numPoints; i++ { buckets[int64(i)] = &bucketData{} } // 收集所有相关 Key 的请求历史并放入对应桶 for _, apiKey := range activeKeys { metricsKey := generateMetricsKey(baseURL, apiKey) if metrics, exists := m.keyMetrics[metricsKey]; exists { for _, record := range metrics.requestHistory { // 使用 [startTime, endTime) 的区间,避免 endTime 处 offset 越界 if !record.Timestamp.Before(startTime) && record.Timestamp.Before(endTime) { // 计算记录应该属于哪个桶 offset := int64(record.Timestamp.Sub(startTime) / interval) if offset >= 0 && offset < int64(numPoints) { b := buckets[offset] b.requestCount++ if record.Success { b.successCount++ } else { b.failureCount++ } } } } } } // 构建结果 result := make([]HistoryDataPoint, numPoints) for i := 0; i < numPoints; i++ { b := buckets[int64(i)] // 空桶成功率默认为 0,避免误导(100% 暗示完美成功) successRate := float64(0) if b.requestCount > 0 { successRate = float64(b.successCount) / float64(b.requestCount) * 100 } result[i] = HistoryDataPoint{ Timestamp: startTime.Add(time.Duration(i) * interval), RequestCount: b.requestCount, SuccessCount: b.successCount, FailureCount: b.failureCount, SuccessRate: successRate, } } return result } // GetHistoricalStatsMultiURL 获取多 URL 聚合的历史统计数据 func (m *MetricsManager) GetHistoricalStatsMultiURL(baseURLs []string, activeKeys []string, duration, interval time.Duration) []HistoryDataPoint { // 参数验证 if interval <= 0 || duration <= 0 || len(baseURLs) == 0 { return []HistoryDataPoint{} } m.mu.RLock() defer m.mu.RUnlock() now := time.Now() // 时间对齐到 interval 边界 startTime := now.Add(-duration).Truncate(interval) // endTime 延伸一个 interval,确保当前时间段的请求也被包含 endTime := now.Truncate(interval).Add(interval) // 计算需要多少个数据点(+1 用于包含延伸的当前时间段) numPoints := int(duration / interval) if numPoints <= 0 { numPoints = 1 } numPoints++ // 额外的一个桶用于当前时间段 // 使用 map 按时间分桶,优化性能:O(records) 而不是 O(records * numPoints) buckets := make(map[int64]*bucketData) for i := 0; i < numPoints; i++ { buckets[int64(i)] = &bucketData{} } // 收集所有 BaseURL 和 Key 组合的请求历史并放入对应桶 for _, baseURL := range baseURLs { for _, apiKey := range activeKeys { metricsKey := generateMetricsKey(baseURL, apiKey) if metrics, exists := m.keyMetrics[metricsKey]; exists { for _, record := range metrics.requestHistory { // 使用 [startTime, endTime) 的区间,避免 endTime 处 offset 越界 if !record.Timestamp.Before(startTime) && record.Timestamp.Before(endTime) { // 计算记录应该属于哪个桶 offset := int64(record.Timestamp.Sub(startTime) / interval) if offset >= 0 && offset < int64(numPoints) { b := buckets[offset] b.requestCount++ if record.Success { b.successCount++ } else { b.failureCount++ } } } } } } } // 构建结果 result := make([]HistoryDataPoint, numPoints) for i := 0; i < numPoints; i++ { b := buckets[int64(i)] // 空桶成功率默认为 0,避免误导(100% 暗示完美成功) successRate := float64(0) if b.requestCount > 0 { successRate = float64(b.successCount) / float64(b.requestCount) * 100 } result[i] = HistoryDataPoint{ Timestamp: startTime.Add(time.Duration(i) * interval), RequestCount: b.requestCount, SuccessCount: b.successCount, FailureCount: b.failureCount, SuccessRate: successRate, } } return result } // bucketData 用于时间分桶的辅助结构 type bucketData struct { requestCount int64 successCount int64 failureCount int64 } func (m *MetricsManager) GetAllKeysHistoricalStats(duration, interval time.Duration) []HistoryDataPoint { // 参数验证 if interval <= 0 || duration <= 0 { return []HistoryDataPoint{} } m.mu.RLock() defer m.mu.RUnlock() now := time.Now() // 时间对齐到 interval 边界 startTime := now.Add(-duration).Truncate(interval) // endTime 延伸一个 interval,确保当前时间段的请求也被包含 endTime := now.Truncate(interval).Add(interval) numPoints := int(duration / interval) if numPoints <= 0 { numPoints = 1 } numPoints++ // 额外的一个桶用于当前时间段 // 使用 map 按时间分桶,优化性能 buckets := make(map[int64]*bucketData) for i := 0; i < numPoints; i++ { buckets[int64(i)] = &bucketData{} } // 收集所有 Key 的请求历史并放入对应桶 for _, metrics := range m.keyMetrics { for _, record := range metrics.requestHistory { // 使用 [startTime, endTime) 的区间,避免 endTime 处 offset 越界 if !record.Timestamp.Before(startTime) && record.Timestamp.Before(endTime) { offset := int64(record.Timestamp.Sub(startTime) / interval) if offset >= 0 && offset < int64(numPoints) { b := buckets[offset] b.requestCount++ if record.Success { b.successCount++ } else { b.failureCount++ } } } } } // 构建结果 result := make([]HistoryDataPoint, numPoints) for i := 0; i < numPoints; i++ { b := buckets[int64(i)] // 空桶成功率默认为 0,避免误导(100% 暗示完美成功) successRate := float64(0) if b.requestCount > 0 { successRate = float64(b.successCount) / float64(b.requestCount) * 100 } result[i] = HistoryDataPoint{ Timestamp: startTime.Add(time.Duration(i) * interval), RequestCount: b.requestCount, SuccessCount: b.successCount, FailureCount: b.failureCount, SuccessRate: successRate, } } return result } // GetKeyHistoricalStats 获取单个 Key 的历史统计数据(包含 Token 和 Cache 数据) func (m *MetricsManager) GetKeyHistoricalStats(baseURL, apiKey string, duration, interval time.Duration) []KeyHistoryDataPoint { // 参数验证 if interval <= 0 || duration <= 0 { return []KeyHistoryDataPoint{} } m.mu.RLock() defer m.mu.RUnlock() now := time.Now() // 时间对齐到 interval 边界 startTime := now.Add(-duration).Truncate(interval) // endTime 延伸一个 interval,确保当前时间段的请求也被包含 endTime := now.Truncate(interval).Add(interval) numPoints := int(duration / interval) if numPoints <= 0 { numPoints = 1 } numPoints++ // 额外的一个桶用于当前时间段 // 使用 map 按时间分桶 buckets := make(map[int64]*keyBucketData) for i := 0; i < numPoints; i++ { buckets[int64(i)] = &keyBucketData{} } // 获取 Key 的指标 metricsKey := generateMetricsKey(baseURL, apiKey) metrics, exists := m.keyMetrics[metricsKey] if !exists { // Key 不存在,返回空数据点 result := make([]KeyHistoryDataPoint, numPoints) for i := 0; i < numPoints; i++ { result[i] = KeyHistoryDataPoint{ Timestamp: startTime.Add(time.Duration(i+1) * interval), } } return result } // 收集该 Key 的请求历史并放入对应桶 for _, record := range metrics.requestHistory { // 使用 Before(endTime) 排除恰好落在 endTime 的记录,避免 offset 越界 if record.Timestamp.After(startTime) && record.Timestamp.Before(endTime) { offset := int64(record.Timestamp.Sub(startTime) / interval) if offset >= 0 && offset < int64(numPoints) { b := buckets[offset] b.requestCount++ if record.Success { b.successCount++ } else { b.failureCount++ } // 累加 Token 数据 b.inputTokens += record.InputTokens b.outputTokens += record.OutputTokens b.cacheCreationTokens += record.CacheCreationInputTokens b.cacheReadTokens += record.CacheReadInputTokens } } } // 构建结果 result := make([]KeyHistoryDataPoint, numPoints) for i := 0; i < numPoints; i++ { b := buckets[int64(i)] // 空桶成功率默认为 0,避免误导(100% 暗示完美成功) successRate := float64(0) if b.requestCount > 0 { successRate = float64(b.successCount) / float64(b.requestCount) * 100 } result[i] = KeyHistoryDataPoint{ Timestamp: startTime.Add(time.Duration(i+1) * interval), RequestCount: b.requestCount, SuccessCount: b.successCount, FailureCount: b.failureCount, SuccessRate: successRate, InputTokens: b.inputTokens, OutputTokens: b.outputTokens, CacheCreationInputTokens: b.cacheCreationTokens, CacheReadInputTokens: b.cacheReadTokens, } } return result } // GetKeyHistoricalStatsMultiURL 获取单个 Key 的多 URL 聚合历史统计 func (m *MetricsManager) GetKeyHistoricalStatsMultiURL(baseURLs []string, apiKey string, duration, interval time.Duration) []KeyHistoryDataPoint { // 参数验证 if interval <= 0 || duration <= 0 || len(baseURLs) == 0 { return []KeyHistoryDataPoint{} } m.mu.RLock() defer m.mu.RUnlock() now := time.Now() // 时间对齐到 interval 边界 startTime := now.Add(-duration).Truncate(interval) // endTime 延伸一个 interval,确保当前时间段的请求也被包含 endTime := now.Truncate(interval).Add(interval) numPoints := int(duration / interval) if numPoints <= 0 { numPoints = 1 } numPoints++ // 额外的一个桶用于当前时间段 // 使用 map 按时间分桶 buckets := make(map[int64]*keyBucketData) for i := 0; i < numPoints; i++ { buckets[int64(i)] = &keyBucketData{} } // 遍历所有 BaseURL 聚合同一 Key 的历史数据 hasData := false for _, baseURL := range baseURLs { metricsKey := generateMetricsKey(baseURL, apiKey) metrics, exists := m.keyMetrics[metricsKey] if !exists { continue } hasData = true // 收集该 URL+Key 组合的请求历史并放入对应桶 for _, record := range metrics.requestHistory { // 使用 Before(endTime) 排除恰好落在 endTime 的记录,避免 offset 越界 if record.Timestamp.After(startTime) && record.Timestamp.Before(endTime) { offset := int64(record.Timestamp.Sub(startTime) / interval) if offset >= 0 && offset < int64(numPoints) { b := buckets[offset] b.requestCount++ if record.Success { b.successCount++ } else { b.failureCount++ } // 累加 Token 数据 b.inputTokens += record.InputTokens b.outputTokens += record.OutputTokens b.cacheCreationTokens += record.CacheCreationInputTokens b.cacheReadTokens += record.CacheReadInputTokens } } } } // 如果没有任何数据,返回空数据点 if !hasData { result := make([]KeyHistoryDataPoint, numPoints) for i := 0; i < numPoints; i++ { result[i] = KeyHistoryDataPoint{ Timestamp: startTime.Add(time.Duration(i+1) * interval), } } return result } // 构建结果 result := make([]KeyHistoryDataPoint, numPoints) for i := 0; i < numPoints; i++ { b := buckets[int64(i)] // 空桶成功率默认为 0,避免误导(100% 暗示完美成功) successRate := float64(0) if b.requestCount > 0 { successRate = float64(b.successCount) / float64(b.requestCount) * 100 } result[i] = KeyHistoryDataPoint{ Timestamp: startTime.Add(time.Duration(i+1) * interval), RequestCount: b.requestCount, SuccessCount: b.successCount, FailureCount: b.failureCount, SuccessRate: successRate, InputTokens: b.inputTokens, OutputTokens: b.outputTokens, CacheCreationInputTokens: b.cacheCreationTokens, CacheReadInputTokens: b.cacheReadTokens, } } return result } // keyBucketData Key 级别时间分桶的辅助结构(包含 Token 数据) type keyBucketData struct { requestCount int64 successCount int64 failureCount int64 inputTokens int64 outputTokens int64 cacheCreationTokens int64 cacheReadTokens int64 } // ============ 全局统计数据结构和方法(用于全局流量统计图表)============ // GlobalHistoryDataPoint 全局历史数据点(含 Token 数据) type GlobalHistoryDataPoint struct { Timestamp time.Time `json:"timestamp"` RequestCount int64 `json:"requestCount"` SuccessCount int64 `json:"successCount"` FailureCount int64 `json:"failureCount"` SuccessRate float64 `json:"successRate"` InputTokens int64 `json:"inputTokens"` OutputTokens int64 `json:"outputTokens"` CacheCreationTokens int64 `json:"cacheCreationTokens"` CacheReadTokens int64 `json:"cacheReadTokens"` } // GlobalStatsSummary 全局统计汇总 type GlobalStatsSummary struct { TotalRequests int64 `json:"totalRequests"` TotalSuccess int64 `json:"totalSuccess"` TotalFailure int64 `json:"totalFailure"` TotalInputTokens int64 `json:"totalInputTokens"` TotalOutputTokens int64 `json:"totalOutputTokens"` TotalCacheCreationTokens int64 `json:"totalCacheCreationTokens"` TotalCacheReadTokens int64 `json:"totalCacheReadTokens"` AvgSuccessRate float64 `json:"avgSuccessRate"` Duration string `json:"duration"` } // GlobalStatsHistoryResponse 全局统计响应 type GlobalStatsHistoryResponse struct { DataPoints []GlobalHistoryDataPoint `json:"dataPoints"` Summary GlobalStatsSummary `json:"summary"` } // GetGlobalHistoricalStatsWithTokens 获取全局历史统计(包含 Token 数据) // 聚合所有 Key 的数据,按时间间隔分桶 func (m *MetricsManager) GetGlobalHistoricalStatsWithTokens(duration, interval time.Duration) GlobalStatsHistoryResponse { // 参数验证 if interval <= 0 || duration <= 0 { return GlobalStatsHistoryResponse{ DataPoints: []GlobalHistoryDataPoint{}, Summary: GlobalStatsSummary{Duration: duration.String()}, } } m.mu.RLock() defer m.mu.RUnlock() now := time.Now() // 时间对齐到 interval 边界 startTime := now.Add(-duration).Truncate(interval) // endTime 延伸一个 interval,确保当前时间段的请求也被包含 endTime := now.Truncate(interval).Add(interval) numPoints := int(duration / interval) if numPoints <= 0 { numPoints = 1 } numPoints++ // 额外的一个桶用于当前时间段 // 使用 map 按时间分桶 buckets := make(map[int64]*globalBucketData) for i := 0; i < numPoints; i++ { buckets[int64(i)] = &globalBucketData{} } // 汇总统计 var totalRequests, totalSuccess, totalFailure int64 var totalInputTokens, totalOutputTokens, totalCacheCreation, totalCacheRead int64 // 遍历所有 Key 的请求历史 for _, metrics := range m.keyMetrics { for _, record := range metrics.requestHistory { // 使用 Before(endTime) 排除恰好落在 endTime 的记录,避免 offset 越界 if record.Timestamp.After(startTime) && record.Timestamp.Before(endTime) { offset := int64(record.Timestamp.Sub(startTime) / interval) if offset >= 0 && offset < int64(numPoints) { b := buckets[offset] b.requestCount++ if record.Success { b.successCount++ } else { b.failureCount++ } b.inputTokens += record.InputTokens b.outputTokens += record.OutputTokens b.cacheCreationTokens += record.CacheCreationInputTokens b.cacheReadTokens += record.CacheReadInputTokens // 累加汇总 totalRequests++ if record.Success { totalSuccess++ } else { totalFailure++ } totalInputTokens += record.InputTokens totalOutputTokens += record.OutputTokens totalCacheCreation += record.CacheCreationInputTokens totalCacheRead += record.CacheReadInputTokens } } } } // 构建数据点结果 dataPoints := make([]GlobalHistoryDataPoint, numPoints) for i := 0; i < numPoints; i++ { b := buckets[int64(i)] successRate := float64(0) if b.requestCount > 0 { successRate = float64(b.successCount) / float64(b.requestCount) * 100 } dataPoints[i] = GlobalHistoryDataPoint{ Timestamp: startTime.Add(time.Duration(i+1) * interval), RequestCount: b.requestCount, SuccessCount: b.successCount, FailureCount: b.failureCount, SuccessRate: successRate, InputTokens: b.inputTokens, OutputTokens: b.outputTokens, CacheCreationTokens: b.cacheCreationTokens, CacheReadTokens: b.cacheReadTokens, } } // 计算平均成功率 avgSuccessRate := float64(0) if totalRequests > 0 { avgSuccessRate = float64(totalSuccess) / float64(totalRequests) * 100 } summary := GlobalStatsSummary{ TotalRequests: totalRequests, TotalSuccess: totalSuccess, TotalFailure: totalFailure, TotalInputTokens: totalInputTokens, TotalOutputTokens: totalOutputTokens, TotalCacheCreationTokens: totalCacheCreation, TotalCacheReadTokens: totalCacheRead, AvgSuccessRate: avgSuccessRate, Duration: duration.String(), } return GlobalStatsHistoryResponse{ DataPoints: dataPoints, Summary: summary, } } // globalBucketData 全局统计时间分桶的辅助结构 type globalBucketData struct { requestCount int64 successCount int64 failureCount int64 inputTokens int64 outputTokens int64 cacheCreationTokens int64 cacheReadTokens int64 } // CalculateTodayDuration 计算"今日"时间范围(从今天 0 点到现在) func CalculateTodayDuration() time.Duration { now := time.Now() startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) return now.Sub(startOfDay) } // ============ 渠道实时活跃度数据(用于渐变背景显示)============ // ActivitySegment 活跃度分段数据(每 6 秒一段) type ActivitySegment struct { RequestCount int64 `json:"requestCount"` SuccessCount int64 `json:"successCount"` FailureCount int64 `json:"failureCount"` InputTokens int64 `json:"inputTokens"` OutputTokens int64 `json:"outputTokens"` } // ChannelRecentActivity 渠道最近活跃度数据 type ChannelRecentActivity struct { ChannelIndex int `json:"channelIndex"` Segments []ActivitySegment `json:"segments"` // 150 段,每段 6 秒,从旧到新(共 15 分钟) RPM float64 `json:"rpm"` // 15分钟平均 RPM TPM float64 `json:"tpm"` // 15分钟平均 TPM } // GetRecentActivityMultiURL 获取渠道最近活跃度数据(支持多 URL 和多 Key 聚合) // 参数: // - channelIndex: 渠道索引 // - baseURLs: 渠道的所有故障转移 URL(支持多个) // - activeKeys: 渠道的所有活跃 API Key(支持多个) // // 返回: // - 150 段活跃度数据(每段 6 秒,共 15 分钟) // - 自动聚合所有 URL × Key 组合的请求数据 // - RPM/TPM 为 15 分钟平均值 func (m *MetricsManager) GetRecentActivityMultiURL(channelIndex int, baseURLs []string, activeKeys []string) *ChannelRecentActivity { // 150 段,每段 6 秒 = 900 秒 = 15 分钟 const numSegments = 150 const segmentDuration = 6 * time.Second if len(baseURLs) == 0 || len(activeKeys) == 0 { return &ChannelRecentActivity{ ChannelIndex: channelIndex, Segments: make([]ActivitySegment, numSegments), RPM: 0, TPM: 0, } } m.mu.RLock() defer m.mu.RUnlock() now := time.Now() // 时间边界对齐:将 endTime 向上对齐到下一个 segmentDuration 边界 // 这样每次请求的分段边界都是固定的,不会因为 now 的微小变化而导致数据跳动 // 例如:segmentDuration=6s,now=12:34:57,则 endTime=12:35:00(包含当前正在进行的段) endTimeUnix := now.Unix() segmentSeconds := int64(segmentDuration.Seconds()) alignedEndUnix := ((endTimeUnix / segmentSeconds) + 1) * segmentSeconds endTime := time.Unix(alignedEndUnix, 0) startTime := endTime.Add(-time.Duration(numSegments) * segmentDuration) // 初始化分段数据 segments := make([]ActivitySegment, numSegments) // 汇总统计 var totalRequests, totalInputTokens, totalOutputTokens int64 // 遍历所有 BaseURL 和 Key 的组合 for _, baseURL := range baseURLs { for _, apiKey := range activeKeys { metricsKey := generateMetricsKey(baseURL, apiKey) metrics, exists := m.keyMetrics[metricsKey] if !exists { continue } // 遍历该 Key 的请求历史,放入对应分段 for _, record := range metrics.requestHistory { // 检查是否在 [startTime, endTime) 范围内 if record.Timestamp.Before(startTime) || !record.Timestamp.Before(endTime) { continue } // 计算属于哪个分段 offset := int(record.Timestamp.Sub(startTime) / segmentDuration) if offset < 0 || offset >= numSegments { continue } seg := &segments[offset] seg.RequestCount++ if record.Success { seg.SuccessCount++ } else { seg.FailureCount++ } seg.InputTokens += record.InputTokens seg.OutputTokens += record.OutputTokens // 累加汇总 totalRequests++ totalInputTokens += record.InputTokens totalOutputTokens += record.OutputTokens } } } // 计算 RPM 和 TPM(基于实际窗口时长) // TPM 只计算输出 tokens(包含思考),不包含输入 tokens 和缓存 tokens windowMinutes := float64(numSegments) * segmentDuration.Minutes() rpm := float64(totalRequests) / windowMinutes tpm := float64(totalOutputTokens) / windowMinutes return &ChannelRecentActivity{ ChannelIndex: channelIndex, Segments: segments, RPM: rpm, TPM: tpm, } } ================================================ FILE: backend-go/internal/metrics/channel_metrics_activity_test.go ================================================ package metrics import ( "math" "testing" "time" ) // floatEquals 使用容差比较浮点数 func floatEquals(a, b, epsilon float64) bool { return math.Abs(a-b) < epsilon } func TestGetRecentActivityMultiURL_EmptyInputs(t *testing.T) { m := NewMetricsManager() defer m.Stop() // 空 baseURLs result := m.GetRecentActivityMultiURL(0, []string{}, []string{"key1"}) if result.ChannelIndex != 0 { t.Errorf("expected channelIndex 0, got %d", result.ChannelIndex) } if len(result.Segments) != 150 { t.Errorf("expected 150 segments, got %d", len(result.Segments)) } if result.RPM != 0 || result.TPM != 0 { t.Errorf("expected RPM=0, TPM=0 for empty input") } // 空 activeKeys result = m.GetRecentActivityMultiURL(0, []string{"http://example.com"}, []string{}) if len(result.Segments) != 150 { t.Errorf("expected 150 segments, got %d", len(result.Segments)) } } func TestGetRecentActivityMultiURL_SegmentBoundaries(t *testing.T) { m := NewMetricsManager() defer m.Stop() baseURL := "http://test.com" apiKey := "test-key" // 模拟在不同时间点的请求 now := time.Now() m.mu.Lock() metrics := m.getOrCreateKey(baseURL, apiKey) // 添加当前 6 秒段的请求(应该在最后一个 segment) metrics.requestHistory = append(metrics.requestHistory, RequestRecord{ Timestamp: now, Success: true, InputTokens: 100, OutputTokens: 50, }) // 添加 5 分钟前的请求(5*60/6 = 50 段前) metrics.requestHistory = append(metrics.requestHistory, RequestRecord{ Timestamp: now.Add(-5 * time.Minute), Success: true, InputTokens: 200, OutputTokens: 100, }) // 添加 14 分钟前的请求(14*60/6 = 140 段前) metrics.requestHistory = append(metrics.requestHistory, RequestRecord{ Timestamp: now.Add(-14 * time.Minute), Success: false, InputTokens: 50, OutputTokens: 25, }) // 添加 16 分钟前的请求(应该被排除,超出 15 分钟窗口) metrics.requestHistory = append(metrics.requestHistory, RequestRecord{ Timestamp: now.Add(-16 * time.Minute), Success: true, InputTokens: 1000, OutputTokens: 500, }) m.mu.Unlock() result := m.GetRecentActivityMultiURL(1, []string{baseURL}, []string{apiKey}) // 验证 channelIndex if result.ChannelIndex != 1 { t.Errorf("expected channelIndex 1, got %d", result.ChannelIndex) } // 验证 segment 数量(150 段,每段 6 秒) if len(result.Segments) != 150 { t.Errorf("expected 150 segments, got %d", len(result.Segments)) } // 验证总请求数(应该是 3,排除 16 分钟前的) var totalRequests int64 for _, seg := range result.Segments { totalRequests += seg.RequestCount } if totalRequests != 3 { t.Errorf("expected 3 total requests, got %d", totalRequests) } // 验证 RPM 计算(15 分钟平均) expectedRPM := 3.0 / 15.0 if !floatEquals(result.RPM, expectedRPM, 0.0001) { t.Errorf("expected RPM %.4f, got %.4f", expectedRPM, result.RPM) } // 验证 TPM 只计算 OutputTokens(50 + 100 + 25 = 175) expectedTPM := 175.0 / 15.0 if !floatEquals(result.TPM, expectedTPM, 0.0001) { t.Errorf("expected TPM %.4f, got %.4f", expectedTPM, result.TPM) } } func TestGetRecentActivityMultiURL_FailureCount(t *testing.T) { m := NewMetricsManager() defer m.Stop() baseURL := "http://test.com" apiKey := "test-key" now := time.Now() m.mu.Lock() metrics := m.getOrCreateKey(baseURL, apiKey) // 添加 2 个成功和 1 个失败 metrics.requestHistory = append(metrics.requestHistory, RequestRecord{Timestamp: now, Success: true}, RequestRecord{Timestamp: now, Success: true}, RequestRecord{Timestamp: now, Success: false}, ) m.mu.Unlock() result := m.GetRecentActivityMultiURL(0, []string{baseURL}, []string{apiKey}) // 找到有数据的 segment var foundSeg *ActivitySegment for i := range result.Segments { if result.Segments[i].RequestCount > 0 { foundSeg = &result.Segments[i] break } } if foundSeg == nil { t.Fatal("expected to find a segment with data") } if foundSeg.RequestCount != 3 { t.Errorf("expected 3 requests, got %d", foundSeg.RequestCount) } if foundSeg.SuccessCount != 2 { t.Errorf("expected 2 successes, got %d", foundSeg.SuccessCount) } if foundSeg.FailureCount != 1 { t.Errorf("expected 1 failure, got %d", foundSeg.FailureCount) } } func TestGetRecentActivityMultiURL_MultipleURLs(t *testing.T) { m := NewMetricsManager() defer m.Stop() baseURL1 := "http://test1.com" baseURL2 := "http://test2.com" apiKey := "test-key" now := time.Now() m.mu.Lock() metrics1 := m.getOrCreateKey(baseURL1, apiKey) metrics1.requestHistory = append(metrics1.requestHistory, RequestRecord{ Timestamp: now, Success: true, OutputTokens: 100, }) metrics2 := m.getOrCreateKey(baseURL2, apiKey) metrics2.requestHistory = append(metrics2.requestHistory, RequestRecord{ Timestamp: now, Success: true, OutputTokens: 200, }) m.mu.Unlock() result := m.GetRecentActivityMultiURL(0, []string{baseURL1, baseURL2}, []string{apiKey}) // 验证聚合了两个 URL 的数据 var totalRequests int64 for _, seg := range result.Segments { totalRequests += seg.RequestCount } if totalRequests != 2 { t.Errorf("expected 2 total requests from 2 URLs, got %d", totalRequests) } // TPM 应该是 (100 + 200) / 15 expectedTPM := 300.0 / 15.0 if !floatEquals(result.TPM, expectedTPM, 0.0001) { t.Errorf("expected TPM %.4f, got %.4f", expectedTPM, result.TPM) } } func TestGetRecentActivityMultiURL_MultipleKeys(t *testing.T) { m := NewMetricsManager() defer m.Stop() baseURL := "http://test.com" apiKey1 := "test-key-1" apiKey2 := "test-key-2" now := time.Now() m.mu.Lock() metrics1 := m.getOrCreateKey(baseURL, apiKey1) metrics1.requestHistory = append(metrics1.requestHistory, RequestRecord{ Timestamp: now, Success: true, OutputTokens: 150, }) metrics2 := m.getOrCreateKey(baseURL, apiKey2) metrics2.requestHistory = append(metrics2.requestHistory, RequestRecord{ Timestamp: now, Success: true, OutputTokens: 250, }) m.mu.Unlock() result := m.GetRecentActivityMultiURL(0, []string{baseURL}, []string{apiKey1, apiKey2}) // 验证聚合了两个 Key 的数据 var totalRequests int64 for _, seg := range result.Segments { totalRequests += seg.RequestCount } if totalRequests != 2 { t.Errorf("expected 2 total requests from 2 Keys, got %d", totalRequests) } // TPM 应该是 (150 + 250) / 15 expectedTPM := 400.0 / 15.0 if !floatEquals(result.TPM, expectedTPM, 0.0001) { t.Errorf("expected TPM %.4f, got %.4f", expectedTPM, result.TPM) } } func TestGetRecentActivityMultiURL_MultipleURLsAndKeys(t *testing.T) { m := NewMetricsManager() defer m.Stop() baseURL1 := "http://test1.com" baseURL2 := "http://test2.com" apiKey1 := "test-key-1" apiKey2 := "test-key-2" now := time.Now() m.mu.Lock() // URL1 + Key1 metrics11 := m.getOrCreateKey(baseURL1, apiKey1) metrics11.requestHistory = append(metrics11.requestHistory, RequestRecord{ Timestamp: now, Success: true, OutputTokens: 100, }) // URL1 + Key2 metrics12 := m.getOrCreateKey(baseURL1, apiKey2) metrics12.requestHistory = append(metrics12.requestHistory, RequestRecord{ Timestamp: now, Success: true, OutputTokens: 200, }) // URL2 + Key1 metrics21 := m.getOrCreateKey(baseURL2, apiKey1) metrics21.requestHistory = append(metrics21.requestHistory, RequestRecord{ Timestamp: now, Success: false, OutputTokens: 150, }) // URL2 + Key2 metrics22 := m.getOrCreateKey(baseURL2, apiKey2) metrics22.requestHistory = append(metrics22.requestHistory, RequestRecord{ Timestamp: now, Success: true, OutputTokens: 250, }) m.mu.Unlock() result := m.GetRecentActivityMultiURL(0, []string{baseURL1, baseURL2}, []string{apiKey1, apiKey2}) // 验证聚合了所有 URL × Key 组合的数据(2×2=4 个请求) var totalRequests int64 var totalFailures int64 for _, seg := range result.Segments { totalRequests += seg.RequestCount totalFailures += seg.FailureCount } if totalRequests != 4 { t.Errorf("expected 4 total requests from 2 URLs × 2 Keys, got %d", totalRequests) } if totalFailures != 1 { t.Errorf("expected 1 failure, got %d", totalFailures) } // TPM 应该是 (100 + 200 + 150 + 250) / 15 expectedTPM := 700.0 / 15.0 if !floatEquals(result.TPM, expectedTPM, 0.0001) { t.Errorf("expected TPM %.4f, got %.4f", expectedTPM, result.TPM) } // RPM 应该是 4 / 15 expectedRPM := 4.0 / 15.0 if !floatEquals(result.RPM, expectedRPM, 0.0001) { t.Errorf("expected RPM %.4f, got %.4f", expectedRPM, result.RPM) } } ================================================ FILE: backend-go/internal/metrics/channel_metrics_cache_stats_test.go ================================================ package metrics import ( "math" "testing" "github.com/BenedictKing/claude-proxy/internal/types" ) func TestToResponse_TimeWindowsIncludesCacheStats(t *testing.T) { m := NewMetricsManagerWithConfig(10, 0.5) baseURL := "https://example.com" key1 := "k1" key2 := "k2" m.RecordSuccessWithUsage(baseURL, key1, &types.Usage{ InputTokens: 100, OutputTokens: 10, CacheCreationInputTokens: 20, CacheReadInputTokens: 50, }) m.RecordSuccessWithUsage(baseURL, key2, &types.Usage{ InputTokens: 200, OutputTokens: 20, }) resp := m.ToResponse(0, baseURL, []string{key1, key2}, 0) stats, ok := resp.TimeWindows["15m"] if !ok { t.Fatalf("expected timeWindows[15m] to exist") } if stats.InputTokens != 300 { t.Fatalf("expected inputTokens=300, got %d", stats.InputTokens) } if stats.OutputTokens != 30 { t.Fatalf("expected outputTokens=30, got %d", stats.OutputTokens) } if stats.CacheCreationTokens != 20 { t.Fatalf("expected cacheCreationTokens=20, got %d", stats.CacheCreationTokens) } if stats.CacheReadTokens != 50 { t.Fatalf("expected cacheReadTokens=50, got %d", stats.CacheReadTokens) } wantHitRate := float64(50) / float64(50+300) * 100 if math.Abs(stats.CacheHitRate-wantHitRate) > 0.01 { t.Fatalf("expected cacheHitRate=%.4f, got %.4f", wantHitRate, stats.CacheHitRate) } } func TestRecordSuccessWithUsage_CacheCreationFallbackFromTTLBreakdown(t *testing.T) { m := NewMetricsManagerWithConfig(10, 0.5) baseURL := "https://example.com" key := "k1" // 上游有时只返回 TTL 细分字段(5m/1h),不返回 cache_creation_input_tokens。 m.RecordSuccessWithUsage(baseURL, key, &types.Usage{ InputTokens: 100, OutputTokens: 10, CacheCreationInputTokens: 0, CacheCreation5mInputTokens: 20, CacheCreation1hInputTokens: 30, CacheReadInputTokens: 50, }) resp := m.ToResponse(0, baseURL, []string{key}, 0) stats, ok := resp.TimeWindows["15m"] if !ok { t.Fatalf("expected timeWindows[15m] to exist") } if stats.CacheCreationTokens != 50 { t.Fatalf("expected cacheCreationTokens=50, got %d", stats.CacheCreationTokens) } if stats.CacheReadTokens != 50 { t.Fatalf("expected cacheReadTokens=50, got %d", stats.CacheReadTokens) } } ================================================ FILE: backend-go/internal/metrics/persistence.go ================================================ package metrics import ( "time" ) // PersistenceStore 持久化存储接口 type PersistenceStore interface { // AddRecord 添加记录到写入缓冲区(非阻塞) AddRecord(record PersistentRecord) // LoadRecords 加载指定时间范围内的记录 LoadRecords(since time.Time, apiType string) ([]PersistentRecord, error) // CleanupOldRecords 清理过期数据 CleanupOldRecords(before time.Time) (int64, error) // DeleteRecordsByMetricsKeys 按 metrics_key 和 api_type 批量删除记录(用于删除渠道时清理数据) // apiType: 接口类型(messages/responses/gemini),避免误删其他接口的数据 DeleteRecordsByMetricsKeys(metricsKeys []string, apiType string) (int64, error) // Close 关闭存储(会先刷新缓冲区) Close() error } // PersistentRecord 持久化记录结构 type PersistentRecord struct { MetricsKey string // hash(baseURL + apiKey) BaseURL string // 上游 BaseURL KeyMask string // 脱敏的 API Key Timestamp time.Time // 请求时间 Success bool // 是否成功 InputTokens int64 // 输入 Token 数 OutputTokens int64 // 输出 Token 数 CacheCreationTokens int64 // 缓存创建 Token CacheReadTokens int64 // 缓存读取 Token APIType string // "messages"、"responses" 或 "gemini" } ================================================ FILE: backend-go/internal/metrics/sqlite_store.go ================================================ package metrics import ( "database/sql" "fmt" "log" "os" "path/filepath" "strings" "sync" "time" _ "modernc.org/sqlite" ) // SQLiteStore SQLite 持久化存储 type SQLiteStore struct { db *sql.DB dbPath string // 写入缓冲区 writeBuffer []PersistentRecord bufferMu sync.Mutex // 配置 batchSize int // 批量写入阈值(记录数) flushInterval time.Duration // 定时刷新间隔 retentionDays int // 数据保留天数 // 控制 stopCh chan struct{} wg sync.WaitGroup closed bool // 是否已关闭 flushMu sync.Mutex // 串行化 flush 与 delete 操作,避免并发竞态 asyncFlushWg sync.WaitGroup // 追踪 AddRecord 触发的异步 flush goroutine } // SQLiteStoreConfig SQLite 存储配置 type SQLiteStoreConfig struct { DBPath string // 数据库文件路径 RetentionDays int // 数据保留天数(3-30) } // 硬编码的内部配置 const ( defaultBatchSize = 100 // 批量写入阈值 defaultFlushInterval = 30 * time.Second // 定时刷新间隔 ) // NewSQLiteStore 创建 SQLite 存储 func NewSQLiteStore(cfg *SQLiteStoreConfig) (*SQLiteStore, error) { if cfg == nil { cfg = &SQLiteStoreConfig{ DBPath: ".config/metrics.db", RetentionDays: 7, } } // 验证保留天数范围 if cfg.RetentionDays < 3 { cfg.RetentionDays = 3 } else if cfg.RetentionDays > 30 { cfg.RetentionDays = 30 } // 确保目录存在 dir := filepath.Dir(cfg.DBPath) if err := os.MkdirAll(dir, 0755); err != nil { return nil, fmt.Errorf("创建数据库目录失败: %w", err) } // 打开数据库连接(WAL 模式 + NORMAL 同步) // modernc.org/sqlite 使用 _pragma= 语法设置 PRAGMA dsn := cfg.DBPath + "?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=busy_timeout(5000)" db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("打开数据库失败: %w", err) } // 设置连接池参数 db.SetMaxOpenConns(1) // SQLite 单写入连接 db.SetMaxIdleConns(1) db.SetConnMaxLifetime(0) // 不限制连接生命周期 // 初始化表结构 if err := initSchema(db); err != nil { db.Close() return nil, fmt.Errorf("初始化数据库 schema 失败: %w", err) } store := &SQLiteStore{ db: db, dbPath: cfg.DBPath, writeBuffer: make([]PersistentRecord, 0, defaultBatchSize), batchSize: defaultBatchSize, flushInterval: defaultFlushInterval, retentionDays: cfg.RetentionDays, stopCh: make(chan struct{}), } // 启动后台任务 store.wg.Add(2) go store.flushLoop() go store.cleanupLoop() log.Printf("[SQLite-Init] 指标存储已初始化: %s (保留 %d 天)", cfg.DBPath, cfg.RetentionDays) return store, nil } // initSchema 初始化数据库表结构 func initSchema(db *sql.DB) error { schema := ` -- 请求记录表 CREATE TABLE IF NOT EXISTS request_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, metrics_key TEXT NOT NULL, base_url TEXT NOT NULL, key_mask TEXT NOT NULL, timestamp INTEGER NOT NULL, success INTEGER NOT NULL, input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0, cache_creation_tokens INTEGER DEFAULT 0, cache_read_tokens INTEGER DEFAULT 0, api_type TEXT NOT NULL DEFAULT 'messages' ); -- 索引:按 api_type 和时间查询 CREATE INDEX IF NOT EXISTS idx_records_api_type_timestamp ON request_records(api_type, timestamp); -- 索引:按 metrics_key 查询 CREATE INDEX IF NOT EXISTS idx_records_metrics_key ON request_records(metrics_key); ` _, err := db.Exec(schema) return err } // AddRecord 添加记录到写入缓冲区(非阻塞) func (s *SQLiteStore) AddRecord(record PersistentRecord) { s.bufferMu.Lock() if s.closed { s.bufferMu.Unlock() return // 已关闭,忽略新记录 } s.writeBuffer = append(s.writeBuffer, record) shouldFlush := len(s.writeBuffer) >= s.batchSize s.bufferMu.Unlock() if shouldFlush { s.asyncFlushWg.Add(1) go func() { defer s.asyncFlushWg.Done() // 获取 flush 锁,与 DeleteRecordsByMetricsKeys 串行化 s.flushMu.Lock() s.flush() s.flushMu.Unlock() }() } } // flush 刷新缓冲区到数据库 func (s *SQLiteStore) flush() { s.bufferMu.Lock() if len(s.writeBuffer) == 0 { s.bufferMu.Unlock() return } // 取出缓冲区数据 records := s.writeBuffer s.writeBuffer = make([]PersistentRecord, 0, s.batchSize) s.bufferMu.Unlock() // 批量写入 if err := s.batchInsertRecords(records); err != nil { log.Printf("[SQLite-Flush] 警告: 批量写入指标记录失败: %v", err) // 失败时将记录放回缓冲区(限制重试,避免无限增长) s.bufferMu.Lock() if len(s.writeBuffer) < s.batchSize*10 { // 最多保留 10 倍缓冲 s.writeBuffer = append(records, s.writeBuffer...) } else { log.Printf("[SQLite-Flush] 警告: 写入缓冲区已满,丢弃 %d 条记录", len(records)) } s.bufferMu.Unlock() } } // batchInsertRecords 批量插入记录 func (s *SQLiteStore) batchInsertRecords(records []PersistentRecord) error { if len(records) == 0 { return nil } tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() stmt, err := tx.Prepare(` INSERT INTO request_records (metrics_key, base_url, key_mask, timestamp, success, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, api_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) if err != nil { return err } defer stmt.Close() for _, r := range records { success := 0 if r.Success { success = 1 } _, err := stmt.Exec( r.MetricsKey, r.BaseURL, r.KeyMask, r.Timestamp.Unix(), success, r.InputTokens, r.OutputTokens, r.CacheCreationTokens, r.CacheReadTokens, r.APIType, ) if err != nil { return err } } return tx.Commit() } // LoadRecords 加载指定时间范围内的记录 func (s *SQLiteStore) LoadRecords(since time.Time, apiType string) ([]PersistentRecord, error) { rows, err := s.db.Query(` SELECT metrics_key, base_url, key_mask, timestamp, success, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens FROM request_records WHERE timestamp >= ? AND api_type = ? ORDER BY timestamp ASC `, since.Unix(), apiType) if err != nil { return nil, err } defer rows.Close() var records []PersistentRecord for rows.Next() { var r PersistentRecord var ts int64 var success int err := rows.Scan( &r.MetricsKey, &r.BaseURL, &r.KeyMask, &ts, &success, &r.InputTokens, &r.OutputTokens, &r.CacheCreationTokens, &r.CacheReadTokens, ) if err != nil { return nil, err } r.Timestamp = time.Unix(ts, 0) r.Success = success == 1 r.APIType = apiType records = append(records, r) } return records, rows.Err() } // CleanupOldRecords 清理过期数据 func (s *SQLiteStore) CleanupOldRecords(before time.Time) (int64, error) { result, err := s.db.Exec( "DELETE FROM request_records WHERE timestamp < ?", before.Unix(), ) if err != nil { return 0, err } return result.RowsAffected() } // DeleteRecordsByMetricsKeys 按 metrics_key 和 api_type 批量删除记录 // apiType: 接口类型(messages/responses/gemini),避免误删其他接口的数据 func (s *SQLiteStore) DeleteRecordsByMetricsKeys(metricsKeys []string, apiType string) (int64, error) { if len(metricsKeys) == 0 { return 0, nil } // 获取 flush 锁,确保删除期间不会有后台 flush 写入新记录 s.flushMu.Lock() defer s.flushMu.Unlock() // 先刷新缓冲区,确保待删除的记录已写入数据库 s.flush() // 分批删除,避免触发 SQLite 变量上限(默认 999) const batchSize = 500 var totalDeleted int64 for i := 0; i < len(metricsKeys); i += batchSize { end := i + batchSize if end > len(metricsKeys) { end = len(metricsKeys) } batch := metricsKeys[i:end] // 构建 IN 子句的占位符 placeholders := make([]string, len(batch)) args := make([]interface{}, 0, len(batch)+1) args = append(args, apiType) // 第一个参数是 api_type for j, key := range batch { placeholders[j] = "?" args = append(args, key) } query := fmt.Sprintf( "DELETE FROM request_records WHERE api_type = ? AND metrics_key IN (%s)", strings.Join(placeholders, ","), ) result, err := s.db.Exec(query, args...) if err != nil { return totalDeleted, fmt.Errorf("batch %d-%d failed: %w", i, end, err) } affected, _ := result.RowsAffected() totalDeleted += affected } return totalDeleted, nil } // flushLoop 定时刷新循环 func (s *SQLiteStore) flushLoop() { defer s.wg.Done() ticker := time.NewTicker(s.flushInterval) defer ticker.Stop() for { select { case <-ticker.C: // 获取 flush 锁,与 DeleteRecordsByMetricsKeys 串行化 s.flushMu.Lock() s.flush() s.flushMu.Unlock() case <-s.stopCh: // 关闭前最后一次刷新 s.flushMu.Lock() s.flush() s.flushMu.Unlock() return } } } // cleanupLoop 定期清理循环 func (s *SQLiteStore) cleanupLoop() { defer s.wg.Done() // 启动时先清理一次 s.doCleanup() ticker := time.NewTicker(1 * time.Hour) defer ticker.Stop() for { select { case <-ticker.C: s.doCleanup() case <-s.stopCh: return } } } // doCleanup 执行清理 func (s *SQLiteStore) doCleanup() { cutoff := time.Now().AddDate(0, 0, -s.retentionDays) deleted, err := s.CleanupOldRecords(cutoff) if err != nil { log.Printf("[SQLite-Cleanup] 警告: 清理过期指标记录失败: %v", err) } else if deleted > 0 { log.Printf("[SQLite-Cleanup] 已清理 %d 条过期指标记录(超过 %d 天)", deleted, s.retentionDays) } } // Close 关闭存储 func (s *SQLiteStore) Close() error { // 标记为已关闭,阻止新记录 s.bufferMu.Lock() s.closed = true s.bufferMu.Unlock() // 停止后台循环(flushLoop 会在退出前执行最后一次 flush) close(s.stopCh) s.wg.Wait() // 等待所有 AddRecord 触发的异步 flush goroutine 完成 s.asyncFlushWg.Wait() return s.db.Close() } // GetRecordCount 获取记录总数(用于调试) func (s *SQLiteStore) GetRecordCount() (int64, error) { var count int64 err := s.db.QueryRow("SELECT COUNT(*) FROM request_records").Scan(&count) return count, err } ================================================ FILE: backend-go/internal/middleware/auth.go ================================================ package middleware import ( "log" "strings" "time" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/gin-gonic/gin" ) // WebAuthMiddleware Web 访问控制中间件 func WebAuthMiddleware(envCfg *config.EnvConfig, cfgManager *config.ConfigManager) gin.HandlerFunc { return func(c *gin.Context) { path := c.Request.URL.Path // 公开端点直接放行(健康检查固定为 /health) if path == "/health" { c.Next() return } // 静态资源文件直接放行 if isStaticResource(path) { c.Next() return } // API 代理端点后续处理 if strings.HasPrefix(path, "/v1/") || strings.HasPrefix(path, "/v1beta/") { c.Next() return } // 如果禁用了 Web UI,返回 404 if !envCfg.EnableWebUI { c.JSON(404, gin.H{ "error": "Web界面已禁用", "message": "此服务器运行在纯API模式下,请通过API端点访问服务", }) c.Abort() return } // SPA 页面路由直接交给前端处理,但需要排除 /api* 路径 if path == "/" || path == "/index.html" || (!strings.Contains(path, ".") && !strings.HasPrefix(path, "/api") && !strings.HasPrefix(path, "/admin")) { c.Next() return } // 检查访问密钥(管理 API + 管理端点) if strings.HasPrefix(path, "/api") || strings.HasPrefix(path, "/admin") { providedKey := getAPIKey(c) expectedKey := envCfg.ProxyAccessKey // 记录认证尝试 clientIP := c.ClientIP() timestamp := time.Now().Format(time.RFC3339) if providedKey == "" || providedKey != expectedKey { // 认证失败 - 记录详细日志 reason := "密钥无效" if providedKey == "" { reason = "密钥缺失" } log.Printf("[Auth-Failed] IP: %s | Path: %s | Time: %s | Reason: %s", clientIP, path, timestamp, reason) c.JSON(401, gin.H{ "error": "Unauthorized", "message": "Invalid or missing access key", }) c.Abort() return } // 认证成功 - 记录日志(可选,根据日志级别) // 如果启用了 QuietPollingLogs,则静默轮询端点日志 if envCfg.ShouldLog("info") && !(envCfg.QuietPollingLogs && isPollingEndpoint(path)) { log.Printf("[Auth-Success] IP: %s | Path: %s | Time: %s", clientIP, path, timestamp) } } c.Next() } } // isPollingEndpoint 判断是否为轮询端点(前缀匹配,兼容 query string 和尾部斜杠) // 复用 defaultSkipPrefixes 保持与 FilteredLogger 一致 func isPollingEndpoint(path string) bool { // 移除 query string if idx := strings.Index(path, "?"); idx != -1 { path = path[:idx] } // 移除尾部斜杠 path = strings.TrimSuffix(path, "/") // 复用 logger.go 中的 defaultSkipPrefixes for _, prefix := range defaultSkipPrefixes { if strings.HasPrefix(path, prefix) { return true } } return false } // isStaticResource 判断是否为静态资源 func isStaticResource(path string) bool { staticExtensions := []string{ "/assets/", ".css", ".js", ".ico", ".png", ".jpg", ".gif", ".svg", ".woff", ".woff2", ".ttf", ".eot", } for _, ext := range staticExtensions { if strings.HasPrefix(path, ext) || strings.HasSuffix(path, ext) { return true } } return false } // getAPIKey 获取 API 密钥 func getAPIKey(c *gin.Context) string { // 从 header 获取 if key := c.GetHeader("x-api-key"); key != "" { return key } if auth := c.GetHeader("Authorization"); auth != "" { // 移除 Bearer 前缀 return strings.TrimPrefix(auth, "Bearer ") } // 支持 Gemini SDK 的 x-goog-api-key 头部 if key := c.GetHeader("x-goog-api-key"); key != "" { return key } return "" } // ProxyAuthMiddleware 代理访问控制中间件 func ProxyAuthMiddleware(envCfg *config.EnvConfig) gin.HandlerFunc { return func(c *gin.Context) { providedKey := getAPIKey(c) expectedKey := envCfg.ProxyAccessKey if providedKey == "" || providedKey != expectedKey { if envCfg.ShouldLog("warn") { log.Printf("[Auth-Failed] 代理访问密钥验证失败 - IP: %s", c.ClientIP()) } c.JSON(401, gin.H{ "error": "Invalid proxy access key", }) c.Abort() return } c.Next() } } ================================================ FILE: backend-go/internal/middleware/auth_test.go ================================================ package middleware import ( "net/http" "net/http/httptest" "testing" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/gin-gonic/gin" ) // setupRouterWithAuth builds a minimal router with the auth middleware wired. func setupRouterWithAuth(envCfg *config.EnvConfig) *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() r.Use(WebAuthMiddleware(envCfg, nil)) // Protected management API r.GET("/api/channels", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) // Protected admin endpoint r.POST("/admin/config/save", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) r.GET("/admin/dev/info", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) // SPA routes should pass through without access key r.GET("/", func(c *gin.Context) { c.String(http.StatusOK, "home") }) r.GET("/dashboard", func(c *gin.Context) { c.String(http.StatusOK, "dashboard") }) return r } func TestWebAuthMiddleware_APIRequiresKey(t *testing.T) { envCfg := &config.EnvConfig{ ProxyAccessKey: "secret-key", EnableWebUI: true, } router := setupRouterWithAuth(envCfg) t.Run("missing key returns 401", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/channels", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized) } }) t.Run("wrong key returns 401", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/channels", nil) req.Header.Set("x-api-key", "wrong") w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized) } }) t.Run("correct key allows access", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/channels", nil) req.Header.Set("x-api-key", envCfg.ProxyAccessKey) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) } }) } func TestWebAuthMiddleware_SPAPassesThrough(t *testing.T) { envCfg := &config.EnvConfig{ ProxyAccessKey: "secret-key", EnableWebUI: true, } router := setupRouterWithAuth(envCfg) req := httptest.NewRequest(http.MethodGet, "/dashboard", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) } } func TestWebAuthMiddleware_AdminRequiresKey(t *testing.T) { envCfg := &config.EnvConfig{ ProxyAccessKey: "secret-key", EnableWebUI: true, } router := setupRouterWithAuth(envCfg) t.Run("missing key returns 401", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/admin/config/save", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized) } }) t.Run("correct key allows access", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/admin/config/save", nil) req.Header.Set("x-api-key", envCfg.ProxyAccessKey) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) } }) } func TestWebAuthMiddleware_DevInfoRequiresKeyInDevelopment(t *testing.T) { envCfg := &config.EnvConfig{ Env: "development", ProxyAccessKey: "secret-key", EnableWebUI: true, } router := setupRouterWithAuth(envCfg) t.Run("missing key returns 401", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/admin/dev/info", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized) } }) t.Run("correct key allows access", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/admin/dev/info", nil) req.Header.Set("x-api-key", envCfg.ProxyAccessKey) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) } }) } func TestWebAuthMiddleware_AllowsV1BetaRoutesWhenWebUIDisabled(t *testing.T) { envCfg := &config.EnvConfig{ ProxyAccessKey: "secret-key", EnableWebUI: false, } gin.SetMode(gin.TestMode) r := gin.New() r.Use(WebAuthMiddleware(envCfg, nil)) r.POST("/v1beta/models/*modelAction", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.0-flash:generateContent", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) } } ================================================ FILE: backend-go/internal/middleware/cors.go ================================================ package middleware import ( "strings" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/gin-gonic/gin" ) // CORSMiddleware CORS 中间件 func CORSMiddleware(envCfg *config.EnvConfig) gin.HandlerFunc { return func(c *gin.Context) { // 如果未启用 CORS,直接跳过 if !envCfg.EnableCORS { c.Next() return } origin := c.GetHeader("Origin") // 开发环境允许所有 localhost 源 if envCfg.IsDevelopment() { if origin != "" && strings.Contains(origin, "localhost") { c.Header("Access-Control-Allow-Origin", origin) } } else { // 生产环境使用配置的源 c.Header("Access-Control-Allow-Origin", envCfg.CORSOrigin) } c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, x-api-key, x-goog-api-key") // 仅在非 * 时设置 credentials,避免浏览器拒绝 credentials + * 组合 if envCfg.CORSOrigin != "*" { c.Header("Access-Control-Allow-Credentials", "true") } // 处理预检请求 if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) return } c.Next() } } ================================================ FILE: backend-go/internal/middleware/logger.go ================================================ package middleware import ( "net/http" "strings" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/gin-gonic/gin" ) // 默认跳过日志的路径前缀(仅 GET 请求) var defaultSkipPrefixes = []string{ "/api/messages/channels", "/api/responses/channels", "/api/gemini/channels", "/api/messages/global/stats", "/api/responses/global/stats", "/api/gemini/global/stats", } // FilteredLogger 创建一个可过滤路径的 Logger 中间件 // 仅对 GET 请求且匹配 skipPrefixes 前缀的路径跳过日志输出 // POST/PUT/DELETE 等管理操作始终记录日志以保留审计跟踪 func FilteredLogger(envCfg *config.EnvConfig, skipPrefixes ...string) gin.HandlerFunc { // 如果 QuietPollingLogs 为 false,使用标准 Logger if !envCfg.QuietPollingLogs { return gin.Logger() } if len(skipPrefixes) == 0 { skipPrefixes = defaultSkipPrefixes } return gin.LoggerWithConfig(gin.LoggerConfig{ Skip: func(c *gin.Context) bool { // 只跳过 GET 请求,保留其他方法的审计日志 if c.Request.Method != http.MethodGet { return false } path := c.Request.URL.Path for _, prefix := range skipPrefixes { if strings.HasPrefix(path, prefix) { return true } } return false }, }) } ================================================ FILE: backend-go/internal/providers/claude.go ================================================ package providers import ( "bufio" "bytes" "encoding/json" "io" "net/http" "regexp" "strings" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/types" "github.com/BenedictKing/claude-proxy/internal/utils" "github.com/gin-gonic/gin" ) // ClaudeProvider Claude 提供商(直接透传) type ClaudeProvider struct{} // redirectModelInBody 仅修改请求体中的 model 字段,保持其他内容不变 // 使用 map[string]interface{} 避免结构体字段丢失问题 func redirectModelInBody(bodyBytes []byte, upstream *config.UpstreamConfig) []byte { decoder := json.NewDecoder(bytes.NewReader(bodyBytes)) decoder.UseNumber() // 保留数字精度 var data map[string]interface{} if err := decoder.Decode(&data); err != nil { return bodyBytes // 解析失败,返回原始数据 } model, ok := data["model"].(string) if !ok { return bodyBytes // 没有 model 字段或类型不对 } newModel := config.RedirectModel(model, upstream) if newModel == model { return bodyBytes // 模型未变,无需重编码 } data["model"] = newModel // 使用 Encoder 并禁用 HTML 转义,保持原始格式 newBytes, err := utils.MarshalJSONNoEscape(data) if err != nil { return bodyBytes // 编码失败,返回原始数据 } return newBytes } // ConvertToProviderRequest 转换为 Claude 请求(实现真正的透传) func (p *ClaudeProvider) ConvertToProviderRequest(c *gin.Context, upstream *config.UpstreamConfig, apiKey string) (*http.Request, []byte, error) { // 读取原始请求体 bodyBytes, err := io.ReadAll(c.Request.Body) if err != nil { return nil, nil, err } c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes)) // 恢复body // 模型重定向:仅修改 model 字段,保持其他内容不变 if upstream.ModelMapping != nil && len(upstream.ModelMapping) > 0 { bodyBytes = redirectModelInBody(bodyBytes, upstream) } // 构建目标URL // 智能拼接逻辑: // 1. 如果 baseURL 以 # 结尾,跳过自动添加 /v1 // 2. 如果 baseURL 已包含版本号后缀(如 /v1, /v2, /v3),直接拼接端点路径 // 3. 如果 baseURL 不包含版本号后缀,自动添加 /v1 再拼接端点路径 endpoint := strings.TrimPrefix(c.Request.URL.Path, "/v1") baseURL := upstream.GetEffectiveBaseURL() skipVersionPrefix := strings.HasSuffix(baseURL, "#") if skipVersionPrefix { baseURL = strings.TrimSuffix(baseURL, "#") } baseURL = strings.TrimSuffix(baseURL, "/") // 使用正则表达式检测 baseURL 是否以版本号结尾(/v1, /v2, /v1beta, /v2alpha等) versionPattern := regexp.MustCompile(`/v\d+[a-z]*$`) var targetURL string if versionPattern.MatchString(baseURL) || skipVersionPrefix { // baseURL 已包含版本号或以#结尾,直接拼接 targetURL = baseURL + endpoint } else { // baseURL 不包含版本号,添加 /v1 targetURL = baseURL + "/v1" + endpoint } if c.Request.URL.RawQuery != "" { targetURL += "?" + c.Request.URL.RawQuery } // 创建请求 var req *http.Request if len(bodyBytes) > 0 { req, err = http.NewRequestWithContext(c.Request.Context(), c.Request.Method, targetURL, bytes.NewReader(bodyBytes)) } else { // 如果 bodyBytes 为空(例如 GET 请求或原始请求体为空),则直接使用 nil Body req, err = http.NewRequestWithContext(c.Request.Context(), c.Request.Method, targetURL, nil) } if err != nil { return nil, nil, err } // 使用统一的头部处理逻辑 req.Header = utils.PrepareUpstreamHeaders(c, req.URL.Host) utils.SetAuthenticationHeader(req.Header, apiKey) utils.EnsureCompatibleUserAgent(req.Header, "claude") return req, bodyBytes, nil } // ConvertToClaudeResponse 转换为 Claude 响应(直接透传) func (p *ClaudeProvider) ConvertToClaudeResponse(providerResp *types.ProviderResponse) (*types.ClaudeResponse, error) { var claudeResp types.ClaudeResponse if err := json.Unmarshal(providerResp.Body, &claudeResp); err != nil { return nil, err } return &claudeResp, nil } // HandleStreamResponse 处理流式响应(直接透传) func (p *ClaudeProvider) HandleStreamResponse(body io.ReadCloser) (<-chan string, <-chan error, error) { eventChan := make(chan string, 100) errChan := make(chan error, 1) go func() { defer close(eventChan) defer close(errChan) defer body.Close() scanner := bufio.NewScanner(body) // 设置更大的 buffer (1MB) 以处理大 JSON chunk,避免默认 64KB 限制 const maxScannerBufferSize = 1024 * 1024 // 1MB scanner.Buffer(make([]byte, 0, 64*1024), maxScannerBufferSize) toolUseStopEmitted := false // 注意:为了让下游的 token 注入/修补逻辑保持正确,这里必须按「完整 SSE 事件」转发。 // 上游以空行分隔事件:event/data/id/retry/... + "\n",空行 => 事件结束。 var eventBuf strings.Builder flushEvent := func() { if eventBuf.Len() == 0 { return } eventChan <- eventBuf.String() eventBuf.Reset() } for scanner.Scan() { line := scanner.Text() // 检测是否发送了 tool_use 相关的 stop_reason(通常在 data 行中) if strings.Contains(line, `"stop_reason":"tool_use"`) || strings.Contains(line, `"stop_reason": "tool_use"`) { toolUseStopEmitted = true } // 透传所有 SSE 字段(包括注释、id、retry 等) eventBuf.WriteString(line) eventBuf.WriteString("\n") // 空行表示一个 SSE event 结束 if line == "" { flushEvent() } } // 若上游未以空行结尾,仍尝试把最后的残留事件发出去 flushEvent() if err := scanner.Err(); err != nil { // 在 tool_use 场景下,客户端主动断开是正常行为 // 如果已经发送了 tool_use stop 事件,并且错误是连接断开相关的,则忽略该错误 errMsg := err.Error() if toolUseStopEmitted && (strings.Contains(errMsg, "broken pipe") || strings.Contains(errMsg, "connection reset") || strings.Contains(errMsg, "EOF")) { // 这是预期的客户端行为,不报告错误 return } errChan <- err } }() return eventChan, errChan, nil } ================================================ FILE: backend-go/internal/providers/gemini.go ================================================ package providers import ( "bufio" "bytes" "encoding/json" "fmt" "io" "net/http" "strings" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/types" "github.com/BenedictKing/claude-proxy/internal/utils" "github.com/gin-gonic/gin" ) // GeminiProvider Gemini 提供商 type GeminiProvider struct{} // ConvertToProviderRequest 转换为 Gemini 请求 func (p *GeminiProvider) ConvertToProviderRequest(c *gin.Context, upstream *config.UpstreamConfig, apiKey string) (*http.Request, []byte, error) { // 读取和解析原始请求体 originalBodyBytes, err := io.ReadAll(c.Request.Body) if err != nil { return nil, nil, fmt.Errorf("读取请求体失败: %w", err) } // 恢复请求体,以便gin context可以被其他地方再次读取(尽管这里我们已经完全处理了) c.Request.Body = io.NopCloser(bytes.NewReader(originalBodyBytes)) var claudeReq types.ClaudeRequest if err := json.Unmarshal(originalBodyBytes, &claudeReq); err != nil { return nil, originalBodyBytes, fmt.Errorf("解析Claude请求体失败: %w", err) } // --- 复用旧的转换逻辑 --- geminiReq := p.convertToGeminiRequest(&claudeReq, upstream) // --- 转换逻辑结束 --- reqBodyBytes, err := json.Marshal(geminiReq) if err != nil { return nil, originalBodyBytes, fmt.Errorf("序列化Gemini请求体失败: %w", err) } model := config.RedirectModel(claudeReq.Model, upstream) action := "generateContent" if claudeReq.Stream { action = "streamGenerateContent?alt=sse" } url := fmt.Sprintf("%s/models/%s:%s", strings.TrimSuffix(upstream.GetEffectiveBaseURL(), "/"), model, action) req, err := http.NewRequestWithContext(c.Request.Context(), "POST", url, bytes.NewReader(reqBodyBytes)) if err != nil { return nil, originalBodyBytes, fmt.Errorf("创建Gemini请求失败: %w", err) } // 使用统一的头部处理逻辑(透明代理) // 保留客户端的大部分 headers,只移除/替换必要的认证和代理相关 headers req.Header = utils.PrepareUpstreamHeaders(c, req.URL.Host) utils.SetGeminiAuthenticationHeader(req.Header, apiKey) return req, originalBodyBytes, nil } // convertToGeminiRequest 转换为 Gemini 请求体 func (p *GeminiProvider) convertToGeminiRequest(claudeReq *types.ClaudeRequest, upstream *config.UpstreamConfig) map[string]interface{} { req := map[string]interface{}{ "contents": p.convertMessages(claudeReq.Messages), } // 添加系统指令 if claudeReq.System != nil { systemText := extractSystemText(claudeReq.System) if systemText != "" { req["systemInstruction"] = map[string]interface{}{ "parts": []map[string]string{ {"text": systemText}, }, } } } // 生成配置 genConfig := map[string]interface{}{} if claudeReq.MaxTokens > 0 { genConfig["maxOutputTokens"] = claudeReq.MaxTokens } if claudeReq.Temperature > 0 { genConfig["temperature"] = claudeReq.Temperature } if len(genConfig) > 0 { req["generationConfig"] = genConfig } // 工具 if len(claudeReq.Tools) > 0 { req["tools"] = []map[string]interface{}{ { "functionDeclarations": p.convertTools(claudeReq.Tools), }, } } return req } // convertMessages 转换消息 func (p *GeminiProvider) convertMessages(claudeMessages []types.ClaudeMessage) []map[string]interface{} { messages := []map[string]interface{}{} for _, msg := range claudeMessages { geminiMsg := p.convertMessage(msg) if geminiMsg != nil { messages = append(messages, geminiMsg) } } return messages } // convertMessage 转换单个消息 func (p *GeminiProvider) convertMessage(msg types.ClaudeMessage) map[string]interface{} { role := msg.Role if role == "assistant" { role = "model" } parts := []interface{}{} // 处理字符串内容 if str, ok := msg.Content.(string); ok { parts = append(parts, map[string]string{ "text": str, }) return map[string]interface{}{ "role": role, "parts": parts, } } // 处理内容数组 contents, ok := msg.Content.([]interface{}) if !ok { return nil } for _, c := range contents { content, ok := c.(map[string]interface{}) if !ok { continue } contentType, _ := content["type"].(string) switch contentType { case "text": if text, ok := content["text"].(string); ok { parts = append(parts, map[string]string{ "text": text, }) } case "tool_use": name, _ := content["name"].(string) input := content["input"] parts = append(parts, map[string]interface{}{ "functionCall": map[string]interface{}{ "name": name, "args": input, }, }) case "tool_result": toolUseID, _ := content["tool_use_id"].(string) resultContent := content["content"] var response interface{} if str, ok := resultContent.(string); ok { response = map[string]string{"result": str} } else { response = resultContent } parts = append(parts, map[string]interface{}{ "functionResponse": map[string]interface{}{ "name": toolUseID, "response": response, }, }) } } if len(parts) == 0 { return nil } return map[string]interface{}{ "role": role, "parts": parts, } } // convertTools 转换工具 func (p *GeminiProvider) convertTools(claudeTools []types.ClaudeTool) []map[string]interface{} { tools := []map[string]interface{}{} for _, tool := range claudeTools { tools = append(tools, map[string]interface{}{ "name": tool.Name, "description": tool.Description, "parameters": normalizeGeminiParameters(cleanJsonSchema(tool.InputSchema)), }) } return tools } // normalizeGeminiParameters 确保参数 schema 符合 Gemini 要求 // Gemini 要求 functionDeclaration.parameters 必须是 type: "object" 且有 properties 字段 func normalizeGeminiParameters(schema interface{}) map[string]interface{} { // 默认空 schema defaultSchema := map[string]interface{}{ "type": "object", "properties": map[string]interface{}{}, } if schema == nil { return defaultSchema } schemaMap, ok := schema.(map[string]interface{}) if !ok { return defaultSchema } // 确保有 type 字段且为 "object" if _, hasType := schemaMap["type"]; !hasType { schemaMap["type"] = "object" } // 确保有 properties 字段 if _, hasProps := schemaMap["properties"]; !hasProps { schemaMap["properties"] = map[string]interface{}{} } return schemaMap } // ConvertToClaudeResponse 转换为 Claude 响应 func (p *GeminiProvider) ConvertToClaudeResponse(providerResp *types.ProviderResponse) (*types.ClaudeResponse, error) { var geminiResp map[string]interface{} if err := json.Unmarshal(providerResp.Body, &geminiResp); err != nil { return nil, err } claudeResp := &types.ClaudeResponse{ ID: generateID(), Type: "message", Role: "assistant", Content: []types.ClaudeContent{}, } candidates, ok := geminiResp["candidates"].([]interface{}) if !ok || len(candidates) == 0 { return claudeResp, nil } candidate, ok := candidates[0].(map[string]interface{}) if !ok { return claudeResp, nil } content, ok := candidate["content"].(map[string]interface{}) if !ok { return claudeResp, nil } parts, ok := content["parts"].([]interface{}) if !ok { return claudeResp, nil } // 处理各个部分 for _, p := range parts { part, ok := p.(map[string]interface{}) if !ok { continue } // 文本内容 if text, ok := part["text"].(string); ok { claudeResp.Content = append(claudeResp.Content, types.ClaudeContent{ Type: "text", Text: text, }) } // 函数调用 if fc, ok := part["functionCall"].(map[string]interface{}); ok { name, _ := fc["name"].(string) args := fc["args"] claudeResp.Content = append(claudeResp.Content, types.ClaudeContent{ Type: "tool_use", ID: fmt.Sprintf("toolu_%d", len(claudeResp.Content)), Name: name, Input: args, }) } } // 设置停止原因 finishReason, _ := candidate["finishReason"].(string) if strings.Contains(strings.ToLower(finishReason), "stop") { // 检查是否有工具调用 hasToolCall := false for _, c := range claudeResp.Content { if c.Type == "tool_use" { hasToolCall = true break } } if hasToolCall { claudeResp.StopReason = "tool_use" } else { claudeResp.StopReason = "end_turn" } } else if strings.Contains(strings.ToLower(finishReason), "length") { claudeResp.StopReason = "max_tokens" } // 使用统计 if usageMetadata, ok := geminiResp["usageMetadata"].(map[string]interface{}); ok { usage := &types.Usage{} if promptTokens, ok := usageMetadata["promptTokenCount"].(float64); ok { usage.InputTokens = int(promptTokens) } if candidatesTokens, ok := usageMetadata["candidatesTokenCount"].(float64); ok { usage.OutputTokens = int(candidatesTokens) } claudeResp.Usage = usage } return claudeResp, nil } // HandleStreamResponse 处理流式响应 func (p *GeminiProvider) HandleStreamResponse(body io.ReadCloser) (<-chan string, <-chan error, error) { eventChan := make(chan string, 100) errChan := make(chan error, 1) go func() { defer close(eventChan) // defer close(errChan) // 移除此行,避免竞态条件 defer body.Close() scanner := bufio.NewScanner(body) // 设置更大的 buffer (1MB) 以处理大 JSON chunk,避免默认 64KB 限制 const maxScannerBufferSize = 1024 * 1024 // 1MB scanner.Buffer(make([]byte, 0, 64*1024), maxScannerBufferSize) toolUseBlockIndex := 0 // 文本块状态跟踪 textBlockStarted := false textBlockIndex := 0 for scanner.Scan() { line := scanner.Text() line = strings.TrimSpace(line) if line == "" || line == "data: [DONE]" { continue } if !strings.HasPrefix(line, "data: ") { continue } jsonStr := strings.TrimPrefix(line, "data: ") var chunk map[string]interface{} if err := json.Unmarshal([]byte(jsonStr), &chunk); err != nil { continue } candidates, ok := chunk["candidates"].([]interface{}) if !ok || len(candidates) == 0 { continue } candidate, ok := candidates[0].(map[string]interface{}) if !ok { continue } content, ok := candidate["content"].(map[string]interface{}) if !ok { continue } parts, ok := content["parts"].([]interface{}) if !ok { continue } for _, p := range parts { part, ok := p.(map[string]interface{}) if !ok { continue } // 处理文本 if text, ok := part["text"].(string); ok { // 如果是第一个文本块,发送 content_block_start if !textBlockStarted { startEvent := map[string]interface{}{ "type": "content_block_start", "index": textBlockIndex, "content_block": map[string]string{ "type": "text", "text": "", }, } startJSON, _ := json.Marshal(startEvent) eventChan <- fmt.Sprintf("event: content_block_start\ndata: %s\n\n", startJSON) textBlockStarted = true } // 发送 content_block_delta deltaEvent := map[string]interface{}{ "type": "content_block_delta", "index": textBlockIndex, "delta": map[string]string{ "type": "text_delta", "text": text, }, } deltaJSON, _ := json.Marshal(deltaEvent) eventChan <- fmt.Sprintf("event: content_block_delta\ndata: %s\n\n", deltaJSON) } // 处理函数调用 if fc, ok := part["functionCall"].(map[string]interface{}); ok { // 如果有文本块正在进行,先关闭它 if textBlockStarted { stopEvent := map[string]interface{}{ "type": "content_block_stop", "index": textBlockIndex, } stopJSON, _ := json.Marshal(stopEvent) eventChan <- fmt.Sprintf("event: content_block_stop\ndata: %s\n\n", stopJSON) textBlockStarted = false textBlockIndex++ } name, _ := fc["name"].(string) args := fc["args"] id := fmt.Sprintf("toolu_%d", toolUseBlockIndex) events := processToolUsePart(id, name, args, toolUseBlockIndex) for _, event := range events { eventChan <- event } toolUseBlockIndex++ } } // 处理结束原因 if finishReason, ok := candidate["finishReason"].(string); ok { // 如果有未关闭的文本块,先关闭它 if textBlockStarted { stopEvent := map[string]interface{}{ "type": "content_block_stop", "index": textBlockIndex, } stopJSON, _ := json.Marshal(stopEvent) eventChan <- fmt.Sprintf("event: content_block_stop\ndata: %s\n\n", stopJSON) textBlockStarted = false } if strings.Contains(strings.ToLower(finishReason), "stop") { event := map[string]interface{}{ "type": "message_delta", "delta": map[string]string{ "stop_reason": "end_turn", }, } eventJSON, _ := json.Marshal(event) eventChan <- fmt.Sprintf("event: message_delta\ndata: %s\n\n", eventJSON) } } } // 确保流结束时关闭任何未关闭的文本块 if textBlockStarted { stopEvent := map[string]interface{}{ "type": "content_block_stop", "index": textBlockIndex, } stopJSON, _ := json.Marshal(stopEvent) eventChan <- fmt.Sprintf("event: content_block_stop\ndata: %s\n\n", stopJSON) } if err := scanner.Err(); err != nil { errChan <- err } }() return eventChan, errChan, nil } ================================================ FILE: backend-go/internal/providers/openai.go ================================================ package providers import ( "bufio" "bytes" "encoding/json" "fmt" "io" "net/http" "regexp" "strings" "time" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/types" "github.com/BenedictKing/claude-proxy/internal/utils" "github.com/gin-gonic/gin" ) // OpenAIProvider OpenAI 提供商 type OpenAIProvider struct{} // ConvertToProviderRequest 转换为 OpenAI 请求 func (p *OpenAIProvider) ConvertToProviderRequest(c *gin.Context, upstream *config.UpstreamConfig, apiKey string) (*http.Request, []byte, error) { // 读取和解析原始请求体 originalBodyBytes, err := io.ReadAll(c.Request.Body) if err != nil { return nil, nil, fmt.Errorf("读取请求体失败: %w", err) } // 恢复请求体,以便gin context可以被其他地方再次读取(尽管这里我们已经完全处理了) c.Request.Body = io.NopCloser(bytes.NewReader(originalBodyBytes)) var claudeReq types.ClaudeRequest if err := json.Unmarshal(originalBodyBytes, &claudeReq); err != nil { return nil, originalBodyBytes, fmt.Errorf("解析Claude请求体失败: %w", err) } // --- 复用旧的转换逻辑 --- openaiReq := &types.OpenAIRequest{ Model: config.RedirectModel(claudeReq.Model, upstream), Messages: p.convertMessages(&claudeReq), Stream: claudeReq.Stream, Temperature: claudeReq.Temperature, } if claudeReq.MaxTokens > 0 { openaiReq.MaxCompletionTokens = claudeReq.MaxTokens } else { openaiReq.MaxCompletionTokens = 65535 } // 转换工具 if len(claudeReq.Tools) > 0 { openaiReq.Tools = p.convertTools(claudeReq.Tools) openaiReq.ToolChoice = "auto" } // --- 转换逻辑结束 --- reqBodyBytes, err := json.Marshal(openaiReq) if err != nil { return nil, originalBodyBytes, fmt.Errorf("序列化OpenAI请求体失败: %w", err) } // 构建URL - baseURL可能已包含版本号(如/v1, /v2, /v1beta, /v2alpha等),需要智能拼接 // 如果 baseURL 以 # 结尾,则跳过自动添加 /v1 baseURL := upstream.GetEffectiveBaseURL() skipVersionPrefix := strings.HasSuffix(baseURL, "#") if skipVersionPrefix { baseURL = strings.TrimSuffix(baseURL, "#") } baseURL = strings.TrimSuffix(baseURL, "/") // 检查baseURL是否以版本号结尾(如/v1, /v2, /v1beta, /v2alpha等) // 使用正则表达式匹配 /v\d+[a-z]* 的模式(v后跟数字,可选字母后缀) versionPattern := regexp.MustCompile(`/v\d+[a-z]*$`) hasVersionSuffix := versionPattern.MatchString(baseURL) // 如果baseURL已经包含版本号或以#结尾,直接拼接/chat/completions // 否则拼接/v1/chat/completions endpoint := "/chat/completions" if !hasVersionSuffix && !skipVersionPrefix { endpoint = "/v1" + endpoint } url := baseURL + endpoint req, err := http.NewRequestWithContext(c.Request.Context(), "POST", url, bytes.NewReader(reqBodyBytes)) if err != nil { return nil, originalBodyBytes, fmt.Errorf("创建OpenAI请求失败: %w", err) } // 使用统一的头部处理逻辑(透明代理) // 保留客户端的大部分 headers,只移除/替换必要的认证和代理相关 headers req.Header = utils.PrepareUpstreamHeaders(c, req.URL.Host) utils.SetAuthenticationHeader(req.Header, apiKey) return req, originalBodyBytes, nil } // convertMessages 转换消息 func (p *OpenAIProvider) convertMessages(claudeReq *types.ClaudeRequest) []types.OpenAIMessage { messages := []types.OpenAIMessage{} // 添加系统消息 if claudeReq.System != nil { systemText := extractSystemText(claudeReq.System) if systemText != "" { messages = append(messages, types.OpenAIMessage{ Role: "system", Content: systemText, }) } } // 转换普通消息 for _, msg := range claudeReq.Messages { openaiMsg := p.convertMessage(msg) messages = append(messages, openaiMsg...) } return messages } // convertMessage 转换单个消息 func (p *OpenAIProvider) convertMessage(msg types.ClaudeMessage) []types.OpenAIMessage { messages := []types.OpenAIMessage{} // 如果是字符串内容 if str, ok := msg.Content.(string); ok { if msg.Role != "tool" { messages = append(messages, types.OpenAIMessage{ Role: normalizeRole(msg.Role), Content: str, }) } return messages } // 如果是内容数组 contents, ok := msg.Content.([]interface{}) if !ok { return messages } textContents := []string{} toolCalls := []types.OpenAIToolCall{} toolResults := []types.OpenAIMessage{} for _, c := range contents { content, ok := c.(map[string]interface{}) if !ok { continue } contentType, _ := content["type"].(string) switch contentType { case "text": if text, ok := content["text"].(string); ok { textContents = append(textContents, text) } case "tool_use": id, _ := content["id"].(string) name, _ := content["name"].(string) input := content["input"] inputJSON, _ := json.Marshal(input) toolCalls = append(toolCalls, types.OpenAIToolCall{ ID: id, Type: "function", Function: types.OpenAIToolCallFunction{ Name: name, Arguments: string(inputJSON), }, }) case "tool_result": toolUseID, _ := content["tool_use_id"].(string) resultContent := content["content"] var contentStr string if str, ok := resultContent.(string); ok { contentStr = str } else { contentJSON, _ := json.Marshal(resultContent) contentStr = string(contentJSON) } toolResults = append(toolResults, types.OpenAIMessage{ Role: "tool", ToolCallID: toolUseID, Content: contentStr, }) } } // 添加工具结果 messages = append(messages, toolResults...) // 添加文本和工具调用 if len(textContents) > 0 || len(toolCalls) > 0 { role := normalizeRole(msg.Role) if role != "tool" { openaiMsg := types.OpenAIMessage{ Role: role, } if len(textContents) > 0 { openaiMsg.Content = strings.Join(textContents, "\n") } else { openaiMsg.Content = nil } if len(toolCalls) > 0 { openaiMsg.ToolCalls = toolCalls } messages = append(messages, openaiMsg) } } return messages } // convertTools 转换工具 func (p *OpenAIProvider) convertTools(claudeTools []types.ClaudeTool) []types.OpenAITool { tools := []types.OpenAITool{} for _, tool := range claudeTools { tools = append(tools, types.OpenAITool{ Type: "function", Function: types.OpenAIToolFunction{ Name: tool.Name, Description: tool.Description, Parameters: cleanJsonSchema(tool.InputSchema), }, }) } return tools } // cleanJsonSchema 清理 JSON Schema,移除某些上游不支持的字段 func cleanJsonSchema(schema interface{}) interface{} { if schema == nil { return schema } // 如果是 map,递归清理 if schemaMap, ok := schema.(map[string]interface{}); ok { cleaned := make(map[string]interface{}) for key, value := range schemaMap { // 移除不需要的字段 if key == "$schema" || key == "title" || key == "examples" || key == "additionalProperties" { continue } // 移除 format 字段(当类型为 string 时) if key == "format" { if schemaType, hasType := schemaMap["type"]; hasType && schemaType == "string" { continue } } // 递归处理嵌套对象 if key == "properties" || key == "items" { cleaned[key] = cleanJsonSchema(value) } else if valueMap, isMap := value.(map[string]interface{}); isMap { cleaned[key] = cleanJsonSchema(valueMap) } else if valueSlice, isSlice := value.([]interface{}); isSlice { cleanedSlice := make([]interface{}, len(valueSlice)) for i, item := range valueSlice { cleanedSlice[i] = cleanJsonSchema(item) } cleaned[key] = cleanedSlice } else { cleaned[key] = value } } return cleaned } // 如果是数组,递归清理每个元素 if schemaSlice, ok := schema.([]interface{}); ok { cleaned := make([]interface{}, len(schemaSlice)) for i, item := range schemaSlice { cleaned[i] = cleanJsonSchema(item) } return cleaned } // 其他类型直接返回 return schema } // ConvertToClaudeResponse 转换为 Claude 响应 func (p *OpenAIProvider) ConvertToClaudeResponse(providerResp *types.ProviderResponse) (*types.ClaudeResponse, error) { var openaiResp types.OpenAIResponse if err := json.Unmarshal(providerResp.Body, &openaiResp); err != nil { return nil, err } claudeResp := &types.ClaudeResponse{ ID: generateID(), Type: "message", Role: "assistant", Content: []types.ClaudeContent{}, } if len(openaiResp.Choices) > 0 { choice := openaiResp.Choices[0] msg := choice.Message // 添加文本内容 if str, ok := msg.Content.(string); ok && str != "" { claudeResp.Content = append(claudeResp.Content, types.ClaudeContent{ Type: "text", Text: str, }) } // 添加工具调用 for _, toolCall := range msg.ToolCalls { var input interface{} json.Unmarshal([]byte(toolCall.Function.Arguments), &input) claudeResp.Content = append(claudeResp.Content, types.ClaudeContent{ Type: "tool_use", ID: toolCall.ID, Name: toolCall.Function.Name, Input: input, }) } // 设置停止原因 if len(msg.ToolCalls) > 0 { claudeResp.StopReason = "tool_use" } else if choice.FinishReason == "length" { claudeResp.StopReason = "max_tokens" } else { claudeResp.StopReason = "end_turn" } } // 添加使用统计 if openaiResp.Usage != nil { claudeResp.Usage = &types.Usage{ InputTokens: openaiResp.Usage.PromptTokens, OutputTokens: openaiResp.Usage.CompletionTokens, } } return claudeResp, nil } // HandleStreamResponse 处理流式响应 func (p *OpenAIProvider) HandleStreamResponse(body io.ReadCloser) (<-chan string, <-chan error, error) { eventChan := make(chan string, 100) errChan := make(chan error, 1) go func() { defer close(eventChan) // defer close(errChan) // 移除此行,避免竞态条件 defer body.Close() scanner := bufio.NewScanner(body) // 设置更大的 buffer (1MB) 以处理大 JSON chunk,避免默认 64KB 限制 const maxScannerBufferSize = 1024 * 1024 // 1MB scanner.Buffer(make([]byte, 0, 64*1024), maxScannerBufferSize) toolUseBlockIndex := 0 toolCallAccumulator := make(map[int]*ToolCallAccumulator) toolUseStopEmitted := false // 文本块状态跟踪 textBlockStarted := false textBlockIndex := 0 for scanner.Scan() { line := scanner.Text() line = strings.TrimSpace(line) if line == "" || line == "data: [DONE]" { continue } if !strings.HasPrefix(line, "data: ") { continue } jsonStr := strings.TrimPrefix(line, "data: ") var chunk map[string]interface{} if err := json.Unmarshal([]byte(jsonStr), &chunk); err != nil { continue } // 检查是否有错误 if errObj, ok := chunk["error"]; ok { errChan <- fmt.Errorf("upstream error: %v", errObj) return } choices, ok := chunk["choices"].([]interface{}) if !ok || len(choices) == 0 { continue } choice, ok := choices[0].(map[string]interface{}) if !ok { continue } delta, ok := choice["delta"].(map[string]interface{}) if !ok { continue } // 处理文本内容 if content, ok := delta["content"].(string); ok && content != "" { // 如果是第一个文本块,发送 content_block_start if !textBlockStarted { startEvent := map[string]interface{}{ "type": "content_block_start", "index": textBlockIndex, "content_block": map[string]string{ "type": "text", "text": "", }, } startJSON, _ := json.Marshal(startEvent) eventChan <- fmt.Sprintf("event: content_block_start\ndata: %s\n\n", startJSON) textBlockStarted = true } // 发送 content_block_delta deltaEvent := map[string]interface{}{ "type": "content_block_delta", "index": textBlockIndex, "delta": map[string]string{ "type": "text_delta", "text": content, }, } deltaJSON, _ := json.Marshal(deltaEvent) eventChan <- fmt.Sprintf("event: content_block_delta\ndata: %s\n\n", deltaJSON) } // 处理工具调用 if toolCalls, ok := delta["tool_calls"].([]interface{}); ok { // 如果有文本块正在进行,先关闭它 if textBlockStarted { stopEvent := map[string]interface{}{ "type": "content_block_stop", "index": textBlockIndex, } stopJSON, _ := json.Marshal(stopEvent) eventChan <- fmt.Sprintf("event: content_block_stop\ndata: %s\n\n", stopJSON) textBlockStarted = false textBlockIndex++ } for _, tc := range toolCalls { toolCall, ok := tc.(map[string]interface{}) if !ok { continue } index := 0 if idx, ok := toolCall["index"].(float64); ok { index = int(idx) } // 获取或创建累加器 if _, exists := toolCallAccumulator[index]; !exists { toolCallAccumulator[index] = &ToolCallAccumulator{} } acc := toolCallAccumulator[index] // 累积数据 if id, ok := toolCall["id"].(string); ok { acc.ID = id } if function, ok := toolCall["function"].(map[string]interface{}); ok { if name, ok := function["name"].(string); ok { acc.Name = name } if args, ok := function["arguments"].(string); ok { acc.Arguments += args } } // 检查是否完整 if acc.ID != "" && acc.Name != "" && acc.Arguments != "" { var args interface{} if err := json.Unmarshal([]byte(acc.Arguments), &args); err == nil { events := processToolUsePart(acc.ID, acc.Name, args, toolUseBlockIndex) for _, event := range events { eventChan <- event } toolUseBlockIndex++ delete(toolCallAccumulator, index) } } } } // 处理结束原因 if finishReason, ok := choice["finish_reason"].(string); ok { // 如果有未关闭的文本块,先关闭它 if textBlockStarted { stopEvent := map[string]interface{}{ "type": "content_block_stop", "index": textBlockIndex, } stopJSON, _ := json.Marshal(stopEvent) eventChan <- fmt.Sprintf("event: content_block_stop\ndata: %s\n\n", stopJSON) textBlockStarted = false } if !toolUseStopEmitted && (finishReason == "tool_calls" || finishReason == "function_call") { event := map[string]interface{}{ "type": "message_delta", "delta": map[string]string{ "stop_reason": "tool_use", }, } eventJSON, _ := json.Marshal(event) eventChan <- fmt.Sprintf("event: message_delta\ndata: %s\n\n", eventJSON) toolUseStopEmitted = true } } } // 确保流结束时关闭任何未关闭的文本块 if textBlockStarted { stopEvent := map[string]interface{}{ "type": "content_block_stop", "index": textBlockIndex, } stopJSON, _ := json.Marshal(stopEvent) eventChan <- fmt.Sprintf("event: content_block_stop\ndata: %s\n\n", stopJSON) } if err := scanner.Err(); err != nil { // 在 tool_use 场景下,客户端主动断开是正常行为 // 如果已经发送了 tool_use stop 事件,并且错误是连接断开相关的,则忽略该错误 errMsg := err.Error() if toolUseStopEmitted && (strings.Contains(errMsg, "broken pipe") || strings.Contains(errMsg, "connection reset") || strings.Contains(errMsg, "EOF")) { // 这是预期的客户端行为,不报告错误 return } errChan <- err } }() return eventChan, errChan, nil } // ToolCallAccumulator 工具调用累加器 type ToolCallAccumulator struct { ID string Name string Arguments string } // processToolUsePart 处理工具使用部分 func processToolUsePart(id, name string, input interface{}, index int) []string { events := []string{} // content_block_start startEvent := map[string]interface{}{ "type": "content_block_start", "index": index, "content_block": map[string]interface{}{ "type": "tool_use", "id": id, "name": name, }, } startJSON, _ := json.Marshal(startEvent) events = append(events, fmt.Sprintf("event: content_block_start\ndata: %s\n\n", startJSON)) // content_block_delta inputJSON, _ := json.Marshal(input) deltaEvent := map[string]interface{}{ "type": "content_block_delta", "index": index, "delta": map[string]string{ "type": "input_json_delta", "partial_json": string(inputJSON), }, } deltaJSON, _ := json.Marshal(deltaEvent) events = append(events, fmt.Sprintf("event: content_block_delta\ndata: %s\n\n", deltaJSON)) // content_block_stop stopEvent := map[string]interface{}{ "type": "content_block_stop", "index": index, } stopJSON, _ := json.Marshal(stopEvent) events = append(events, fmt.Sprintf("event: content_block_stop\ndata: %s\n\n", stopJSON)) return events } // 辅助函数 func extractSystemText(system interface{}) string { if str, ok := system.(string); ok { return str } // 可能是数组 arr, ok := system.([]interface{}) if !ok { return "" } parts := []string{} for _, item := range arr { obj, ok := item.(map[string]interface{}) if !ok { continue } if obj["type"] == "text" { if text, ok := obj["text"].(string); ok { parts = append(parts, text) } } } return strings.Join(parts, "\n") } func normalizeRole(role string) string { role = strings.ToLower(role) switch role { case "user", "assistant", "system", "tool": return role default: return "user" } } func generateID() string { return fmt.Sprintf("msg_%d", time.Now().UnixNano()) } ================================================ FILE: backend-go/internal/providers/provider.go ================================================ package providers import ( "io" "net/http" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/types" "github.com/gin-gonic/gin" ) // Provider 提供商接口 type Provider interface { // ConvertToProviderRequest 将 gin context 中的请求转换为目标上游的 http.Request,并返回用于日志的原始请求体 ConvertToProviderRequest(c *gin.Context, upstream *config.UpstreamConfig, apiKey string) (*http.Request, []byte, error) // ConvertToClaudeResponse 将提供商响应转换为 Claude 响应 ConvertToClaudeResponse(providerResp *types.ProviderResponse) (*types.ClaudeResponse, error) // HandleStreamResponse 处理流式响应 HandleStreamResponse(body io.ReadCloser) (<-chan string, <-chan error, error) } // GetProvider 根据服务类型获取提供商 func GetProvider(serviceType string) Provider { switch serviceType { case "openai": return &OpenAIProvider{} case "gemini": return &GeminiProvider{} case "claude": return &ClaudeProvider{} default: return nil } } ================================================ FILE: backend-go/internal/providers/request_context_test.go ================================================ package providers import ( "bytes" "context" "net/http" "net/http/httptest" "testing" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/gin-gonic/gin" ) type testContextKey string func newGinContext(method, url string, body []byte, ctx context.Context) *gin.Context { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) req := httptest.NewRequest(method, url, bytes.NewReader(body)) if ctx != nil { req = req.WithContext(ctx) } c.Request = req return c } func TestConvertToProviderRequest_PropagatesContext(t *testing.T) { gin.SetMode(gin.TestMode) key := testContextKey("test-key") ctx := context.WithValue(context.Background(), key, "ok") t.Run("claude", func(t *testing.T) { c := newGinContext(http.MethodPost, "/v1/messages", []byte(`{"model":"claude-3","messages":[]}`), ctx) upstream := &config.UpstreamConfig{BaseURL: "https://api.example.com", ServiceType: "claude"} p := &ClaudeProvider{} req, _, err := p.ConvertToProviderRequest(c, upstream, "sk-ant-test") if err != nil { t.Fatalf("ConvertToProviderRequest() err = %v", err) } if got := req.Context().Value(key); got != "ok" { t.Fatalf("req.Context().Value(key) = %v, want %v", got, "ok") } }) t.Run("openai", func(t *testing.T) { c := newGinContext(http.MethodPost, "/v1/messages", []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`), ctx) upstream := &config.UpstreamConfig{BaseURL: "https://api.example.com", ServiceType: "openai"} p := &OpenAIProvider{} req, _, err := p.ConvertToProviderRequest(c, upstream, "sk-test") if err != nil { t.Fatalf("ConvertToProviderRequest() err = %v", err) } if got := req.Context().Value(key); got != "ok" { t.Fatalf("req.Context().Value(key) = %v, want %v", got, "ok") } }) t.Run("gemini", func(t *testing.T) { c := newGinContext(http.MethodPost, "/v1/messages", []byte(`{"model":"gemini-2.0-flash","messages":[{"role":"user","content":"hi"}]}`), ctx) upstream := &config.UpstreamConfig{BaseURL: "https://api.example.com", ServiceType: "gemini"} p := &GeminiProvider{} req, _, err := p.ConvertToProviderRequest(c, upstream, "AIza-test") if err != nil { t.Fatalf("ConvertToProviderRequest() err = %v", err) } if got := req.Context().Value(key); got != "ok" { t.Fatalf("req.Context().Value(key) = %v, want %v", got, "ok") } }) t.Run("responses", func(t *testing.T) { c := newGinContext(http.MethodPost, "/v1/responses", []byte(`{"model":"gpt-4o","input":"hi"}`), ctx) upstream := &config.UpstreamConfig{BaseURL: "https://api.example.com", ServiceType: "responses"} p := &ResponsesProvider{} req, _, err := p.ConvertToProviderRequest(c, upstream, "sk-test") if err != nil { t.Fatalf("ConvertToProviderRequest() err = %v", err) } if got := req.Context().Value(key); got != "ok" { t.Fatalf("req.Context().Value(key) = %v, want %v", got, "ok") } }) } ================================================ FILE: backend-go/internal/providers/responses.go ================================================ package providers import ( "bytes" "encoding/json" "fmt" "io" "net/http" "regexp" "strings" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/converters" "github.com/BenedictKing/claude-proxy/internal/session" "github.com/BenedictKing/claude-proxy/internal/types" "github.com/BenedictKing/claude-proxy/internal/utils" "github.com/gin-gonic/gin" ) // ResponsesProvider Responses API 提供商 type ResponsesProvider struct { SessionManager *session.SessionManager } // ConvertToProviderRequest 将 Responses 请求转换为上游格式 func (p *ResponsesProvider) ConvertToProviderRequest( c *gin.Context, upstream *config.UpstreamConfig, apiKey string, ) (*http.Request, []byte, error) { // 1. 读取原始请求体 bodyBytes, err := io.ReadAll(c.Request.Body) if err != nil { return nil, nil, fmt.Errorf("读取请求体失败: %w", err) } c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes)) var providerReq interface{} // 2. 使用转换器工厂创建转换器 converter := converters.NewConverter(upstream.ServiceType) // 3. 判断是否为透传模式 if _, ok := converter.(*converters.ResponsesPassthroughConverter); ok { // [Mode-Passthrough] 透传模式:使用 map 保留所有字段 var reqMap map[string]interface{} if err := json.Unmarshal(bodyBytes, &reqMap); err != nil { return nil, bodyBytes, fmt.Errorf("透传模式下解析请求失败: %w", err) } // 只做模型重定向 if model, ok := reqMap["model"].(string); ok { reqMap["model"] = config.RedirectModel(model, upstream) } providerReq = reqMap } else { // [Mode-Convert] 非透传模式:保持原有逻辑 var responsesReq types.ResponsesRequest if err := json.Unmarshal(bodyBytes, &responsesReq); err != nil { return nil, bodyBytes, fmt.Errorf("解析 Responses 请求失败: %w", err) } // 获取或创建会话 sess, err := p.SessionManager.GetOrCreateSession(responsesReq.PreviousResponseID) if err != nil { return nil, bodyBytes, fmt.Errorf("获取会话失败: %w", err) } // 模型重定向 responsesReq.Model = config.RedirectModel(responsesReq.Model, upstream) // 转换请求 convertedReq, err := converter.ToProviderRequest(sess, &responsesReq) if err != nil { return nil, bodyBytes, fmt.Errorf("转换请求失败: %w", err) } providerReq = convertedReq } // 4. 序列化请求体(禁用 HTML 转义) reqBody, err := utils.MarshalJSONNoEscape(providerReq) if err != nil { return nil, bodyBytes, fmt.Errorf("序列化请求失败: %w", err) } // 7. 构建 HTTP 请求 targetURL := p.buildTargetURL(upstream) req, err := http.NewRequestWithContext(c.Request.Context(), "POST", targetURL, bytes.NewReader(reqBody)) if err != nil { return nil, bodyBytes, err } // 8. 设置请求头(透明代理) // 使用统一的头部处理逻辑,保留客户端的大部分 headers req.Header = utils.PrepareUpstreamHeaders(c, req.URL.Host) // 删除客户端的所有认证头,避免冲突 req.Header.Del("authorization") req.Header.Del("x-api-key") req.Header.Del("x-goog-api-key") // 根据 ServiceType 设置对应的认证头 switch upstream.ServiceType { case "gemini": // 只有 Gemini 使用特殊的认证头 utils.SetGeminiAuthenticationHeader(req.Header, apiKey) default: // claude, responses, openai 等都使用 Authorization: Bearer utils.SetAuthenticationHeader(req.Header, apiKey) } // 确保 Content-Type 正确 req.Header.Set("Content-Type", "application/json") return req, bodyBytes, nil } // buildTargetURL 根据上游类型构建目标 URL // 智能拼接逻辑: // 1. 如果 baseURL 以 # 结尾,跳过自动添加 /v1 // 2. 如果 baseURL 已包含版本号后缀(如 /v1, /v2, /v8, /v1beta),直接拼接端点路径 // 3. 如果 baseURL 不包含版本号后缀,自动添加 /v1 再拼接端点路径 func (p *ResponsesProvider) buildTargetURL(upstream *config.UpstreamConfig) string { baseURL := upstream.BaseURL skipVersionPrefix := strings.HasSuffix(baseURL, "#") if skipVersionPrefix { baseURL = strings.TrimSuffix(baseURL, "#") } baseURL = strings.TrimSuffix(baseURL, "/") // 使用正则表达式检测 baseURL 是否以版本号结尾(/v1, /v2, /v1beta, /v2alpha等) versionPattern := regexp.MustCompile(`/v\d+[a-z]*$`) hasVersionSuffix := versionPattern.MatchString(baseURL) // 根据 ServiceType 确定端点路径 var endpoint string switch upstream.ServiceType { case "responses": endpoint = "/responses" case "claude": endpoint = "/messages" default: endpoint = "/chat/completions" } // 如果 baseURL 已包含版本号或以#结尾,直接拼接端点 // 否则添加 /v1 再拼接端点 if hasVersionSuffix || skipVersionPrefix { return baseURL + endpoint } return baseURL + "/v1" + endpoint } // ConvertToClaudeResponse 将上游响应转换为 Responses 格式(实际上不再需要 Claude 格式) func (p *ResponsesProvider) ConvertToClaudeResponse(providerResp *types.ProviderResponse) (*types.ClaudeResponse, error) { // 这个方法在 ResponsesHandler 中不会被调用,这里提供兼容性实现 return nil, fmt.Errorf("ResponsesProvider 不支持 ConvertToClaudeResponse") } // ConvertToResponsesResponse 将上游响应转换为 Responses 格式 func (p *ResponsesProvider) ConvertToResponsesResponse( providerResp *types.ProviderResponse, upstreamType string, sessionID string, ) (*types.ResponsesResponse, error) { // 解析响应体为 map respMap, err := converters.JSONToMap(providerResp.Body) if err != nil { return nil, fmt.Errorf("解析响应失败: %w", err) } // 使用转换器工厂 converter := converters.NewConverter(upstreamType) return converter.FromProviderResponse(respMap, sessionID) } // HandleStreamResponse 处理流式响应(暂不实现) func (p *ResponsesProvider) HandleStreamResponse(body io.ReadCloser) (<-chan string, <-chan error, error) { return nil, nil, fmt.Errorf("Responses Provider 暂不支持流式响应") } ================================================ FILE: backend-go/internal/providers/url_builder_test.go ================================================ package providers import ( "regexp" "strings" "testing" "github.com/BenedictKing/claude-proxy/internal/config" ) // buildOpenAIURL 模拟 openai.go 中的 URL 构建逻辑 func buildOpenAIURL(baseURL string) string { skipVersionPrefix := strings.HasSuffix(baseURL, "#") if skipVersionPrefix { baseURL = strings.TrimSuffix(baseURL, "#") } baseURL = strings.TrimSuffix(baseURL, "/") versionPattern := regexp.MustCompile(`/v\d+[a-z]*$`) hasVersionSuffix := versionPattern.MatchString(baseURL) endpoint := "/chat/completions" if !hasVersionSuffix && !skipVersionPrefix { endpoint = "/v1" + endpoint } return baseURL + endpoint } // buildClaudeURL 模拟 claude.go 中的 URL 构建逻辑 func buildClaudeURL(baseURL, requestPath string) string { endpoint := strings.TrimPrefix(requestPath, "/v1") skipVersionPrefix := strings.HasSuffix(baseURL, "#") if skipVersionPrefix { baseURL = strings.TrimSuffix(baseURL, "#") } baseURL = strings.TrimSuffix(baseURL, "/") versionPattern := regexp.MustCompile(`/v\d+[a-z]*$`) if versionPattern.MatchString(baseURL) || skipVersionPrefix { return baseURL + endpoint } return baseURL + "/v1" + endpoint } func TestOpenAIURL_SkipVersionWithHash(t *testing.T) { tests := []struct { name string baseURL string want string }{ {"normal", "https://api.openai.com", "https://api.openai.com/v1/chat/completions"}, {"with_v1", "https://api.openai.com/v1", "https://api.openai.com/v1/chat/completions"}, {"hash_skip", "https://api.example.com#", "https://api.example.com/chat/completions"}, {"hash_with_slash", "https://api.example.com/#", "https://api.example.com/chat/completions"}, {"trailing_slash", "https://api.example.com/", "https://api.example.com/v1/chat/completions"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := buildOpenAIURL(tt.baseURL) if got != tt.want { t.Errorf("buildOpenAIURL(%q) = %q, want %q", tt.baseURL, got, tt.want) } }) } } func TestClaudeURL_SkipVersionWithHash(t *testing.T) { tests := []struct { name string baseURL string requestPath string want string }{ {"normal", "https://api.anthropic.com", "/v1/messages", "https://api.anthropic.com/v1/messages"}, {"with_v1", "https://api.anthropic.com/v1", "/v1/messages", "https://api.anthropic.com/v1/messages"}, {"hash_skip", "https://api.example.com#", "/v1/messages", "https://api.example.com/messages"}, {"hash_with_slash", "https://api.example.com/#", "/v1/messages", "https://api.example.com/messages"}, {"trailing_slash", "https://api.example.com/", "/v1/messages", "https://api.example.com/v1/messages"}, {"count_tokens", "https://api.example.com#", "/v1/messages/count_tokens", "https://api.example.com/messages/count_tokens"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := buildClaudeURL(tt.baseURL, tt.requestPath) if got != tt.want { t.Errorf("buildClaudeURL(%q, %q) = %q, want %q", tt.baseURL, tt.requestPath, got, tt.want) } }) } } func TestBuildTargetURL_SkipVersionWithHash(t *testing.T) { p := &ResponsesProvider{} tests := []struct { name string baseURL string serviceType string want string }{ // 正常情况:自动添加 /v1 {"normal_responses", "https://api.example.com", "responses", "https://api.example.com/v1/responses"}, {"normal_claude", "https://api.example.com", "claude", "https://api.example.com/v1/messages"}, {"normal_openai", "https://api.example.com", "openai", "https://api.example.com/v1/chat/completions"}, // 已有版本号:不添加 /v1 {"with_version", "https://api.example.com/v1", "responses", "https://api.example.com/v1/responses"}, {"with_v2", "https://api.example.com/v2", "openai", "https://api.example.com/v2/chat/completions"}, // # 结尾:跳过 /v1 {"hash_skip", "https://api.example.com#", "responses", "https://api.example.com/responses"}, {"hash_skip_claude", "https://api.example.com#", "claude", "https://api.example.com/messages"}, {"hash_skip_openai", "https://api.example.com#", "openai", "https://api.example.com/chat/completions"}, // # 结尾 + 末尾斜杠:正确处理 {"hash_with_slash", "https://api.example.com/#", "responses", "https://api.example.com/responses"}, {"hash_with_slash_openai", "https://api.example.com/#", "openai", "https://api.example.com/chat/completions"}, // 末尾斜杠:正确移除 {"trailing_slash", "https://api.example.com/", "responses", "https://api.example.com/v1/responses"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { upstream := &config.UpstreamConfig{ BaseURL: tt.baseURL, ServiceType: tt.serviceType, } got := p.buildTargetURL(upstream) if got != tt.want { t.Errorf("buildTargetURL() = %q, want %q", got, tt.want) } }) } } ================================================ FILE: backend-go/internal/scheduler/channel_scheduler.go ================================================ package scheduler import ( "context" "fmt" "log" "sort" "sync" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/metrics" "github.com/BenedictKing/claude-proxy/internal/session" "github.com/BenedictKing/claude-proxy/internal/types" "github.com/BenedictKing/claude-proxy/internal/warmup" ) // ChannelScheduler 多渠道调度器 type ChannelScheduler struct { mu sync.RWMutex configManager *config.ConfigManager messagesMetricsManager *metrics.MetricsManager // Messages 渠道指标 responsesMetricsManager *metrics.MetricsManager // Responses 渠道指标 geminiMetricsManager *metrics.MetricsManager // Gemini 渠道指标 traceAffinity *session.TraceAffinityManager urlManager *warmup.URLManager // URL 管理器(非阻塞,动态排序) } // ChannelKind 标识调度器所处理的渠道类型 // 注意:这里的 kind 与 upstream.ServiceType(openai/claude/gemini)不同, // kind 对应的是本代理对外暴露的三类入口:messages / responses / gemini。 type ChannelKind string const ( ChannelKindMessages ChannelKind = "messages" ChannelKindResponses ChannelKind = "responses" ChannelKindGemini ChannelKind = "gemini" ) // NewChannelScheduler 创建多渠道调度器 func NewChannelScheduler( cfgManager *config.ConfigManager, messagesMetrics *metrics.MetricsManager, responsesMetrics *metrics.MetricsManager, geminiMetrics *metrics.MetricsManager, traceAffinity *session.TraceAffinityManager, urlMgr *warmup.URLManager, ) *ChannelScheduler { return &ChannelScheduler{ configManager: cfgManager, messagesMetricsManager: messagesMetrics, responsesMetricsManager: responsesMetrics, geminiMetricsManager: geminiMetrics, traceAffinity: traceAffinity, urlManager: urlMgr, } } // getMetricsManager 根据类型获取对应的指标管理器 func (s *ChannelScheduler) getMetricsManager(kind ChannelKind) *metrics.MetricsManager { switch kind { case ChannelKindResponses: return s.responsesMetricsManager case ChannelKindGemini: return s.geminiMetricsManager default: return s.messagesMetricsManager } } // SelectionResult 渠道选择结果 type SelectionResult struct { Upstream *config.UpstreamConfig ChannelIndex int Reason string // 选择原因(用于日志) } // SelectChannel 选择最佳渠道 // 优先级: 促销期渠道 > Trace亲和(促销渠道失败时回退) > 渠道优先级顺序 func (s *ChannelScheduler) SelectChannel( ctx context.Context, userID string, failedChannels map[int]bool, kind ChannelKind, ) (*SelectionResult, error) { s.mu.RLock() defer s.mu.RUnlock() // 获取活跃渠道列表 activeChannels := s.getActiveChannels(kind) if len(activeChannels) == 0 { switch kind { case ChannelKindGemini: return nil, fmt.Errorf("没有可用的活跃 Gemini 渠道") case ChannelKindResponses: return nil, fmt.Errorf("没有可用的活跃 Responses 渠道") default: return nil, fmt.Errorf("没有可用的活跃 Messages 渠道") } } // 获取对应类型的指标管理器 metricsManager := s.getMetricsManager(kind) // 0. 检查促销期渠道(最高优先级,绕过健康检查) promotedChannel := s.findPromotedChannel(activeChannels, kind) if promotedChannel != nil && !failedChannels[promotedChannel.Index] { // 促销渠道存在且未失败,直接使用(不检查健康状态,让用户设置的促销渠道有机会尝试) upstream := s.getUpstreamByIndex(promotedChannel.Index, kind) if upstream != nil && len(upstream.APIKeys) > 0 { failureRate := metricsManager.CalculateChannelFailureRate(upstream.BaseURL, upstream.APIKeys) prefix := kindSchedulerLogPrefix(kind) log.Printf("[%s-Promotion] 促销期优先选择渠道: [%d] %s (失败率: %.1f%%, 绕过健康检查)", prefix, promotedChannel.Index, upstream.Name, failureRate*100) return &SelectionResult{ Upstream: upstream, ChannelIndex: promotedChannel.Index, Reason: "promotion_priority", }, nil } else if upstream != nil { prefix := kindSchedulerLogPrefix(kind) log.Printf("[%s-Promotion] 警告: 促销渠道 [%d] %s 无可用密钥,跳过", prefix, promotedChannel.Index, upstream.Name) } } else if promotedChannel != nil { prefix := kindSchedulerLogPrefix(kind) log.Printf("[%s-Promotion] 警告: 促销渠道 [%d] %s 已在本次请求中失败,跳过", prefix, promotedChannel.Index, promotedChannel.Name) } // 1. 检查 Trace 亲和性(促销渠道失败时或无促销渠道时) if userID != "" { if preferredIdx, ok := s.traceAffinity.GetPreferredChannel(userID); ok { for _, ch := range activeChannels { if ch.Index == preferredIdx && !failedChannels[preferredIdx] { // 检查渠道状态:只有 active 状态才使用亲和性 if ch.Status != "active" { prefix := kindSchedulerLogPrefix(kind) log.Printf("[%s-Affinity] 跳过亲和渠道 [%d] %s: 状态为 %s (user: %s)", prefix, preferredIdx, ch.Name, ch.Status, maskUserID(userID)) continue } // 检查渠道是否健康 upstream := s.getUpstreamByIndex(preferredIdx, kind) if upstream != nil && metricsManager.IsChannelHealthyWithKeys(upstream.BaseURL, upstream.APIKeys) { prefix := kindSchedulerLogPrefix(kind) log.Printf("[%s-Affinity] Trace亲和选择渠道: [%d] %s (user: %s)", prefix, preferredIdx, upstream.Name, maskUserID(userID)) return &SelectionResult{ Upstream: upstream, ChannelIndex: preferredIdx, Reason: "trace_affinity", }, nil } } } } } // 2. 按优先级遍历活跃渠道 for _, ch := range activeChannels { // 跳过本次请求已经失败的渠道 if failedChannels[ch.Index] { continue } // 跳过非 active 状态的渠道(suspended 等) if ch.Status != "active" { prefix := kindSchedulerLogPrefix(kind) log.Printf("[%s-Channel] 跳过非活跃渠道: [%d] %s (状态: %s)", prefix, ch.Index, ch.Name, ch.Status) continue } upstream := s.getUpstreamByIndex(ch.Index, kind) if upstream == nil || len(upstream.APIKeys) == 0 { continue } // 跳过失败率过高的渠道(已熔断或即将熔断) if !metricsManager.IsChannelHealthyWithKeys(upstream.BaseURL, upstream.APIKeys) { failureRate := metricsManager.CalculateChannelFailureRate(upstream.BaseURL, upstream.APIKeys) prefix := kindSchedulerLogPrefix(kind) log.Printf("[%s-Channel] 警告: 跳过不健康渠道: [%d] %s (失败率: %.1f%%)", prefix, ch.Index, ch.Name, failureRate*100) continue } prefix := kindSchedulerLogPrefix(kind) log.Printf("[%s-Channel] 选择渠道: [%d] %s (优先级: %d)", prefix, ch.Index, upstream.Name, ch.Priority) return &SelectionResult{ Upstream: upstream, ChannelIndex: ch.Index, Reason: "priority_order", }, nil } // 3. 所有健康渠道都失败,选择失败率最低的作为降级 return s.selectFallbackChannel(activeChannels, failedChannels, kind) } // findPromotedChannel 查找处于促销期的渠道 func (s *ChannelScheduler) findPromotedChannel(activeChannels []ChannelInfo, kind ChannelKind) *ChannelInfo { for i := range activeChannels { ch := &activeChannels[i] if ch.Status != "active" { continue } upstream := s.getUpstreamByIndex(ch.Index, kind) if upstream != nil { if config.IsChannelInPromotion(upstream) { prefix := kindSchedulerLogPrefix(kind) log.Printf("[%s-Promotion] 找到促销渠道: [%d] %s (promotionUntil: %v)", prefix, ch.Index, upstream.Name, upstream.PromotionUntil) return ch } } } return nil } // selectFallbackChannel 选择降级渠道(失败率最低的) func (s *ChannelScheduler) selectFallbackChannel( activeChannels []ChannelInfo, failedChannels map[int]bool, kind ChannelKind, ) (*SelectionResult, error) { metricsManager := s.getMetricsManager(kind) var bestChannel *ChannelInfo var bestUpstream *config.UpstreamConfig bestFailureRate := float64(2) // 初始化为不可能的值 for i := range activeChannels { ch := &activeChannels[i] if failedChannels[ch.Index] { continue } // 跳过非 active 状态的渠道 if ch.Status != "active" { continue } upstream := s.getUpstreamByIndex(ch.Index, kind) if upstream == nil || len(upstream.APIKeys) == 0 { continue } failureRate := metricsManager.CalculateChannelFailureRate(upstream.BaseURL, upstream.APIKeys) if failureRate < bestFailureRate { bestFailureRate = failureRate bestChannel = ch bestUpstream = upstream } } if bestChannel != nil && bestUpstream != nil { prefix := kindSchedulerLogPrefix(kind) log.Printf("[%s-Fallback] 警告: 降级选择渠道: [%d] %s (失败率: %.1f%%)", prefix, bestChannel.Index, bestUpstream.Name, bestFailureRate*100) return &SelectionResult{ Upstream: bestUpstream, ChannelIndex: bestChannel.Index, Reason: "fallback", }, nil } return nil, fmt.Errorf("所有渠道都不可用") } // ChannelInfo 渠道信息(用于排序) type ChannelInfo struct { Index int Name string Priority int Status string } // getActiveChannels 获取活跃渠道列表(按优先级排序) func (s *ChannelScheduler) getActiveChannels(kind ChannelKind) []ChannelInfo { cfg := s.configManager.GetConfig() var upstreams []config.UpstreamConfig switch kind { case ChannelKindResponses: upstreams = cfg.ResponsesUpstream case ChannelKindGemini: upstreams = cfg.GeminiUpstream default: upstreams = cfg.Upstream } // 筛选活跃渠道 var activeChannels []ChannelInfo for i, upstream := range upstreams { status := upstream.Status if status == "" { status = "active" // 默认为活跃 } // 只选择 active 状态的渠道(suspended 也算在活跃序列中,但会被健康检查过滤) if status != "disabled" { priority := upstream.Priority if priority == 0 { priority = i // 默认优先级为索引 } activeChannels = append(activeChannels, ChannelInfo{ Index: i, Name: upstream.Name, Priority: priority, Status: status, }) } } // 按优先级排序(数字越小优先级越高) sort.Slice(activeChannels, func(i, j int) bool { return activeChannels[i].Priority < activeChannels[j].Priority }) return activeChannels } // getUpstreamByIndex 根据索引获取上游配置 // 注意:返回的是副本,避免指向 slice 元素的指针在 slice 重分配后失效 func (s *ChannelScheduler) getUpstreamByIndex(index int, kind ChannelKind) *config.UpstreamConfig { cfg := s.configManager.GetConfig() var upstreams []config.UpstreamConfig switch kind { case ChannelKindResponses: upstreams = cfg.ResponsesUpstream case ChannelKindGemini: upstreams = cfg.GeminiUpstream default: upstreams = cfg.Upstream } if index >= 0 && index < len(upstreams) { // 返回副本,避免返回指向 slice 元素的指针 upstream := upstreams[index] return &upstream } return nil } // RecordSuccess 记录渠道成功(使用 baseURL + apiKey) func (s *ChannelScheduler) RecordSuccess(baseURL, apiKey string, kind ChannelKind) { s.getMetricsManager(kind).RecordSuccess(baseURL, apiKey) } // RecordSuccessWithUsage 记录渠道成功(带 Usage 数据) func (s *ChannelScheduler) RecordSuccessWithUsage(baseURL, apiKey string, usage *types.Usage, kind ChannelKind) { s.getMetricsManager(kind).RecordSuccessWithUsage(baseURL, apiKey, usage) } // RecordFailure 记录渠道失败(使用 baseURL + apiKey) func (s *ChannelScheduler) RecordFailure(baseURL, apiKey string, kind ChannelKind) { s.getMetricsManager(kind).RecordFailure(baseURL, apiKey) } // RecordRequestStart 记录请求开始 func (s *ChannelScheduler) RecordRequestStart(baseURL, apiKey string, kind ChannelKind) { s.getMetricsManager(kind).RecordRequestStart(baseURL, apiKey) } // RecordRequestEnd 记录请求结束 func (s *ChannelScheduler) RecordRequestEnd(baseURL, apiKey string, kind ChannelKind) { s.getMetricsManager(kind).RecordRequestEnd(baseURL, apiKey) } // SetTraceAffinity 设置 Trace 亲和 func (s *ChannelScheduler) SetTraceAffinity(userID string, channelIndex int) { if userID != "" { s.traceAffinity.SetPreferredChannel(userID, channelIndex) } } // UpdateTraceAffinity 更新 Trace 亲和时间(续期) func (s *ChannelScheduler) UpdateTraceAffinity(userID string) { if userID != "" { s.traceAffinity.UpdateLastUsed(userID) } } // GetMessagesMetricsManager 获取 Messages 渠道指标管理器 func (s *ChannelScheduler) GetMessagesMetricsManager() *metrics.MetricsManager { return s.messagesMetricsManager } // GetResponsesMetricsManager 获取 Responses 渠道指标管理器 func (s *ChannelScheduler) GetResponsesMetricsManager() *metrics.MetricsManager { return s.responsesMetricsManager } // GetGeminiMetricsManager 获取 Gemini 渠道指标管理器 func (s *ChannelScheduler) GetGeminiMetricsManager() *metrics.MetricsManager { return s.geminiMetricsManager } // GetTraceAffinityManager 获取 Trace 亲和性管理器 func (s *ChannelScheduler) GetTraceAffinityManager() *session.TraceAffinityManager { return s.traceAffinity } // ResetChannelMetrics 重置渠道所有 Key 的熔断/失败状态(保留历史统计) // 用于:1) 手动恢复熔断 2) 更换 API Key 后重置熔断状态 func (s *ChannelScheduler) ResetChannelMetrics(channelIndex int, kind ChannelKind) { upstream := s.getUpstreamByIndex(channelIndex, kind) if upstream == nil { return } metricsManager := s.getMetricsManager(kind) for _, baseURL := range upstream.GetAllBaseURLs() { for _, apiKey := range upstream.APIKeys { metricsManager.ResetKeyFailureState(baseURL, apiKey) } } prefix := kindSchedulerLogPrefix(kind) log.Printf("[%s-Reset] 渠道 [%d] %s 的熔断状态已重置(保留历史统计)", prefix, channelIndex, upstream.Name) } // ResetKeyMetrics 重置单个 Key 的指标 func (s *ChannelScheduler) ResetKeyMetrics(baseURL, apiKey string, kind ChannelKind) { s.getMetricsManager(kind).ResetKey(baseURL, apiKey) } // DeleteChannelMetrics 删除渠道的所有指标数据(内存 + 持久化) // 用于删除渠道时清理相关的统计数据 func (s *ChannelScheduler) DeleteChannelMetrics(upstream *config.UpstreamConfig, kind ChannelKind) { if upstream == nil { return } metricsManager := s.getMetricsManager(kind) // 合并活跃 Key 和历史 Key,一起清理 allKeys := append([]string{}, upstream.APIKeys...) allKeys = append(allKeys, upstream.HistoricalAPIKeys...) // MetricsManager 内部已有 apiType,无需外部传递 metricsManager.DeleteChannelMetrics(upstream.GetAllBaseURLs(), allKeys) prefix := kindSchedulerLogPrefix(kind) log.Printf("[%s-Delete] 渠道 %s 的指标数据已清理", prefix, upstream.Name) } // GetActiveChannelCount 获取活跃渠道数量 func (s *ChannelScheduler) GetActiveChannelCount(kind ChannelKind) int { return len(s.getActiveChannels(kind)) } // IsMultiChannelMode 判断是否为多渠道模式 func (s *ChannelScheduler) IsMultiChannelMode(kind ChannelKind) bool { return s.GetActiveChannelCount(kind) > 1 } // maskUserID 掩码 user_id(保护隐私) func maskUserID(userID string) string { if len(userID) <= 16 { return "***" } return userID[:8] + "***" + userID[len(userID)-4:] } // GetSortedURLsForChannel 获取渠道排序后的 URL 列表(非阻塞,立即返回) // 返回按动态排序的 URL 结果列表,包含原始索引用于指标记录 func (s *ChannelScheduler) GetSortedURLsForChannel( kind ChannelKind, channelIndex int, urls []string, ) []warmup.URLLatencyResult { if s.urlManager == nil || len(urls) <= 1 { // 无 URL 管理器或单 URL,返回默认结果 results := make([]warmup.URLLatencyResult, len(urls)) for i, url := range urls { results[i] = warmup.URLLatencyResult{ URL: url, OriginalIdx: i, Success: true, } } return results } return s.urlManager.GetSortedURLs(urlManagerChannelKey(kind, channelIndex), urls) } // MarkURLSuccess 标记 URL 成功 func (s *ChannelScheduler) MarkURLSuccess(kind ChannelKind, channelIndex int, url string) { if s.urlManager != nil { s.urlManager.MarkSuccess(urlManagerChannelKey(kind, channelIndex), url) } } // MarkURLFailure 标记 URL 失败,触发动态排序 func (s *ChannelScheduler) MarkURLFailure(kind ChannelKind, channelIndex int, url string) { if s.urlManager != nil { s.urlManager.MarkFailure(urlManagerChannelKey(kind, channelIndex), url) } } // InvalidateURLCache 使渠道 URL 状态失效 func (s *ChannelScheduler) InvalidateURLCache(kind ChannelKind, channelIndex int) { if s.urlManager != nil { s.urlManager.InvalidateChannel(urlManagerChannelKey(kind, channelIndex)) } } // GetURLManagerStats 获取 URL 管理器统计 func (s *ChannelScheduler) GetURLManagerStats() map[string]interface{} { if s.urlManager != nil { return s.urlManager.GetStats() } return nil } func kindSchedulerLogPrefix(kind ChannelKind) string { switch kind { case ChannelKindResponses: return "Scheduler-Responses" case ChannelKindGemini: return "Scheduler-Gemini" default: return "Scheduler" } } func urlManagerChannelKey(kind ChannelKind, channelIndex int) int { const stride = 1_000_000 return urlManagerChannelKeyOrdinal(kind)*stride + channelIndex } func urlManagerChannelKeyOrdinal(kind ChannelKind) int { switch kind { case ChannelKindResponses: return 1 case ChannelKindGemini: return 2 default: return 0 } } ================================================ FILE: backend-go/internal/scheduler/channel_scheduler_test.go ================================================ package scheduler import ( "context" "encoding/json" "os" "path/filepath" "testing" "time" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/metrics" "github.com/BenedictKing/claude-proxy/internal/session" "github.com/BenedictKing/claude-proxy/internal/warmup" ) // createTestConfigManager 创建测试用配置管理器 func createTestConfigManager(t *testing.T, cfg config.Config) (*config.ConfigManager, func()) { t.Helper() // 创建临时目录 tmpDir, err := os.MkdirTemp("", "scheduler-test-*") if err != nil { t.Fatalf("创建临时目录失败: %v", err) } // 创建临时配置文件 configFile := filepath.Join(tmpDir, "config.json") data, err := json.MarshalIndent(cfg, "", " ") if err != nil { os.RemoveAll(tmpDir) t.Fatalf("序列化配置失败: %v", err) } if err := os.WriteFile(configFile, data, 0644); err != nil { os.RemoveAll(tmpDir) t.Fatalf("写入配置文件失败: %v", err) } // 创建配置管理器 cfgManager, err := config.NewConfigManager(configFile) if err != nil { os.RemoveAll(tmpDir) t.Fatalf("创建配置管理器失败: %v", err) } cleanup := func() { cfgManager.Close() os.RemoveAll(tmpDir) } return cfgManager, cleanup } // createTestScheduler 创建测试用调度器 func createTestScheduler(t *testing.T, cfg config.Config) (*ChannelScheduler, func()) { t.Helper() cfgManager, cleanup := createTestConfigManager(t, cfg) messagesMetrics := metrics.NewMetricsManager() responsesMetrics := metrics.NewMetricsManager() geminiMetrics := metrics.NewMetricsManager() traceAffinity := session.NewTraceAffinityManager() urlManager := warmup.NewURLManager(30*time.Second, 3) scheduler := NewChannelScheduler(cfgManager, messagesMetrics, responsesMetrics, geminiMetrics, traceAffinity, urlManager) return scheduler, func() { messagesMetrics.Stop() responsesMetrics.Stop() geminiMetrics.Stop() cleanup() } } // TestPromotedChannelBypassesHealthCheck 测试促销渠道绕过健康检查 func TestPromotedChannelBypassesHealthCheck(t *testing.T) { // 设置促销截止时间为 5 分钟后 promotionUntil := time.Now().Add(5 * time.Minute) cfg := config.Config{ Upstream: []config.UpstreamConfig{ { Name: "normal-channel", BaseURL: "https://normal.example.com", APIKeys: []string{"sk-normal-key"}, Status: "active", Priority: 1, }, { Name: "promoted-channel", BaseURL: "https://promoted.example.com", APIKeys: []string{"sk-promoted-key"}, Status: "active", Priority: 2, PromotionUntil: &promotionUntil, }, }, } scheduler, cleanup := createTestScheduler(t, cfg) defer cleanup() // 模拟促销渠道之前有高失败率(使其不健康) metricsManager := scheduler.messagesMetricsManager for i := 0; i < 10; i++ { metricsManager.RecordFailure("https://promoted.example.com", "sk-promoted-key") } // 验证促销渠道确实不健康 isHealthy := metricsManager.IsChannelHealthyWithKeys("https://promoted.example.com", []string{"sk-promoted-key"}) if isHealthy { t.Fatal("促销渠道应该被标记为不健康") } // 选择渠道 - 促销渠道应该被选中,即使它不健康 result, err := scheduler.SelectChannel(context.Background(), "test-user", make(map[int]bool), ChannelKindMessages) if err != nil { t.Fatalf("选择渠道失败: %v", err) } if result.ChannelIndex != 1 { t.Errorf("期望选择促销渠道 (index=1),实际选择了 index=%d", result.ChannelIndex) } if result.Reason != "promotion_priority" { t.Errorf("期望选择原因为 promotion_priority,实际为 %s", result.Reason) } if result.Upstream.Name != "promoted-channel" { t.Errorf("期望选择 promoted-channel,实际选择了 %s", result.Upstream.Name) } } // TestPromotedChannelSkippedAfterFailure 测试促销渠道在本次请求失败后被跳过 func TestPromotedChannelSkippedAfterFailure(t *testing.T) { promotionUntil := time.Now().Add(5 * time.Minute) cfg := config.Config{ Upstream: []config.UpstreamConfig{ { Name: "normal-channel", BaseURL: "https://normal.example.com", APIKeys: []string{"sk-normal-key"}, Status: "active", Priority: 1, }, { Name: "promoted-channel", BaseURL: "https://promoted.example.com", APIKeys: []string{"sk-promoted-key"}, Status: "active", Priority: 2, PromotionUntil: &promotionUntil, }, }, } scheduler, cleanup := createTestScheduler(t, cfg) defer cleanup() // 模拟促销渠道在本次请求中已经失败 failedChannels := map[int]bool{ 1: true, // 促销渠道已失败 } // 选择渠道 - 应该跳过促销渠道,选择正常渠道 result, err := scheduler.SelectChannel(context.Background(), "test-user", failedChannels, ChannelKindMessages) if err != nil { t.Fatalf("选择渠道失败: %v", err) } if result.ChannelIndex != 0 { t.Errorf("期望选择正常渠道 (index=0),实际选择了 index=%d", result.ChannelIndex) } if result.Upstream.Name != "normal-channel" { t.Errorf("期望选择 normal-channel,实际选择了 %s", result.Upstream.Name) } } // TestNonPromotedChannelStillChecksHealth 测试非促销渠道仍然检查健康状态 func TestNonPromotedChannelStillChecksHealth(t *testing.T) { cfg := config.Config{ Upstream: []config.UpstreamConfig{ { Name: "unhealthy-channel", BaseURL: "https://unhealthy.example.com", APIKeys: []string{"sk-unhealthy-key"}, Status: "active", Priority: 1, }, { Name: "healthy-channel", BaseURL: "https://healthy.example.com", APIKeys: []string{"sk-healthy-key"}, Status: "active", Priority: 2, }, }, } scheduler, cleanup := createTestScheduler(t, cfg) defer cleanup() // 模拟第一个渠道不健康 metricsManager := scheduler.messagesMetricsManager for i := 0; i < 10; i++ { metricsManager.RecordFailure("https://unhealthy.example.com", "sk-unhealthy-key") } // 选择渠道 - 应该跳过不健康的渠道,选择健康的渠道 result, err := scheduler.SelectChannel(context.Background(), "test-user", make(map[int]bool), ChannelKindMessages) if err != nil { t.Fatalf("选择渠道失败: %v", err) } if result.ChannelIndex != 1 { t.Errorf("期望选择健康渠道 (index=1),实际选择了 index=%d", result.ChannelIndex) } if result.Upstream.Name != "healthy-channel" { t.Errorf("期望选择 healthy-channel,实际选择了 %s", result.Upstream.Name) } } // TestExpiredPromotionNotBypassHealthCheck 测试过期的促销不绕过健康检查 func TestExpiredPromotionNotBypassHealthCheck(t *testing.T) { // 设置促销截止时间为过去 promotionUntil := time.Now().Add(-5 * time.Minute) cfg := config.Config{ Upstream: []config.UpstreamConfig{ { Name: "healthy-channel", BaseURL: "https://healthy.example.com", APIKeys: []string{"sk-healthy-key"}, Status: "active", Priority: 1, }, { Name: "expired-promoted-channel", BaseURL: "https://expired.example.com", APIKeys: []string{"sk-expired-key"}, Status: "active", Priority: 2, PromotionUntil: &promotionUntil, // 已过期 }, }, } scheduler, cleanup := createTestScheduler(t, cfg) defer cleanup() // 模拟过期促销渠道不健康 metricsManager := scheduler.messagesMetricsManager for i := 0; i < 10; i++ { metricsManager.RecordFailure("https://expired.example.com", "sk-expired-key") } // 选择渠道 - 过期促销渠道不应该被优先选择,应该选择健康的渠道 result, err := scheduler.SelectChannel(context.Background(), "test-user", make(map[int]bool), ChannelKindMessages) if err != nil { t.Fatalf("选择渠道失败: %v", err) } if result.ChannelIndex != 0 { t.Errorf("期望选择健康渠道 (index=0),实际选择了 index=%d", result.ChannelIndex) } if result.Upstream.Name != "healthy-channel" { t.Errorf("期望选择 healthy-channel,实际选择了 %s", result.Upstream.Name) } } ================================================ FILE: backend-go/internal/session/manager.go ================================================ package session import ( "crypto/rand" "encoding/hex" "fmt" "log" "sync" "time" "github.com/BenedictKing/claude-proxy/internal/types" ) // Session 会话数据结构 type Session struct { ID string // sess_xxxxx Messages []types.ResponsesItem // 完整对话历史 LastResponseID string // 最后一个 response ID CreatedAt time.Time LastAccessAt time.Time TotalTokens int } // SessionManager 会话管理器 type SessionManager struct { sessions map[string]*Session // sessionID → Session responseMapping map[string]string // responseID → sessionID mu sync.RWMutex // 清理配置 maxAge time.Duration // 24小时 maxMessages int // 100条 maxTokens int // 100k } // NewSessionManager 创建会话管理器 func NewSessionManager(maxAge time.Duration, maxMessages int, maxTokens int) *SessionManager { sm := &SessionManager{ sessions: make(map[string]*Session), responseMapping: make(map[string]string), maxAge: maxAge, maxMessages: maxMessages, maxTokens: maxTokens, } // 启动定期清理 go sm.cleanupLoop() return sm } // GetOrCreateSession 获取或创建会话 func (sm *SessionManager) GetOrCreateSession(previousResponseID string) (*Session, error) { sm.mu.Lock() defer sm.mu.Unlock() // 如果提供了 previousResponseID,尝试查找对应的会话 if previousResponseID != "" { if sessionID, ok := sm.responseMapping[previousResponseID]; ok { if session, exists := sm.sessions[sessionID]; exists { session.LastAccessAt = time.Now() return session, nil } } // 如果找不到对应会话,返回错误 return nil, fmt.Errorf("无效的 previous_response_id: %s", previousResponseID) } // 创建新会话 sessionID := generateID("sess") session := &Session{ ID: sessionID, Messages: []types.ResponsesItem{}, CreatedAt: time.Now(), LastAccessAt: time.Now(), TotalTokens: 0, } sm.sessions[sessionID] = session log.Printf("[Session-Create] 创建新会话: %s", sessionID) return session, nil } // RecordResponseMapping 记录 responseID 到 sessionID 的映射 func (sm *SessionManager) RecordResponseMapping(responseID, sessionID string) { sm.mu.Lock() defer sm.mu.Unlock() sm.responseMapping[responseID] = sessionID log.Printf("[Session-Mapping] 记录映射: %s -> %s", responseID, sessionID) } // AppendMessage 追加消息到会话 func (sm *SessionManager) AppendMessage(sessionID string, item types.ResponsesItem, tokensUsed int) error { sm.mu.Lock() defer sm.mu.Unlock() session, exists := sm.sessions[sessionID] if !exists { return fmt.Errorf("会话不存在: %s", sessionID) } session.Messages = append(session.Messages, item) session.TotalTokens += tokensUsed session.LastAccessAt = time.Now() return nil } // UpdateLastResponseID 更新会话的最后一个 responseID func (sm *SessionManager) UpdateLastResponseID(sessionID, responseID string) error { sm.mu.Lock() defer sm.mu.Unlock() session, exists := sm.sessions[sessionID] if !exists { return fmt.Errorf("会话不存在: %s", sessionID) } session.LastResponseID = responseID return nil } // GetSession 获取会话(只读) func (sm *SessionManager) GetSession(sessionID string) (*Session, error) { sm.mu.RLock() defer sm.mu.RUnlock() session, exists := sm.sessions[sessionID] if !exists { return nil, fmt.Errorf("会话不存在: %s", sessionID) } return session, nil } // cleanupLoop 定期清理过期会话 func (sm *SessionManager) cleanupLoop() { ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() for range ticker.C { sm.cleanup() } } // cleanup 执行清理逻辑 func (sm *SessionManager) cleanup() { sm.mu.Lock() defer sm.mu.Unlock() now := time.Now() removedSessions := 0 removedMappings := 0 // 清理过期会话 for sessionID, session := range sm.sessions { shouldRemove := false // 时间过期 if now.Sub(session.LastAccessAt) > sm.maxAge { shouldRemove = true log.Printf("[Session-Cleanup] 清理过期会话 (时间): %s (最后访问: %v 前)", sessionID, now.Sub(session.LastAccessAt)) } // 消息数超限 if len(session.Messages) > sm.maxMessages { shouldRemove = true log.Printf("[Session-Cleanup] 清理过期会话 (消息数): %s (%d 条)", sessionID, len(session.Messages)) } // Token 超限 if session.TotalTokens > sm.maxTokens { shouldRemove = true log.Printf("[Session-Cleanup] 清理过期会话 (Token): %s (%d tokens)", sessionID, session.TotalTokens) } if shouldRemove { delete(sm.sessions, sessionID) removedSessions++ } } // 清理孤立的 responseID 映射 for responseID, sessionID := range sm.responseMapping { if _, exists := sm.sessions[sessionID]; !exists { delete(sm.responseMapping, responseID) removedMappings++ } } if removedSessions > 0 || removedMappings > 0 { log.Printf("[Session-Cleanup] 清理完成: 删除 %d 个会话, %d 个映射", removedSessions, removedMappings) log.Printf("[Session-Stats] 当前活跃会话: %d 个, 映射: %d 个", len(sm.sessions), len(sm.responseMapping)) } } // GetStats 获取统计信息 func (sm *SessionManager) GetStats() map[string]interface{} { sm.mu.RLock() defer sm.mu.RUnlock() return map[string]interface{}{ "total_sessions": len(sm.sessions), "total_mappings": len(sm.responseMapping), } } // generateID 生成唯一ID func generateID(prefix string) string { bytes := make([]byte, 16) if _, err := rand.Read(bytes); err != nil { // 降级方案:使用时间戳 return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano()) } return fmt.Sprintf("%s_%s", prefix, hex.EncodeToString(bytes)) } ================================================ FILE: backend-go/internal/session/trace_affinity.go ================================================ package session import ( "log" "os" "sync" "time" ) // affinityDebug 控制亲和性日志是否输出 // 通过环境变量 AFFINITY_DEBUG=true 启用 var affinityDebug = os.Getenv("AFFINITY_DEBUG") == "true" // TraceAffinity 记录 trace 与渠道的亲和关系 type TraceAffinity struct { ChannelIndex int LastUsedAt time.Time } // TraceAffinityManager 管理 trace 与渠道的亲和性 type TraceAffinityManager struct { mu sync.RWMutex affinity map[string]*TraceAffinity // key: user_id ttl time.Duration stopCh chan struct{} // 用于停止清理 goroutine } // NewTraceAffinityManager 创建 Trace 亲和性管理器 func NewTraceAffinityManager() *TraceAffinityManager { mgr := &TraceAffinityManager{ affinity: make(map[string]*TraceAffinity), ttl: 30 * time.Minute, // 默认 30 分钟无活动后过期 stopCh: make(chan struct{}), } // 启动定期清理 go mgr.cleanupLoop() return mgr } // NewTraceAffinityManagerWithTTL 创建带自定义 TTL 的管理器 func NewTraceAffinityManagerWithTTL(ttl time.Duration) *TraceAffinityManager { if ttl <= 0 { ttl = 30 * time.Minute } mgr := &TraceAffinityManager{ affinity: make(map[string]*TraceAffinity), ttl: ttl, stopCh: make(chan struct{}), } go mgr.cleanupLoop() return mgr } // GetPreferredChannel 获取 user_id 偏好的渠道 // 返回渠道索引和是否存在 func (m *TraceAffinityManager) GetPreferredChannel(userID string) (int, bool) { if userID == "" { return -1, false } m.mu.RLock() defer m.mu.RUnlock() affinity, exists := m.affinity[userID] if !exists { return -1, false } // 检查是否过期 if time.Since(affinity.LastUsedAt) > m.ttl { return -1, false } return affinity.ChannelIndex, true } // SetPreferredChannel 设置 user_id 偏好的渠道 func (m *TraceAffinityManager) SetPreferredChannel(userID string, channelIndex int) { if userID == "" { return } var logType int // 0=无, 1=新建, 2=变更 var oldChannel int m.mu.Lock() oldAffinity, existed := m.affinity[userID] if existed && oldAffinity.ChannelIndex != channelIndex { logType, oldChannel = 2, oldAffinity.ChannelIndex } else if !existed { logType = 1 } m.affinity[userID] = &TraceAffinity{ ChannelIndex: channelIndex, LastUsedAt: time.Now(), } m.mu.Unlock() if affinityDebug { if logType == 2 { log.Printf("[Affinity-Set] 用户亲和变更: %s -> 渠道[%d] (原渠道[%d])", maskUserID(userID), channelIndex, oldChannel) } else if logType == 1 { log.Printf("[Affinity-Set] 新建用户亲和: %s -> 渠道[%d]", maskUserID(userID), channelIndex) } } } // UpdateLastUsed 更新最后使用时间(续期) func (m *TraceAffinityManager) UpdateLastUsed(userID string) { if userID == "" { return } m.mu.Lock() defer m.mu.Unlock() if affinity, exists := m.affinity[userID]; exists { affinity.LastUsedAt = time.Now() } } // Remove 移除 user_id 的亲和记录 func (m *TraceAffinityManager) Remove(userID string) { var oldChannel int var existed bool m.mu.Lock() if affinity, exists := m.affinity[userID]; exists { oldChannel, existed = affinity.ChannelIndex, true delete(m.affinity, userID) } m.mu.Unlock() if affinityDebug && existed { log.Printf("[Affinity-Remove] 移除用户亲和: %s (原渠道[%d])", maskUserID(userID), oldChannel) } } // RemoveByChannel 移除指定渠道的所有亲和记录 // 用于渠道被禁用或删除时 func (m *TraceAffinityManager) RemoveByChannel(channelIndex int) { m.mu.Lock() removed := 0 for userID, affinity := range m.affinity { if affinity.ChannelIndex == channelIndex { delete(m.affinity, userID) removed++ } } m.mu.Unlock() if affinityDebug && removed > 0 { log.Printf("[Affinity-RemoveByChannel] 渠道[%d]被移除,清理了 %d 条亲和记录", channelIndex, removed) } } // Cleanup 清理过期的亲和记录 func (m *TraceAffinityManager) Cleanup() int { m.mu.Lock() now := time.Now() cleaned := 0 for userID, affinity := range m.affinity { if now.Sub(affinity.LastUsedAt) > m.ttl { delete(m.affinity, userID) cleaned++ } } ttl := m.ttl m.mu.Unlock() if affinityDebug && cleaned > 0 { log.Printf("[Affinity-Cleanup] 清理了 %d 条过期亲和记录 (TTL: %v)", cleaned, ttl) } return cleaned } // cleanupLoop 定期清理过期记录 func (m *TraceAffinityManager) cleanupLoop() { ticker := time.NewTicker(5 * time.Minute) // 每 5 分钟清理一次 defer ticker.Stop() for { select { case <-ticker.C: m.Cleanup() case <-m.stopCh: return } } } // Stop 停止清理 goroutine,释放资源 func (m *TraceAffinityManager) Stop() { close(m.stopCh) } // Size 返回当前亲和记录数量 func (m *TraceAffinityManager) Size() int { m.mu.RLock() defer m.mu.RUnlock() return len(m.affinity) } // GetTTL 获取 TTL 设置 func (m *TraceAffinityManager) GetTTL() time.Duration { return m.ttl } // GetAll 获取所有亲和记录(用于调试) func (m *TraceAffinityManager) GetAll() map[string]TraceAffinity { m.mu.RLock() defer m.mu.RUnlock() result := make(map[string]TraceAffinity, len(m.affinity)) for userID, affinity := range m.affinity { result[userID] = *affinity } return result } // maskUserID 掩码 user_id(保护隐私) // 使用 rune 切片确保 UTF-8 安全 func maskUserID(userID string) string { if userID == "" { return "***" } runes := []rune(userID) n := len(runes) switch { case n <= 4: return string(runes[:1]) + "***" case n <= 8: return string(runes[:2]) + "***" + string(runes[n-1:]) case n <= 16: return string(runes[:3]) + "***" + string(runes[n-2:]) default: return string(runes[:8]) + "***" + string(runes[n-4:]) } } ================================================ FILE: backend-go/internal/types/gemini.go ================================================ package types import "encoding/json" // ============================================================================ // Gemini API 常量 // ============================================================================ // DummyThoughtSignature 用于跳过 Gemini thought_signature 验证 // 参考: https://ai.google.dev/gemini-api/docs/thought-signatures const DummyThoughtSignature = "skip_thought_signature_validator" // StripThoughtSignatureMarker 特殊标记,表示需要完全移除 thought_signature 字段 // 用于 stripThoughtSignature 函数标记需要移除的字段 const StripThoughtSignatureMarker = "__STRIP_THOUGHT_SIGNATURE__" // ============================================================================ // Gemini API 请求结构 // ============================================================================ // GeminiRequest Gemini API 请求 type GeminiRequest struct { Contents []GeminiContent `json:"contents"` SystemInstruction *GeminiContent `json:"systemInstruction,omitempty"` Tools []GeminiTool `json:"tools,omitempty"` GenerationConfig *GeminiGenerationConfig `json:"generationConfig,omitempty"` SafetySettings []GeminiSafetySetting `json:"safetySettings,omitempty"` } // GeminiContent Gemini 内容 type GeminiContent struct { Parts []GeminiPart `json:"parts"` Role string `json:"role,omitempty"` // "user" 或 "model" } // GeminiPart Gemini 内容块 type GeminiPart struct { Text string `json:"text,omitempty"` InlineData *GeminiInlineData `json:"inlineData,omitempty"` FunctionCall *GeminiFunctionCall `json:"functionCall,omitempty"` FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"` FileData *GeminiFileData `json:"fileData,omitempty"` Thought bool `json:"thought,omitempty"` // 是否为 thinking 内容 } // UnmarshalJSON 自定义反序列化,兼容部分客户端将 thoughtSignature 放在 part 层级的情况(而非 functionCall 内部) // 示例(Gemini CLI): // // { // "functionCall": { ... }, // "thoughtSignature": "..." // } func (p *GeminiPart) UnmarshalJSON(data []byte) error { type partAlias GeminiPart var raw struct { partAlias ThoughtSignatureCamel string `json:"thoughtSignature,omitempty"` ThoughtSignatureSnake string `json:"thought_signature,omitempty"` } if err := json.Unmarshal(data, &raw); err != nil { return err } *p = GeminiPart(raw.partAlias) // 兼容:当签名出现在 part 层级时,将其归一化到 functionCall 内部(内部存储即可) if p.FunctionCall == nil || p.FunctionCall.ThoughtSignature != "" { return nil } if raw.ThoughtSignatureSnake != "" { p.FunctionCall.ThoughtSignature = raw.ThoughtSignatureSnake } else if raw.ThoughtSignatureCamel != "" { p.FunctionCall.ThoughtSignature = raw.ThoughtSignatureCamel } return nil } // MarshalJSON 自定义序列化:Gemini thoughtSignature 字段位于 part 层级(与 functionCall 同级)。 func (p GeminiPart) MarshalJSON() ([]byte, error) { type partAlias GeminiPart out := struct { partAlias ThoughtSignature string `json:"thoughtSignature,omitempty"` }{ partAlias: partAlias(p), } if p.FunctionCall != nil { sig := p.FunctionCall.ThoughtSignature if sig != "" && sig != StripThoughtSignatureMarker { out.ThoughtSignature = sig } } return json.Marshal(out) } // GeminiInlineData 内联数据(图片、音频等) type GeminiInlineData struct { MimeType string `json:"mimeType"` Data string `json:"data"` // base64 编码 } // GeminiFileData 文件引用(File API) type GeminiFileData struct { MimeType string `json:"mimeType,omitempty"` FileURI string `json:"fileUri"` } // GeminiFunctionCall 函数调用 // 注意:thought_signature 有两种格式: // - 下划线格式(thought_signature):Google 官方 API // - 驼峰格式(thoughtSignature):Gemini CLI 等第三方客户端 // 为了保持透传,我们记录原始格式并在输出时使用相同格式 type GeminiFunctionCall struct { Name string `json:"name"` Args map[string]interface{} `json:"args"` ThoughtSignature string `json:"-"` // thoughtSignature 位于 part 层级,仅内部使用 } // GeminiFunctionResponse 函数响应 type GeminiFunctionResponse struct { Name string `json:"name"` Response map[string]interface{} `json:"response"` } // GeminiTool 工具定义 type GeminiTool struct { FunctionDeclarations []GeminiFunctionDeclaration `json:"functionDeclarations,omitempty"` } // GeminiFunctionDeclaration 函数声明 type GeminiFunctionDeclaration struct { Name string `json:"name"` Description string `json:"description,omitempty"` Parameters interface{} `json:"parameters,omitempty"` // JSON Schema } // UnmarshalJSON 自定义反序列化: // - 支持 parameters(官方字段) // - 兼容部分客户端使用 parametersJsonSchema(例如 Gemini CLI) // 为了让上游模型正确理解参数结构,统一写入 Parameters,并在序列化时输出为 parameters。 func (fd *GeminiFunctionDeclaration) UnmarshalJSON(data []byte) error { var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { return err } if nameRaw, ok := raw["name"]; ok { if err := json.Unmarshal(nameRaw, &fd.Name); err != nil { return err } } if descRaw, ok := raw["description"]; ok { if err := json.Unmarshal(descRaw, &fd.Description); err != nil { return err } } var paramsRaw json.RawMessage if v, ok := raw["parameters"]; ok { paramsRaw = v } else if v, ok := raw["parametersJsonSchema"]; ok { paramsRaw = v } if paramsRaw != nil { var params interface{} if err := json.Unmarshal(paramsRaw, ¶ms); err != nil { return err } fd.Parameters = sanitizeGeminiToolSchema(params) } return nil } // sanitizeGeminiToolSchema 清洗工具参数 schema,以兼容部分上游对 parameters 字段的严格校验。 // // 已知不兼容字段: // - $schema // - additionalProperties // - const(转换为 enum: [const]) func sanitizeGeminiToolSchema(v interface{}) interface{} { switch vv := v.(type) { case map[string]interface{}: out := make(map[string]interface{}, len(vv)) var constValue interface{} hasConst := false for k, val := range vv { switch k { case "$schema", "additionalProperties": continue case "const": constValue = val hasConst = true continue default: out[k] = sanitizeGeminiToolSchema(val) } } if hasConst { if _, ok := out["enum"]; !ok { out["enum"] = []interface{}{sanitizeGeminiToolSchema(constValue)} } } return out case []interface{}: out := make([]interface{}, len(vv)) for i := range vv { out[i] = sanitizeGeminiToolSchema(vv[i]) } return out default: return v } } // GeminiGenerationConfig 生成配置 type GeminiGenerationConfig struct { Temperature *float64 `json:"temperature,omitempty"` TopP *float64 `json:"topP,omitempty"` TopK *int `json:"topK,omitempty"` MaxOutputTokens int `json:"maxOutputTokens,omitempty"` StopSequences []string `json:"stopSequences,omitempty"` ResponseMimeType string `json:"responseMimeType,omitempty"` // "application/json" / "text/plain" ResponseModalities []string `json:"responseModalities,omitempty"` // ["TEXT", "IMAGE", "AUDIO"] ThinkingConfig *GeminiThinkingConfig `json:"thinkingConfig,omitempty"` } // GeminiThinkingConfig 推理配置 type GeminiThinkingConfig struct { IncludeThoughts bool `json:"includeThoughts,omitempty"` ThinkingBudget *int32 `json:"thinkingBudget,omitempty"` // 推理 token 预算 ThinkingLevel string `json:"thinkingLevel,omitempty"` // 或使用 level 替代 budget } // GeminiSafetySetting 安全设置 type GeminiSafetySetting struct { Category string `json:"category"` Threshold string `json:"threshold"` } // ============================================================================ // Gemini API 响应结构 // ============================================================================ // GeminiResponse Gemini API 响应 type GeminiResponse struct { Candidates []GeminiCandidate `json:"candidates"` PromptFeedback *GeminiPromptFeedback `json:"promptFeedback,omitempty"` UsageMetadata *GeminiUsageMetadata `json:"usageMetadata,omitempty"` ModelVersion string `json:"modelVersion,omitempty"` } // GeminiCandidate 候选响应 type GeminiCandidate struct { Content *GeminiContent `json:"content,omitempty"` FinishReason string `json:"finishReason,omitempty"` // "STOP", "MAX_TOKENS", "SAFETY", "RECITATION" SafetyRatings []GeminiSafetyRating `json:"safetyRatings,omitempty"` Index int `json:"index,omitempty"` } // GeminiPromptFeedback 提示反馈 type GeminiPromptFeedback struct { BlockReason string `json:"blockReason,omitempty"` SafetyRatings []GeminiSafetyRating `json:"safetyRatings,omitempty"` } // GeminiSafetyRating 安全评级 type GeminiSafetyRating struct { Category string `json:"category"` Probability string `json:"probability"` } // GeminiUsageMetadata 使用统计 type GeminiUsageMetadata struct { PromptTokenCount int `json:"promptTokenCount"` CandidatesTokenCount int `json:"candidatesTokenCount"` TotalTokenCount int `json:"totalTokenCount"` CachedContentTokenCount int `json:"cachedContentTokenCount,omitempty"` ThoughtsTokenCount int `json:"thoughtsTokenCount,omitempty"` // 推理 tokens } // ============================================================================ // Gemini 流式响应结构 // ============================================================================ // GeminiStreamChunk Gemini 流式响应块 type GeminiStreamChunk struct { Candidates []GeminiCandidate `json:"candidates,omitempty"` UsageMetadata *GeminiUsageMetadata `json:"usageMetadata,omitempty"` } // ============================================================================ // Gemini 错误响应结构 // ============================================================================ // GeminiError Gemini 错误响应 type GeminiError struct { Error GeminiErrorDetail `json:"error"` } // GeminiErrorDetail Gemini 错误详情 type GeminiErrorDetail struct { Code int `json:"code"` Message string `json:"message"` Status string `json:"status"` } ================================================ FILE: backend-go/internal/types/gemini_test.go ================================================ package types import ( "encoding/json" "testing" ) func TestGeminiPart_UnmarshalJSON_ThoughtSignatureAtPartLevel(t *testing.T) { t.Run("驼峰 thoughtSignature 在 part 层级时归一化到 functionCall,并在 part 层级输出 thoughtSignature", func(t *testing.T) { input := `{"functionCall":{"name":"list_directory","args":{"path":"."}},"thoughtSignature":"sig_camel"}` var part GeminiPart if err := json.Unmarshal([]byte(input), &part); err != nil { t.Fatalf("UnmarshalJSON 失败: %v", err) } if part.FunctionCall == nil { t.Fatalf("FunctionCall 为空") } if part.FunctionCall.ThoughtSignature != "sig_camel" { t.Fatalf("ThoughtSignature=%q, want=%q", part.FunctionCall.ThoughtSignature, "sig_camel") } outBytes, err := json.Marshal(part) if err != nil { t.Fatalf("Marshal 失败: %v", err) } var got map[string]interface{} if err := json.Unmarshal(outBytes, &got); err != nil { t.Fatalf("解析输出 JSON 失败: %v", err) } if v, ok := got["thoughtSignature"]; !ok || v != "sig_camel" { t.Fatalf("part.thoughtSignature=%v, want=%v", v, "sig_camel") } fc, ok := got["functionCall"].(map[string]interface{}) if !ok { t.Fatalf("functionCall 类型=%T, want=map[string]interface{}", got["functionCall"]) } if _, ok := fc["thoughtSignature"]; ok { t.Fatalf("不应在 functionCall 内输出 thoughtSignature: %v", fc) } if _, ok := fc["thought_signature"]; ok { t.Fatalf("不应在 functionCall 内输出 thought_signature: %v", fc) } }) t.Run("下划线 thought_signature 在 part 层级时归一化到 functionCall,并在 part 层级输出 thoughtSignature", func(t *testing.T) { input := `{"functionCall":{"name":"list_directory","args":{"path":"."}},"thought_signature":"sig_snake"}` var part GeminiPart if err := json.Unmarshal([]byte(input), &part); err != nil { t.Fatalf("UnmarshalJSON 失败: %v", err) } if part.FunctionCall == nil { t.Fatalf("FunctionCall 为空") } if part.FunctionCall.ThoughtSignature != "sig_snake" { t.Fatalf("ThoughtSignature=%q, want=%q", part.FunctionCall.ThoughtSignature, "sig_snake") } outBytes, err := json.Marshal(part) if err != nil { t.Fatalf("Marshal 失败: %v", err) } var got map[string]interface{} if err := json.Unmarshal(outBytes, &got); err != nil { t.Fatalf("解析输出 JSON 失败: %v", err) } if v, ok := got["thoughtSignature"]; !ok || v != "sig_snake" { t.Fatalf("part.thoughtSignature=%v, want=%v", v, "sig_snake") } if _, ok := got["thought_signature"]; ok { t.Fatalf("不应在 part 层级输出 thought_signature: %v", got) } fc, ok := got["functionCall"].(map[string]interface{}) if !ok { t.Fatalf("functionCall 类型=%T, want=map[string]interface{}", got["functionCall"]) } if _, ok := fc["thoughtSignature"]; ok { t.Fatalf("不应在 functionCall 内输出 thoughtSignature: %v", fc) } if _, ok := fc["thought_signature"]; ok { t.Fatalf("不应在 functionCall 内输出 thought_signature: %v", fc) } }) } func TestGeminiFunctionDeclaration_UnmarshalJSON_ParametersJsonSchema(t *testing.T) { input := `{ "name": "list_directory", "description": "Lists the names of files and subdirectories directly within a specified directory path.", "parametersJsonSchema": { "type": "object", "properties": { "dir_path": { "type": "string" } }, "required": ["dir_path"] } }` var decl GeminiFunctionDeclaration if err := json.Unmarshal([]byte(input), &decl); err != nil { t.Fatalf("UnmarshalJSON 失败: %v", err) } if decl.Name != "list_directory" { t.Fatalf("Name=%q, want=%q", decl.Name, "list_directory") } if decl.Parameters == nil { t.Fatalf("Parameters 为空,期望从 parametersJsonSchema 读取") } outBytes, err := json.Marshal(decl) if err != nil { t.Fatalf("Marshal 失败: %v", err) } var got map[string]interface{} if err := json.Unmarshal(outBytes, &got); err != nil { t.Fatalf("解析输出 JSON 失败: %v", err) } if _, ok := got["parameters"]; !ok { t.Fatalf("输出缺少 parameters 字段: %v", got) } if _, ok := got["parametersJsonSchema"]; ok { t.Fatalf("不应输出 parametersJsonSchema 字段: %v", got) } } func TestGeminiFunctionDeclaration_UnmarshalJSON_SanitizeParametersSchema(t *testing.T) { input := `{ "name": "delegate_to_agent", "description": "Delegates a task to a specialized sub-agent.", "parametersJsonSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "additionalProperties": false, "properties": { "agent_name": { "type": "string", "const": "codebase_investigator", "additionalProperties": false } }, "required": ["agent_name"] } }` var decl GeminiFunctionDeclaration if err := json.Unmarshal([]byte(input), &decl); err != nil { t.Fatalf("UnmarshalJSON 失败: %v", err) } params, ok := decl.Parameters.(map[string]interface{}) if !ok { t.Fatalf("Parameters 类型=%T, want=map[string]interface{}", decl.Parameters) } if _, ok := params["$schema"]; ok { t.Fatalf("不应包含 $schema: %v", params) } if _, ok := params["additionalProperties"]; ok { t.Fatalf("不应包含 additionalProperties: %v", params) } props, ok := params["properties"].(map[string]interface{}) if !ok { t.Fatalf("properties 类型=%T, want=map[string]interface{}", params["properties"]) } agentName, ok := props["agent_name"].(map[string]interface{}) if !ok { t.Fatalf("properties.agent_name 类型=%T, want=map[string]interface{}", props["agent_name"]) } if _, ok := agentName["const"]; ok { t.Fatalf("不应包含 const: %v", agentName) } if _, ok := agentName["additionalProperties"]; ok { t.Fatalf("不应包含 additionalProperties(嵌套): %v", agentName) } enum, ok := agentName["enum"].([]interface{}) if !ok || len(enum) != 1 || enum[0] != "codebase_investigator" { t.Fatalf("agent_name.enum=%v, want=%v", agentName["enum"], []interface{}{"codebase_investigator"}) } outBytes, err := json.Marshal(decl) if err != nil { t.Fatalf("Marshal 失败: %v", err) } var got map[string]interface{} if err := json.Unmarshal(outBytes, &got); err != nil { t.Fatalf("解析输出 JSON 失败: %v", err) } outParams, ok := got["parameters"].(map[string]interface{}) if !ok { t.Fatalf("输出 parameters 类型=%T, want=map[string]interface{}", got["parameters"]) } if _, ok := outParams["$schema"]; ok { t.Fatalf("输出不应包含 $schema: %v", outParams) } if _, ok := outParams["additionalProperties"]; ok { t.Fatalf("输出不应包含 additionalProperties: %v", outParams) } outProps, ok := outParams["properties"].(map[string]interface{}) if !ok { t.Fatalf("输出 properties 类型=%T, want=map[string]interface{}", outParams["properties"]) } outAgentName, ok := outProps["agent_name"].(map[string]interface{}) if !ok { t.Fatalf("输出 properties.agent_name 类型=%T, want=map[string]interface{}", outProps["agent_name"]) } if _, ok := outAgentName["const"]; ok { t.Fatalf("输出不应包含 const: %v", outAgentName) } } ================================================ FILE: backend-go/internal/types/responses.go ================================================ package types // ============== Responses API 类型定义 ============== // ResponsesRequest Responses API 请求 type ResponsesRequest struct { Model string `json:"model"` Instructions string `json:"instructions,omitempty"` // 系统指令(映射为 system message) Input interface{} `json:"input"` // string 或 []ResponsesItem PreviousResponseID string `json:"previous_response_id,omitempty"` Store *bool `json:"store,omitempty"` // 默认 true MaxTokens int `json:"max_tokens,omitempty"` // 最大 tokens Temperature float64 `json:"temperature,omitempty"` // 温度参数 TopP float64 `json:"top_p,omitempty"` // top_p 参数 FrequencyPenalty float64 `json:"frequency_penalty,omitempty"` // 频率惩罚 PresencePenalty float64 `json:"presence_penalty,omitempty"` // 存在惩罚 Stream bool `json:"stream,omitempty"` // 是否流式输出 Stop interface{} `json:"stop,omitempty"` // 停止序列 (string 或 []string) User string `json:"user,omitempty"` // 用户标识 StreamOptions interface{} `json:"stream_options,omitempty"` // 流式选项 // TransformerMetadata 转换器元数据(仅内存使用,不序列化) // 用于在单次请求的转换流程中保留原始格式信息,如 system 数组格式等 // 注意:此字段不会通过 JSON 序列化保留,仅在同一请求处理链中有效 TransformerMetadata map[string]interface{} `json:"-"` } // ResponsesItem Responses API 消息项 type ResponsesItem struct { Type string `json:"type"` // message, text, tool_call, tool_result Role string `json:"role,omitempty"` // user, assistant (用于 type=message) Content interface{} `json:"content"` // string 或 []ContentBlock ToolUse *ToolUse `json:"tool_use,omitempty"` } // ContentBlock 内容块(用于嵌套 content 数组) type ContentBlock struct { Type string `json:"type"` // input_text, output_text Text string `json:"text"` } // ToolUse 工具使用定义 type ToolUse struct { ID string `json:"id"` Name string `json:"name"` Input interface{} `json:"input"` } // ResponsesResponse Responses API 响应 type ResponsesResponse struct { ID string `json:"id"` Model string `json:"model"` Output []ResponsesItem `json:"output"` Status string `json:"status"` // completed, failed PreviousID string `json:"previous_id,omitempty"` Usage ResponsesUsage `json:"usage"` Created int64 `json:"created,omitempty"` } // ResponsesUsage Responses API 使用统计 // 完整支持 OpenAI Responses API 和 Claude API 的详细 usage 字段 // 参考 claude-code-hub 实现,支持缓存 TTL 细分 (5m/1h) type ResponsesUsage struct { InputTokens int `json:"input_tokens"` InputTokensDetails *InputTokensDetails `json:"input_tokens_details,omitempty"` OutputTokens int `json:"output_tokens"` OutputTokensDetails *OutputTokensDetails `json:"output_tokens_details,omitempty"` TotalTokens int `json:"total_tokens"` // Claude 扩展字段(缓存创建统计,用于精确计费) CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` CacheCreation5mInputTokens int `json:"cache_creation_5m_input_tokens,omitempty"` // 5分钟 TTL CacheCreation1hInputTokens int `json:"cache_creation_1h_input_tokens,omitempty"` // 1小时 TTL CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` CacheTTL string `json:"cache_ttl,omitempty"` // "5m" | "1h" | "mixed" } // InputTokensDetails 输入 Token 详细统计 type InputTokensDetails struct { CachedTokens int `json:"cached_tokens"` } // OutputTokensDetails 输出 Token 详细统计 type OutputTokensDetails struct { ReasoningTokens int `json:"reasoning_tokens"` } // ResponsesStreamEvent Responses API 流式事件 type ResponsesStreamEvent struct { ID string `json:"id,omitempty"` Model string `json:"model,omitempty"` Output []ResponsesItem `json:"output,omitempty"` Status string `json:"status,omitempty"` PreviousID string `json:"previous_id,omitempty"` Usage *ResponsesUsage `json:"usage,omitempty"` Type string `json:"type,omitempty"` // delta, done Delta *ResponsesDelta `json:"delta,omitempty"` } // ResponsesDelta 流式增量数据 type ResponsesDelta struct { Type string `json:"type,omitempty"` Content interface{} `json:"content,omitempty"` } ================================================ FILE: backend-go/internal/types/types.go ================================================ package types // ClaudeRequest Claude 请求结构 type ClaudeRequest struct { Model string `json:"model"` Messages []ClaudeMessage `json:"messages"` System interface{} `json:"system,omitempty"` // string 或 content 数组 MaxTokens int `json:"max_tokens,omitempty"` Temperature float64 `json:"temperature,omitempty"` Stream bool `json:"stream,omitempty"` Tools []ClaudeTool `json:"tools,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` // Claude Code CLI 等客户端发送的元数据 } // ClaudeMessage Claude 消息 type ClaudeMessage struct { Role string `json:"role"` Content interface{} `json:"content"` // string 或 content 数组 } // CacheControl Anthropic 缓存控制 // 用于 Claude API 请求,会序列化到 JSON(仅在发送给 Anthropic 时有效) type CacheControl struct { Type string `json:"type,omitempty"` // "ephemeral" } // ClaudeContent Claude 内容块 type ClaudeContent struct { Type string `json:"type"` // text, tool_use, tool_result Text string `json:"text,omitempty"` ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Input interface{} `json:"input,omitempty"` ToolUseID string `json:"tool_use_id,omitempty"` CacheControl *CacheControl `json:"cache_control,omitempty"` } // ClaudeTool Claude 工具定义 type ClaudeTool struct { Name string `json:"name"` Description string `json:"description,omitempty"` InputSchema interface{} `json:"input_schema"` CacheControl *CacheControl `json:"cache_control,omitempty"` } // ClaudeResponse Claude 响应 type ClaudeResponse struct { ID string `json:"id"` Type string `json:"type"` Role string `json:"role"` Content []ClaudeContent `json:"content"` StopReason string `json:"stop_reason,omitempty"` Usage *Usage `json:"usage,omitempty"` } // OpenAIRequest OpenAI 请求结构 type OpenAIRequest struct { Model string `json:"model"` Messages []OpenAIMessage `json:"messages"` MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` Temperature float64 `json:"temperature,omitempty"` Stream bool `json:"stream,omitempty"` Tools []OpenAITool `json:"tools,omitempty"` ToolChoice string `json:"tool_choice,omitempty"` } // OpenAIMessage OpenAI 消息 type OpenAIMessage struct { Role string `json:"role"` Content interface{} `json:"content"` // string 或 null ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` } // OpenAIToolCall OpenAI 工具调用 type OpenAIToolCall struct { ID string `json:"id"` Type string `json:"type"` Function OpenAIToolCallFunction `json:"function"` } // OpenAIToolCallFunction OpenAI 工具调用函数 type OpenAIToolCallFunction struct { Name string `json:"name"` Arguments string `json:"arguments"` } // OpenAITool OpenAI 工具定义 type OpenAITool struct { Type string `json:"type"` Function OpenAIToolFunction `json:"function"` } // OpenAIToolFunction OpenAI 工具函数 type OpenAIToolFunction struct { Name string `json:"name"` Description string `json:"description,omitempty"` Parameters interface{} `json:"parameters"` } // OpenAIResponse OpenAI 响应 type OpenAIResponse struct { ID string `json:"id"` Choices []OpenAIChoice `json:"choices"` Usage *Usage `json:"usage,omitempty"` } // OpenAIChoice OpenAI 选择 type OpenAIChoice struct { Message OpenAIMessage `json:"message"` FinishReason string `json:"finish_reason,omitempty"` } // Usage 使用情况统计 // 完整支持 Claude API 的详细 usage 字段,包括缓存 TTL 细分 type Usage struct { InputTokens int `json:"input_tokens,omitempty"` OutputTokens int `json:"output_tokens,omitempty"` CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` // 缓存 TTL 细分(参考 claude-code-hub) CacheCreation5mInputTokens int `json:"cache_creation_5m_input_tokens,omitempty"` // 5分钟 TTL CacheCreation1hInputTokens int `json:"cache_creation_1h_input_tokens,omitempty"` // 1小时 TTL CacheTTL string `json:"cache_ttl,omitempty"` // "5m" | "1h" | "mixed" // OpenAI 兼容字段 PromptTokens int `json:"prompt_tokens,omitempty"` CompletionTokens int `json:"completion_tokens,omitempty"` } // ProviderRequest 提供商请求(通用) type ProviderRequest struct { URL string Method string Headers map[string]string Body interface{} } // ProviderResponse 提供商响应(通用) type ProviderResponse struct { StatusCode int Headers map[string][]string Body []byte Stream bool } ================================================ FILE: backend-go/internal/utils/compression.go ================================================ package utils import ( "bytes" "compress/gzip" "io" "log" "net/http" ) // DecompressGzipIfNeeded 检测并解压缩 gzip 响应体 // 这是一个兜底机制,用于处理错误响应等特殊场景 // 正常情况下,Go 的 http.Client 会自动处理 gzip 解压缩 func DecompressGzipIfNeeded(resp *http.Response, bodyBytes []byte) []byte { // 检查 Content-Encoding 头 if resp.Header.Get("Content-Encoding") != "gzip" { return bodyBytes } // 尝试解压缩 reader, err := gzip.NewReader(bytes.NewReader(bodyBytes)) if err != nil { log.Printf("[Gzip] 警告: 创建 gzip reader 失败: %v", err) return bodyBytes } defer reader.Close() decompressed, err := io.ReadAll(reader) if err != nil { log.Printf("[Gzip] 警告: 解压缩 gzip 响应体失败: %v", err) return bodyBytes } return decompressed } ================================================ FILE: backend-go/internal/utils/headers.go ================================================ package utils import ( "net/http" "strings" "github.com/gin-gonic/gin" ) // PrepareUpstreamHeaders 准备上游请求头(统一头部处理逻辑) // 保留原始请求头,移除代理相关头部,设置认证头 // 注意:此函数适用于Claude类型渠道,对于其他类型请使用 PrepareMinimalHeaders func PrepareUpstreamHeaders(c *gin.Context, targetHost string) http.Header { headers := c.Request.Header.Clone() // 设置正确的Host头部 headers.Set("Host", targetHost) // 移除代理相关头部 headers.Del("x-proxy-key") headers.Del("X-Forwarded-Host") headers.Del("X-Forwarded-Proto") // 移除 Accept-Encoding,让 Go 的 http.Client 自动处理 gzip 压缩/解压缩 // 这样可以避免在原始请求包含 Accept-Encoding 时 Go 不自动解压缩的问题 headers.Del("Accept-Encoding") return headers } // PrepareMinimalHeaders 准备最小化请求头(适用于非Claude渠道如OpenAI、Gemini等) // 只保留必要的头部:Content-Type和Host,不包含任何Anthropic特定头部 // 注意:不设置Accept-Encoding,让Go的http.Client自动处理gzip压缩 func PrepareMinimalHeaders(targetHost string) http.Header { headers := http.Header{} // 只设置最基本的头部 headers.Set("Host", targetHost) headers.Set("Content-Type", "application/json") // 不显式设置Accept-Encoding,让Go的http.Client自动添加并处理gzip解压 return headers } // SetAuthenticationHeader 设置认证头部(根据密钥格式智能选择) func SetAuthenticationHeader(headers http.Header, apiKey string) { // 移除旧的认证头 headers.Del("authorization") headers.Del("x-api-key") headers.Del("x-goog-api-key") // Claude 官方密钥格式(sk-ant-api03-xxx)使用 x-api-key // 符合 Claude API 官方推荐的认证方式 if strings.HasPrefix(apiKey, "sk-ant-") { headers.Set("x-api-key", apiKey) } else { // 其他格式密钥使用 Authorization: Bearer // 适用于 OpenAI、自定义密钥等 headers.Set("Authorization", "Bearer "+apiKey) } } // SetGeminiAuthenticationHeader 设置Gemini认证头部 func SetGeminiAuthenticationHeader(headers http.Header, apiKey string) { headers.Del("authorization") headers.Del("x-api-key") headers.Set("x-goog-api-key", apiKey) } // EnsureCompatibleUserAgent 确保兼容的User-Agent(仅在必要时设置) func EnsureCompatibleUserAgent(headers http.Header, serviceType string) { userAgent := headers.Get("User-Agent") // 仅在Claude服务类型且用户未设置或设置不正确时才修改 if serviceType == "claude" { if userAgent == "" || !strings.HasPrefix(strings.ToLower(userAgent), "claude-cli") { headers.Set("User-Agent", "claude-cli/2.0.34 (external, cli)") } } } // ForwardResponseHeaders 转发上游响应头到客户端 // 作为透明代理,应该转发所有响应头,只过滤框架自动处理的头部 func ForwardResponseHeaders(upstreamHeaders http.Header, clientWriter http.ResponseWriter) { // 不应转发的头部列表(由框架或代理层自动处理) skipHeaders := map[string]bool{ "transfer-encoding": true, // 由框架自动处理 "content-length": true, // 由框架自动处理 "connection": true, // 代理层控制 "content-encoding": true, // 如果已解压则不应转发 } // 复制所有上游响应头到客户端 for key, values := range upstreamHeaders { lowerKey := strings.ToLower(key) // 跳过不应转发的头部 if skipHeaders[lowerKey] { continue } // 转发头部(可能有多个值) for _, value := range values { clientWriter.Header().Add(key, value) } } } ================================================ FILE: backend-go/internal/utils/headers_test.go ================================================ package utils import ( "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" ) func TestPrepareUpstreamHeaders(t *testing.T) { gin.SetMode(gin.TestMode) tests := []struct { name string headers map[string]string targetHost string wantHost string shouldExist map[string]bool }{ { name: "移除代理相关头部", headers: map[string]string{ "Content-Type": "application/json", "x-proxy-key": "secret", "X-Forwarded-Host": "original.host", "X-Forwarded-Proto": "https", }, targetHost: "upstream.api.com", wantHost: "upstream.api.com", shouldExist: map[string]bool{ "Content-Type": true, "x-proxy-key": false, "X-Forwarded-Host": false, "X-Forwarded-Proto": false, }, }, { name: "保留其他头部", headers: map[string]string{ "Content-Type": "application/json", "User-Agent": "TestClient/1.0", "Accept": "*/*", "Custom-Header": "custom-value", }, targetHost: "api.example.com", wantHost: "api.example.com", shouldExist: map[string]bool{ "Content-Type": true, "User-Agent": true, "Accept": true, "Custom-Header": true, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // 创建测试请求 req := httptest.NewRequest("POST", "/test", nil) for k, v := range tt.headers { req.Header.Set(k, v) } // 创建Gin上下文 w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = req // 调用函数 result := PrepareUpstreamHeaders(c, tt.targetHost) // 验证Host头部 if result.Get("Host") != tt.wantHost { t.Errorf("Host = %v, want %v", result.Get("Host"), tt.wantHost) } // 验证头部是否存在 for header, shouldExist := range tt.shouldExist { exists := result.Get(header) != "" if exists != shouldExist { t.Errorf("Header %s existence = %v, want %v", header, exists, shouldExist) } } }) } } func TestSetAuthenticationHeader(t *testing.T) { tests := []struct { name string apiKey string wantXApiKey string wantAuthorization string }{ { name: "Claude官方格式密钥", apiKey: "sk-ant-api03-1234567890", wantXApiKey: "sk-ant-api03-1234567890", wantAuthorization: "", }, { name: "通用Bearer格式密钥", apiKey: "sk-1234567890abcdef", wantXApiKey: "", wantAuthorization: "Bearer sk-1234567890abcdef", }, { name: "其他格式密钥", apiKey: "custom-key-format", wantXApiKey: "", wantAuthorization: "Bearer custom-key-format", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { headers := http.Header{} SetAuthenticationHeader(headers, tt.apiKey) if tt.wantXApiKey != "" { if got := headers.Get("x-api-key"); got != tt.wantXApiKey { t.Errorf("x-api-key = %v, want %v", got, tt.wantXApiKey) } if headers.Get("Authorization") != "" { t.Errorf("Authorization should be empty, got %v", headers.Get("Authorization")) } } else { if got := headers.Get("Authorization"); got != tt.wantAuthorization { t.Errorf("Authorization = %v, want %v", got, tt.wantAuthorization) } if headers.Get("x-api-key") != "" { t.Errorf("x-api-key should be empty, got %v", headers.Get("x-api-key")) } } }) } } func TestSetGeminiAuthenticationHeader(t *testing.T) { headers := http.Header{} apiKey := "AIzaSyABC123DEF456" SetGeminiAuthenticationHeader(headers, apiKey) if got := headers.Get("x-goog-api-key"); got != apiKey { t.Errorf("x-goog-api-key = %v, want %v", got, apiKey) } // 验证其他认证头被删除 if headers.Get("authorization") != "" { t.Errorf("authorization should be empty, got %v", headers.Get("authorization")) } if headers.Get("x-api-key") != "" { t.Errorf("x-api-key should be empty, got %v", headers.Get("x-api-key")) } } func TestEnsureCompatibleUserAgent(t *testing.T) { tests := []struct { name string serviceType string initialUA string expectedUA string shouldBeChanged bool }{ { name: "Claude服务 - 空User-Agent", serviceType: "claude", initialUA: "", expectedUA: "claude-cli/2.0.34 (external, cli)", shouldBeChanged: true, }, { name: "Claude服务 - 非Claude-CLI User-Agent", serviceType: "claude", initialUA: "Mozilla/5.0", expectedUA: "claude-cli/2.0.34 (external, cli)", shouldBeChanged: true, }, { name: "Claude服务 - 已有Claude-CLI User-Agent", serviceType: "claude", initialUA: "claude-cli/2.0.34 (external, cli)", expectedUA: "claude-cli/2.0.34 (external, cli)", shouldBeChanged: false, }, { name: "非Claude服务 - 保留原User-Agent", serviceType: "openai", initialUA: "CustomClient/1.0", expectedUA: "CustomClient/1.0", shouldBeChanged: false, }, { name: "Gemini服务 - 保留原User-Agent", serviceType: "gemini", initialUA: "GeminiClient/2.0", expectedUA: "GeminiClient/2.0", shouldBeChanged: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { headers := http.Header{} if tt.initialUA != "" { headers.Set("User-Agent", tt.initialUA) } EnsureCompatibleUserAgent(headers, tt.serviceType) got := headers.Get("User-Agent") if got != tt.expectedUA { t.Errorf("User-Agent = %v, want %v", got, tt.expectedUA) } }) } } ================================================ FILE: backend-go/internal/utils/json.go ================================================ package utils import ( "bytes" "encoding/json" "strings" ) // MarshalJSONNoEscape 序列化 JSON 并禁用 HTML 字符转义 // 使用 json.Encoder + SetEscapeHTML(false) 避免将 <, >, & 等字符转义为 \u003c 等 // 返回去除末尾换行符的字节数组 func MarshalJSONNoEscape(v interface{}) ([]byte, error) { var buf bytes.Buffer encoder := json.NewEncoder(&buf) encoder.SetEscapeHTML(false) if err := encoder.Encode(v); err != nil { return nil, err } // json.Encoder.Encode 会在末尾添加换行符,需要去掉 return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil } // TruncateJSONIntelligently 智能截断JSON中的长文本内容,保持结构完整 // 只截断字符串值,不影响JSON结构 func TruncateJSONIntelligently(data interface{}, maxTextLength int) interface{} { if data == nil { return nil } switch v := data.(type) { case string: if len(v) > maxTextLength { return v[:maxTextLength] + "..." } return v case float64, int, int64, bool: return v case []interface{}: result := make([]interface{}, len(v)) for i, item := range v { result[i] = TruncateJSONIntelligently(item, maxTextLength) } return result case map[string]interface{}: result := make(map[string]interface{}, len(v)) for key, value := range v { result[key] = TruncateJSONIntelligently(value, maxTextLength) } return result default: return v } } // SimplifyToolsArray 简化tools数组为名称列表,减少日志输出 // 将完整的工具定义简化为只显示工具名称 func SimplifyToolsArray(data interface{}) interface{} { if data == nil { return nil } switch v := data.(type) { case []interface{}: result := make([]interface{}, len(v)) for i, item := range v { result[i] = SimplifyToolsArray(item) } return result case map[string]interface{}: result := make(map[string]interface{}, len(v)) for key, value := range v { // 如果是tools字段且是数组,提取工具名称 if key == "tools" { if toolsArray, ok := value.([]interface{}); ok { result[key] = extractToolNames(toolsArray) continue } } // 如果是content字段且是数组,标记为需要紧凑显示 if key == "content" { if contentArray, ok := value.([]interface{}); ok { result[key] = compactContentArray(contentArray) continue } } // 如果是contents字段(Gemini格式)且是数组,紧凑显示 if key == "contents" { if contentsArray, ok := value.([]interface{}); ok { result[key] = compactGeminiContentsArray(contentsArray) continue } } result[key] = SimplifyToolsArray(value) } return result default: return v } } // compactContentArray 紧凑显示content数组 // 只保留type和text/id/name等关键字段的简短摘要 func compactContentArray(contents []interface{}) []interface{} { result := make([]interface{}, len(contents)) for i, item := range contents { if contentMap, ok := item.(map[string]interface{}); ok { compact := make(map[string]interface{}) // 保留type字段 if contentType, ok := contentMap["type"].(string); ok { compact["type"] = contentType // 根据类型保留关键信息 switch contentType { case "text": if text, ok := contentMap["text"].(string); ok { // 文本内容截断到前200个字符 if len(text) > 200 { compact["text"] = text[:200] + "..." } else { compact["text"] = text } } case "input_text", "output_text": // Responses API 的 input/output 类型 if text, ok := contentMap["text"].(string); ok { if len(text) > 200 { compact["text"] = text[:200] + "..." } else { compact["text"] = text } } case "tool_use": if id, ok := contentMap["id"].(string); ok { compact["id"] = id } if name, ok := contentMap["name"].(string); ok { compact["name"] = name } // input字段紧凑显示 - 保留结构但截断长字符串值 if input, ok := contentMap["input"]; ok { compactInput := truncateInputValues(input, 200) compact["input"] = compactInput } case "tool_result": if toolUseID, ok := contentMap["tool_use_id"].(string); ok { compact["tool_use_id"] = toolUseID } // content字段显示前200字符 if content, ok := contentMap["content"].(string); ok { if len(content) > 200 { compact["content"] = content[:200] + "..." } else { compact["content"] = content } } if isError, ok := contentMap["is_error"].(bool); ok { compact["is_error"] = isError } case "image": if source, ok := contentMap["source"].(map[string]interface{}); ok { compact["source"] = map[string]interface{}{ "type": source["type"], } } case "reasoning": // Codex Responses API 的 reasoning 类型 // 保留 summary,截断 encrypted_content if summary, ok := contentMap["summary"]; ok { compact["summary"] = summary } if encryptedContent, ok := contentMap["encrypted_content"].(string); ok { if len(encryptedContent) > 100 { compact["encrypted_content"] = encryptedContent[:100] + "..." } else { compact["encrypted_content"] = encryptedContent } } // 保留其他可能的字段(如 content) if content, ok := contentMap["content"]; ok { compact["content"] = content } case "function_call": // Codex Responses API 的 function_call 类型 if callID, ok := contentMap["call_id"].(string); ok { compact["call_id"] = callID } if name, ok := contentMap["name"].(string); ok { compact["name"] = name } // arguments 字段截断显示 if args, ok := contentMap["arguments"].(string); ok { if len(args) > 200 { compact["arguments"] = args[:200] + "..." } else { compact["arguments"] = args } } case "function_call_output": // Codex Responses API 的 function_call_output 类型 if callID, ok := contentMap["call_id"].(string); ok { compact["call_id"] = callID } // output 字段截断显示 if output, ok := contentMap["output"].(string); ok { if len(output) > 200 { compact["output"] = output[:200] + "..." } else { compact["output"] = output } } } } result[i] = compact } else { result[i] = item } } return result } // compactGeminiContentsArray 紧凑显示Gemini contents数组 // Gemini格式: contents[].{role, parts[].{text, functionCall, functionResponse}} func compactGeminiContentsArray(contents []interface{}) []interface{} { result := make([]interface{}, len(contents)) for i, item := range contents { if contentMap, ok := item.(map[string]interface{}); ok { compact := make(map[string]interface{}) // 保留role字段 if role, ok := contentMap["role"].(string); ok { compact["role"] = role } // 处理parts数组 if parts, ok := contentMap["parts"].([]interface{}); ok { compactParts := make([]interface{}, len(parts)) for j, part := range parts { if partMap, ok := part.(map[string]interface{}); ok { compactPart := compactGeminiPart(partMap) compactParts[j] = compactPart } else { compactParts[j] = part } } compact["parts"] = compactParts } result[i] = compact } else { result[i] = item } } return result } // compactGeminiPart 紧凑显示单个Gemini part func compactGeminiPart(partMap map[string]interface{}) map[string]interface{} { compact := make(map[string]interface{}) // 处理text字段 if text, ok := partMap["text"].(string); ok { if len(text) > 200 { compact["text"] = text[:200] + "..." } else { compact["text"] = text } } // 处理functionCall字段 if fc, ok := partMap["functionCall"].(map[string]interface{}); ok { compactFC := make(map[string]interface{}) if name, ok := fc["name"].(string); ok { compactFC["name"] = name } // args字段紧凑显示 if args, ok := fc["args"]; ok { compactFC["args"] = truncateInputValues(args, 200) } compact["functionCall"] = compactFC } // 处理functionResponse字段 if fr, ok := partMap["functionResponse"].(map[string]interface{}); ok { compactFR := make(map[string]interface{}) if name, ok := fr["name"].(string); ok { compactFR["name"] = name } // response字段紧凑显示 if response, ok := fr["response"]; ok { compactFR["response"] = truncateInputValues(response, 200) } compact["functionResponse"] = compactFR } // 处理inlineData字段(图片等) if inlineData, ok := partMap["inlineData"].(map[string]interface{}); ok { compactInline := make(map[string]interface{}) if mimeType, ok := inlineData["mimeType"].(string); ok { compactInline["mimeType"] = mimeType } // data字段只显示前50个字符 if data, ok := inlineData["data"].(string); ok { if len(data) > 50 { compactInline["data"] = data[:50] + "...[base64]" } else { compactInline["data"] = data } } compact["inlineData"] = compactInline } // 处理fileData字段 if fileData, ok := partMap["fileData"].(map[string]interface{}); ok { compact["fileData"] = fileData } // 处理thought字段 if thought, ok := partMap["thought"].(bool); ok && thought { compact["thought"] = thought } return compact } // truncateInputValues 递归截断input对象中的长字符串值 // 保留JSON结构,只截断字符串值到指定长度 func truncateInputValues(data interface{}, maxLength int) interface{} { switch v := data.(type) { case string: if len(v) > maxLength { return v[:maxLength] + "..." } return v case map[string]interface{}: result := make(map[string]interface{}, len(v)) for key, value := range v { result[key] = truncateInputValues(value, maxLength) } return result case []interface{}: result := make([]interface{}, len(v)) for i, item := range v { result[i] = truncateInputValues(item, maxLength) } return result default: return v } } // extractToolNames 从tools数组中提取所有工具名称 // 支持Claude格式、OpenAI格式和Gemini格式 func extractToolNames(toolsArray []interface{}) []interface{} { var names []interface{} for _, tool := range toolsArray { toolMap, ok := tool.(map[string]interface{}) if !ok { // 如果不是 map,可能已经是简化后的名称字符串 names = append(names, tool) continue } // Gemini格式: tool.functionDeclarations[].name if funcDecls, ok := toolMap["functionDeclarations"].([]interface{}); ok { for _, funcDecl := range funcDecls { if declMap, ok := funcDecl.(map[string]interface{}); ok { if name, ok := declMap["name"].(string); ok { names = append(names, name) } } } continue } // Claude格式: tool.name if name, ok := toolMap["name"].(string); ok { names = append(names, name) continue } // OpenAI格式: tool.function.name if function, ok := toolMap["function"].(map[string]interface{}); ok { if name, ok := function["name"].(string); ok { names = append(names, name) continue } } // 未知格式,保留原始对象 names = append(names, tool) } return names } // extractToolName 从工具定义中提取名称(保留用于兼容) // 支持Claude格式(tool.name)和OpenAI格式(tool.function.name) func extractToolName(tool interface{}) interface{} { toolMap, ok := tool.(map[string]interface{}) if !ok { return tool } // 检查Claude格式: tool.name if name, ok := toolMap["name"].(string); ok { return name } // 检查OpenAI格式: tool.function.name if function, ok := toolMap["function"].(map[string]interface{}); ok { if name, ok := function["name"].(string); ok { return name } } return tool } // SimplifyToolsInJSON 简化JSON字节数组中的tools字段 // 这是一个便利函数,直接处理JSON字节 func SimplifyToolsInJSON(jsonData []byte) []byte { var data interface{} if err := json.Unmarshal(jsonData, &data); err != nil { return jsonData // 如果不是有效JSON,返回原始数据 } simplifiedData := SimplifyToolsArray(data) simplifiedBytes, err := json.Marshal(simplifiedData) if err != nil { return jsonData // 如果序列化失败,返回原始数据 } return simplifiedBytes } // FormatJSONForLog 格式化JSON用于日志输出 // 先简化tools,再截断长文本,最后美化格式 func FormatJSONForLog(data interface{}, maxTextLength int) string { // 先简化tools和content数组 simplified := SimplifyToolsArray(data) // 再截断长文本 truncated := TruncateJSONIntelligently(simplified, maxTextLength) // 使用自定义格式化来实现content数组的紧凑显示 result := formatJSONWithCompactArrays(truncated, "", 0) return result } // formatMapAsOneLine 将map格式化为单行JSON func formatMapAsOneLine(m map[string]interface{}) string { if len(m) == 0 { return "{}" } var pairs []string // 按照特定顺序输出字段(type优先,然后其他字段) if typeVal, ok := m["type"]; ok { typeJSON, _ := json.Marshal(typeVal) pairs = append(pairs, `"type": `+string(typeJSON)) } // 其他字段按字母顺序 for k, v := range m { if k == "type" { continue // 已经处理过 } keyJSON, _ := json.Marshal(k) // 对于input字段,使用紧凑的单行显示 if k == "input" { if inputMap, ok := v.(map[string]interface{}); ok { valueStr := formatInputMapCompact(inputMap) pairs = append(pairs, string(keyJSON)+": "+valueStr) continue } } // 对于长字符串字段(如 encrypted_content, arguments, output),进行截断 if k == "encrypted_content" || k == "arguments" || k == "output" || k == "text" { if strVal, ok := v.(string); ok { maxLen := 100 if k == "arguments" || k == "output" || k == "text" { maxLen = 200 } if len(strVal) > maxLen { truncated := strVal[:maxLen] + "..." valueJSON, _ := json.Marshal(truncated) pairs = append(pairs, string(keyJSON)+": "+string(valueJSON)) continue } } } valueJSON, _ := json.Marshal(v) pairs = append(pairs, string(keyJSON)+": "+string(valueJSON)) } return "{" + strings.Join(pairs, ", ") + "}" } // formatInputMapCompact 将input map紧凑格式化为单行 func formatInputMapCompact(m map[string]interface{}) string { if len(m) == 0 { return "{}" } var pairs []string for k, v := range m { keyJSON, _ := json.Marshal(k) valueJSON, _ := json.Marshal(v) pairs = append(pairs, string(keyJSON)+": "+string(valueJSON)) } return "{" + strings.Join(pairs, ", ") + "}" } // formatMessageAsOneLine 将message对象(包含role和content/parts)格式化为紧凑的一行 // 支持Claude格式:{role: "user", content: [...]} // 支持Gemini格式:{role: "user", parts: [...]} func formatMessageAsOneLine(m map[string]interface{}) string { var parts []string // 先输出role if role, ok := m["role"]; ok { roleJSON, _ := json.Marshal(role) parts = append(parts, `"role": `+string(roleJSON)) } // 处理content字段(Claude格式) if content, ok := m["content"]; ok { // 如果content是字符串,直接输出 if contentStr, isString := content.(string); isString { contentJSON, _ := json.Marshal(contentStr) parts = append(parts, `"content": `+string(contentJSON)) } else if contentArray, isArray := content.([]interface{}); isArray { // content数组已经是紧凑格式,直接格式化 contentItems := make([]string, len(contentArray)) for i, item := range contentArray { if itemMap, ok := item.(map[string]interface{}); ok { contentItems[i] = formatMapAsOneLine(itemMap) } else { itemJSON, _ := json.Marshal(item) contentItems[i] = string(itemJSON) } } parts = append(parts, `"content": [`+strings.Join(contentItems, ", ")+`]`) } } // 处理parts字段(Gemini格式) if partsField, ok := m["parts"]; ok { if partsArray, isArray := partsField.([]interface{}); isArray { partsItems := make([]string, len(partsArray)) for i, item := range partsArray { if itemMap, ok := item.(map[string]interface{}); ok { partsItems[i] = formatMapAsOneLine(itemMap) } else { itemJSON, _ := json.Marshal(item) partsItems[i] = string(itemJSON) } } parts = append(parts, `"parts": [`+strings.Join(partsItems, ", ")+`]`) } } return "{" + strings.Join(parts, ", ") + "}" } // formatJSONWithCompactArrays 自定义JSON格式化,对content数组使用紧凑单行显示 func formatJSONWithCompactArrays(data interface{}, indent string, depth int) string { switch v := data.(type) { case nil: return "null" case bool: if v { return "true" } return "false" case float64: bytes, _ := json.Marshal(v) return string(bytes) case string: bytes, _ := json.Marshal(v) return string(bytes) case []interface{}: if len(v) == 0 { return "[]" } // 检查是否是已经紧凑化的content数组 isCompactContent := false isInputArray := false isToolsArray := false if len(v) > 0 { // 检查第一个元素判断数组类型 if firstItem, ok := v[0].(map[string]interface{}); ok { if typeVal, ok := firstItem["type"].(string); ok { // 如果第一个元素有type字段,且看起来是content项,使用紧凑格式 if typeVal == "text" || typeVal == "tool_use" || typeVal == "tool_result" || typeVal == "image" || typeVal == "input_text" || typeVal == "output_text" { isCompactContent = true } // 检查是否是 Codex input 数组中的特殊类型对象 // 这些对象应该被单独压缩成一行 if typeVal == "reasoning" || typeVal == "function_call" || typeVal == "function_call_output" { isCompactContent = true } } // 检查是否是 input 数组(包含 message 对象,有 role 字段) // 或者包含 Codex 特殊类型对象 if _, hasRole := firstItem["role"]; hasRole { isInputArray = true } else if typeVal, ok := firstItem["type"].(string); ok { // 如果数组包含 reasoning/function_call 等类型,也当作 input 数组处理 if typeVal == "reasoning" || typeVal == "function_call" || typeVal == "function_call_output" { isInputArray = true } } } else if _, ok := v[0].(string); ok { // 如果数组元素都是字符串,可能是tools数组(已简化为工具名) isToolsArray = true // 验证是否所有元素都是字符串 for _, item := range v { if _, ok := item.(string); !ok { isToolsArray = false break } } } } if isCompactContent { // 紧凑单行显示 - 每个content项压缩为单行 items := make([]string, len(v)) for i, item := range v { // 将单个content项格式化为单行JSON if itemMap, ok := item.(map[string]interface{}); ok { compactItem := formatMapAsOneLine(itemMap) items[i] = compactItem } else { items[i] = formatJSONWithCompactArrays(item, "", depth+1) } } return "[\n" + indent + " " + strings.Join(items, ",\n"+indent+" ") + "\n" + indent + "]" } if isInputArray { // input 数组(包含 message 对象和特殊类型对象)使用紧凑单行显示 items := make([]string, len(v)) for i, item := range v { if itemMap, ok := item.(map[string]interface{}); ok { // 检查是否是 message 对象(有 role 字段) if _, hasRole := itemMap["role"]; hasRole { items[i] = formatMessageAsOneLine(itemMap) } else if typeVal, hasType := itemMap["type"].(string); hasType { // 检查是否是特殊类型对象(reasoning, function_call 等) if typeVal == "reasoning" || typeVal == "function_call" || typeVal == "function_call_output" { items[i] = formatMapAsOneLine(itemMap) } else { items[i] = formatJSONWithCompactArrays(item, "", depth+1) } } else { items[i] = formatJSONWithCompactArrays(item, "", depth+1) } } else { items[i] = formatJSONWithCompactArrays(item, "", depth+1) } } return "[\n" + indent + " " + strings.Join(items, ",\n"+indent+" ") + "\n" + indent + "]" } if isToolsArray { // tools数组使用紧凑的单行显示 items := make([]string, len(v)) for i, item := range v { itemJSON, _ := json.Marshal(item) items[i] = string(itemJSON) } // 始终使用单行显示所有工具 return "[" + strings.Join(items, ", ") + "]" } // 普通数组的多行显示 items := make([]string, len(v)) for i, item := range v { items[i] = indent + " " + formatJSONWithCompactArrays(item, indent+" ", depth+1) } return "[\n" + strings.Join(items, ",\n") + "\n" + indent + "]" case map[string]interface{}: if len(v) == 0 { return "{}" } // 检查是否是message对象(包含role和content字段) if _, hasRole := v["role"]; hasRole { if _, hasContent := v["content"]; hasContent { // 这是一个message对象,使用紧凑的单行显示 return formatMessageAsOneLine(v) } } // 检查是否是包含 type 字段的特殊对象(reasoning, function_call, function_call_output 等) if typeVal, hasType := v["type"].(string); hasType { // 这些类型的对象使用紧凑的单行显示 if typeVal == "reasoning" || typeVal == "function_call" || typeVal == "function_call_output" || typeVal == "text" || typeVal == "tool_use" || typeVal == "tool_result" || typeVal == "image" || typeVal == "input_text" || typeVal == "output_text" { return formatMapAsOneLine(v) } } // 对于普通map,使用多行显示 var keys []string for k := range v { keys = append(keys, k) } items := make([]string, len(keys)) for i, k := range keys { value := formatJSONWithCompactArrays(v[k], indent+" ", depth+1) keyJSON, _ := json.Marshal(k) items[i] = indent + " " + string(keyJSON) + ": " + value } return "{\n" + strings.Join(items, ",\n") + "\n" + indent + "}" default: bytes, _ := json.Marshal(v) return string(bytes) } } // FormatJSONBytesForLog 格式化JSON字节数组用于日志输出 func FormatJSONBytesForLog(jsonData []byte, maxTextLength int) string { var data interface{} if err := json.Unmarshal(jsonData, &data); err != nil { // 如果不是有效JSON,按字符串处理 str := string(jsonData) if len(str) > 500 { return str[:500] + "..." } return str } return FormatJSONForLog(data, maxTextLength) } // MaskSensitiveHeaders 脱敏敏感请求头 func MaskSensitiveHeaders(headers map[string]string) map[string]string { sensitiveKeys := map[string]bool{ "authorization": true, "x-api-key": true, "x-goog-api-key": true, } masked := make(map[string]string, len(headers)) for key, value := range headers { lowerKey := strings.ToLower(key) if sensitiveKeys[lowerKey] { if lowerKey == "authorization" && strings.HasPrefix(value, "Bearer ") { token := value[7:] masked[key] = "Bearer " + MaskAPIKey(token) } else { masked[key] = MaskAPIKey(value) } } else { masked[key] = value } } return masked } // MaskAPIKey 掩码API密钥 func MaskAPIKey(key string) string { if key == "" { return "" } length := len(key) if length <= 10 { if length <= 5 { return "***" } return key[:3] + "***" + key[length-2:] } return key[:8] + "***" + key[length-5:] } // FormatJSONBytesRaw 原始输出JSON字节数组(不缩进、不截断、不重排序) func FormatJSONBytesRaw(jsonData []byte) string { return string(jsonData) } ================================================ FILE: backend-go/internal/utils/json_compact_test.go ================================================ package utils import ( "encoding/json" "strings" "testing" ) func TestCompactContentArray(t *testing.T) { input := map[string]interface{}{ "model": "claude-3", "tools": []interface{}{"Tool1", "Tool2", "Tool3"}, // 简化后的tools数组 "messages": []interface{}{ map[string]interface{}{ "role": "user", "content": []interface{}{ map[string]interface{}{ "type": "text", "text": strings.Repeat("This is a very long text that should be truncated. ", 10), }, map[string]interface{}{ "type": "tool_use", "id": "toolu_123", "name": "get_weather", "input": map[string]interface{}{ "location": "San Francisco", "unit": "celsius", }, }, }, }, map[string]interface{}{ "role": "assistant", "content": []interface{}{ map[string]interface{}{ "type": "tool_result", "tool_use_id": "toolu_123", "content": "Temperature: 18°C, Clear sky", "is_error": false, }, }, }, }, } result := FormatJSONForLog(input, 500) // 验证content数组被紧凑显示 if !strings.Contains(result, `"type": "text"`) { t.Error("应该包含type字段") } // 验证文本被截断到200字符 if strings.Contains(result, strings.Repeat("This is a very long text", 8)) { t.Error("长文本应该被截断到200字符") } // 验证tool_use的input显示JSON而不是{...} if !strings.Contains(result, `"location"`) || !strings.Contains(result, `"San Francisco"`) { t.Error("tool_use的input应该显示JSON内容") } // 验证tools数组被紧凑显示(单行或少量换行) if strings.Contains(result, `"tools": ["Tool1", "Tool2", "Tool3"]`) || strings.Contains(result, `"tools": [ "Tool1", "Tool2", "Tool3" ]`) { t.Log("[Test-OK] tools数组被紧凑显示") } // 验证输出没有被截断(不应该出现"需要�"这种乱码) if strings.Contains(result, "�") { t.Error("输出包含乱码,可能是截断导致的") } t.Logf("格式化后的输出:\n%s", result) } func TestContentArrayCompactFormat(t *testing.T) { // 测试各种content类型的紧凑显示 tests := []struct { name string content []interface{} checks []string // 应该包含的内容 }{ { name: "文本类型 - 长文本截断", content: []interface{}{ map[string]interface{}{ "type": "text", "text": strings.Repeat("This is a very long text that exceeds 200 characters and should be truncated. ", 5), }, }, checks: []string{ `"type": "text"`, // 文本应该被截断到200字符,包含省略号 `...`, }, }, { name: "工具使用类型", content: []interface{}{ map[string]interface{}{ "type": "tool_use", "id": "toolu_abc123", "name": "calculator", "input": map[string]interface{}{ "expression": "2 + 2", }, }, }, checks: []string{ `"type": "tool_use"`, `"id": "toolu_abc123"`, `"name": "calculator"`, // input应该显示JSON内容而不是{...} `"expression"`, }, }, { name: "工具结果类型", content: []interface{}{ map[string]interface{}{ "type": "tool_result", "tool_use_id": "toolu_abc123", "content": "Result: 4", "is_error": false, }, }, checks: []string{ `"type": "tool_result"`, `"tool_use_id": "toolu_abc123"`, `"content": "Result: 4"`, `"is_error": false`, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { input := map[string]interface{}{ "messages": []interface{}{ map[string]interface{}{ "role": "user", "content": tt.content, }, }, } result := FormatJSONForLog(input, 500) for _, check := range tt.checks { if !strings.Contains(result, check) { t.Errorf("输出应该包含: %s\n实际输出:\n%s", check, result) } } // 验证没有乱码 if strings.Contains(result, "�") { t.Error("输出包含乱码") } }) } } func TestNoTruncationInMiddleOfJSON(t *testing.T) { // 创建一个超大的JSON对象来测试截断逻辑 largeMessages := make([]interface{}, 100) for i := 0; i < 100; i++ { largeMessages[i] = map[string]interface{}{ "role": "user", "content": []interface{}{ map[string]interface{}{ "type": "text", "text": "Message " + strings.Repeat("x", 100), }, }, } } input := map[string]interface{}{ "model": "claude-3", "messages": largeMessages, } result := FormatJSONForLog(input, 500) // 如果被截断,应该在换行符处截断 if strings.Contains(result, "... (输出已截断)") { // 检查截断位置是否在合适的地方 truncateIndex := strings.Index(result, "... (输出已截断)") beforeTruncate := result[:truncateIndex] // 应该在换行符后截断 if !strings.HasSuffix(strings.TrimSpace(beforeTruncate), "\n") && !strings.HasSuffix(beforeTruncate, "}") && !strings.HasSuffix(beforeTruncate, "]") { // 允许截断点不完美,但至少不应该在字符串中间 if !strings.Contains(beforeTruncate[len(beforeTruncate)-20:], "\n") { t.Error("截断位置不在合适的边界") } } t.Logf("[Test-OK] 超长输出被正确截断,截断位置: %d", truncateIndex) } } func TestFormatJSONBytesForLog(t *testing.T) { input := map[string]interface{}{ "messages": []interface{}{ map[string]interface{}{ "role": "user", "content": []interface{}{ map[string]interface{}{ "type": "text", "text": "Hello, world!", }, }, }, }, } jsonBytes, _ := json.Marshal(input) result := FormatJSONBytesForLog(jsonBytes, 500) // 验证基本功能 if !strings.Contains(result, `"type": "text"`) { t.Error("应该包含type字段") } if !strings.Contains(result, `"text": "Hello, world!"`) { t.Error("应该包含完整的短文本") } // 验证没有乱码 if strings.Contains(result, "�") { t.Error("输出包含乱码") } t.Logf("格式化结果:\n%s", result) } // TestCodexResponsesFormat 测试 Codex Responses API 格式的压缩显示 func TestCodexResponsesFormat(t *testing.T) { // 模拟 Codex Responses API 的请求体 input := map[string]interface{}{ "input": []interface{}{ map[string]interface{}{ "role": "assistant", "content": []interface{}{ map[string]interface{}{ "type": "output_text", "text": "- This repo is a desktop 文档智能评分系统 built with Wails v3: Go backend + Vue 3 frontend; it parallel-scores DOCX/PDF/PPTX/TXT docs via multiple AI models (Kimi, MiniMax, DeepSeek), su...", }, }, }, map[string]interface{}{ "role": "user", "content": []interface{}{ map[string]interface{}{ "type": "input_text", "text": "[截屏2025-11-18 12.09.22.png 3022x2022] 失败的时候正在评分这个消息一直消不掉", }, map[string]interface{}{ "type": "input_image", }, }, }, map[string]interface{}{ "type": "reasoning", "content": nil, "encrypted_content": "gAAAAABpG_GLKdJFoKhQfJKcN5k9efb8cQRy3md40ZemIZlJMlmuGgxhTjUtFPwmTAToAwIDtPsPMoOxV8SwDDLohrOqLqUMNEBgFV3ZBNgbNamdzu_jRW7JiFFpB8supDB4lIWyIhvh6HwuHP-8it62DBcdKp9U_V1GuSsP96C8GacKBEEyUmmcHbAcgXj341PxsVpiLx3y5xS18kXTXafmVK_EATeun9vLZ-A9m2BbbEfXoC4zb1AfUGQ_46sZyYXZNWr-v3gbbRkPug4Hq8j4d8vHMmDqNHGDuuScL5r63obEnrrhdTl9dbpOeSgIm7ag-fzmdofyP4I4XKx_SUxaEbTbWbHxunTYpA4lZy04Qw0b85TTvY62G6hcik5i-l5b6LgU0LTycR9lp_LE8OnAswvjLT3HQz6tFzZM288H1vWykftDb-eCyOX4pXn7WP4HFFNp_GvoVy1RPGJh_QbVxKAZCYiv0_7AaSjpv1_RS8EYbssy...", "summary": []interface{}{ map[string]interface{}{ "type": "summary_text", "text": "**Investigating status toast bug**", }, }, }, map[string]interface{}{ "call_id": "call_FIgewLLjtlkutO7mK5scikpN", "name": "shell", "type": "function_call", "arguments": `{"command":["bash","-lc","ls frontend/src"],"workdir":"/Users/petaflops/projects/doc-scorer-wails"}`, }, }, } result := FormatJSONForLog(input, 500) // 验证 input 数组被压缩成单行 lines := strings.Split(result, "\n") var inputArrayLines []string inInputArray := false for _, line := range lines { if strings.Contains(line, `"input":`) { inInputArray = true } if inInputArray { inputArrayLines = append(inputArrayLines, line) if strings.Contains(line, "]") && !strings.Contains(line, "[") { break } } } // 验证每个 message 对象都在单行 inputArrayStr := strings.Join(inputArrayLines, "\n") if !strings.Contains(inputArrayStr, `{"role": "assistant"`) { t.Error("input 数组中的 message 对象应该被压缩成单行") } // 验证 reasoning 类型被压缩 if !strings.Contains(result, `"type": "reasoning"`) { t.Error("应该包含 reasoning 类型") } // 验证 function_call 类型被压缩 if !strings.Contains(result, `"type": "function_call"`) { t.Error("应该包含 function_call 类型") } // 验证 encrypted_content 被截断 if strings.Contains(result, "gAAAAABpG_GLKdJFoKhQfJKcN5k9efb8cQRy3md40ZemIZlJMlmuGgxhTjUtFPwmTAToAwIDtPsPMoOxV8SwDDLohrOqLqUMNEBgFV3ZBNgbNamdzu_jRW7JiFFpB8supDB4lIWyIhvh6HwuHP-8it62DBcdKp9U_V1GuSsP96C8GacKBEEyUmmcHbAcgXj341PxsVpiLx3y5xS18kXTXafmVK_EATeun9vLZ-A9m2BbbEfXoC4zb1AfUGQ_46sZyYXZNWr-v3gbbRkPug4Hq8j4d8vHMmDqNHGDuuScL5r63obEnrrhdTl9dbpOeSgIm7ag-fzmdofyP4I4XKx_SUxaEbTbWbHxunTYpA4lZy04Qw0b85TTvY62G6hcik5i-l5b6LgU0LTycR9lp_LE8OnAswvjLT3HQz6tFzZM288H1vWykftDb-eCyOX4pXn7WP4HFFNp_GvoVy1RPGJh_QbVxKAZCYiv0_7AaSjpv1_RS8EYbssy...") { t.Error("encrypted_content 应该被截断") } t.Logf("Codex Responses 格式化结果:\n%s", result) } // TestGeminiContentsFormat 测试 Gemini contents 数组的紧凑格式化 func TestGeminiContentsFormat(t *testing.T) { // 模拟 Gemini 请求格式 input := map[string]interface{}{ "contents": []interface{}{ map[string]interface{}{ "role": "user", "parts": []interface{}{ map[string]interface{}{ "text": "Hello, this is a test message that might be quite long and should be truncated if it exceeds the limit. " + strings.Repeat("More text here. ", 20), }, }, }, map[string]interface{}{ "role": "model", "parts": []interface{}{ map[string]interface{}{ "text": "This is the model response.", }, map[string]interface{}{ "functionCall": map[string]interface{}{ "name": "get_weather", "args": map[string]interface{}{ "location": "San Francisco", "unit": "celsius", }, }, }, }, }, map[string]interface{}{ "role": "user", "parts": []interface{}{ map[string]interface{}{ "functionResponse": map[string]interface{}{ "name": "get_weather", "response": map[string]interface{}{ "temperature": 18, "condition": "Clear sky", }, }, }, }, }, }, "generationConfig": map[string]interface{}{ "maxOutputTokens": 1024, }, } result := FormatJSONForLog(input, 500) // 验证 contents 数组被正确处理 if !strings.Contains(result, `"contents"`) { t.Error("应该包含 contents 字段") } // 验证 parts 字段被保留 if !strings.Contains(result, `"parts"`) { t.Error("应该包含 parts 字段") } // 验证 role 字段被保留 if !strings.Contains(result, `"role": "user"`) { t.Error("应该包含 role 字段") } // 验证 functionCall 被保留 if !strings.Contains(result, `"functionCall"`) { t.Error("应该包含 functionCall 字段") } // 验证 functionResponse 被保留 if !strings.Contains(result, `"functionResponse"`) { t.Error("应该包含 functionResponse 字段") } // 验证长文本被截断 if strings.Contains(result, "More text here. More text here. More text here. More text here. More text here. More text here. More text here. More text here. More text here. More text here. More text here. More text here. More text here. More text here. More text here. More text here. More text here. More text here. More text here. More text here.") { t.Error("长文本应该被截断") } t.Logf("Gemini contents 格式化结果:\n%s", result) } // TestGeminiToolsFormat 测试 Gemini tools 数组的简化显示 func TestGeminiToolsFormat(t *testing.T) { // 模拟 Gemini 请求中的 tools 格式 input := map[string]interface{}{ "contents": []interface{}{ map[string]interface{}{ "role": "user", "parts": []interface{}{ map[string]interface{}{ "text": "List the files in the current directory", }, }, }, }, "tools": []interface{}{ map[string]interface{}{ "functionDeclarations": []interface{}{ map[string]interface{}{ "name": "list_directory", "description": "Lists the names of files and subdirectories directly within a specified directory path.", "parametersJsonSchema": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "dir_path": map[string]interface{}{ "type": "string", "description": "The path to the directory to list", }, }, "required": []interface{}{"dir_path"}, }, }, map[string]interface{}{ "name": "read_file", "description": "Reads and returns the content of a specified file.", "parametersJsonSchema": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "file_path": map[string]interface{}{ "type": "string", "description": "The path to the file to read.", }, }, "required": []interface{}{"file_path"}, }, }, map[string]interface{}{ "name": "write_file", "description": "Writes content to a specified file.", "parametersJsonSchema": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "file_path": map[string]interface{}{ "type": "string", "description": "The path to the file to write.", }, "content": map[string]interface{}{ "type": "string", "description": "The content to write.", }, }, "required": []interface{}{"file_path", "content"}, }, }, }, }, }, "generationConfig": map[string]interface{}{ "maxOutputTokens": 1024, }, } result := FormatJSONForLog(input, 500) // 验证 tools 数组被简化为工具名称列表 if !strings.Contains(result, `"tools": ["list_directory", "read_file", "write_file"]`) { t.Errorf("Gemini tools 应该被简化为工具名称列表,实际输出:\n%s", result) } // 验证完整的 parametersJsonSchema 没有出现在输出中 if strings.Contains(result, "parametersJsonSchema") { t.Error("tools 中不应该包含 parametersJsonSchema") } // 验证完整的 description 没有出现在输出中 if strings.Contains(result, "Lists the names of files") { t.Error("tools 中不应该包含完整的 description") } t.Logf("Gemini tools 格式化结果:\n%s", result) } ================================================ FILE: backend-go/internal/utils/json_test.go ================================================ package utils import ( "encoding/json" "strings" "testing" ) func TestTruncateJSONIntelligently(t *testing.T) { tests := []struct { name string input interface{} maxTextLength int expectTruncate bool }{ { name: "短字符串不截断", input: "Hello", maxTextLength: 10, expectTruncate: false, }, { name: "长字符串截断", input: strings.Repeat("a", 600), maxTextLength: 500, expectTruncate: true, }, { name: "嵌套对象中的长字符串", input: map[string]interface{}{ "short": "test", "long": strings.Repeat("b", 600), }, maxTextLength: 500, expectTruncate: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := TruncateJSONIntelligently(tt.input, tt.maxTextLength) // 转换为JSON检查 jsonBytes, err := json.Marshal(result) if err != nil { t.Fatalf("Marshal failed: %v", err) } resultStr := string(jsonBytes) if tt.expectTruncate { if !strings.Contains(resultStr, "...") { t.Errorf("Expected truncation marker '...' not found") } } }) } } func TestSimplifyToolsArray(t *testing.T) { tests := []struct { name string input interface{} expected string }{ { name: "Claude格式工具", input: map[string]interface{}{ "tools": []interface{}{ map[string]interface{}{ "name": "get_weather", "description": "Get weather info", }, map[string]interface{}{ "name": "search", "description": "Search the web", }, }, }, expected: `["get_weather","search"]`, }, { name: "OpenAI格式工具", input: map[string]interface{}{ "tools": []interface{}{ map[string]interface{}{ "type": "function", "function": map[string]interface{}{ "name": "calculator", "description": "Calculate math", }, }, }, }, expected: `["calculator"]`, }, { name: "非工具字段不受影响", input: map[string]interface{}{ "model": "claude-3", "messages": []interface{}{"hello"}, }, expected: `{"messages":["hello"],"model":"claude-3"}`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := SimplifyToolsArray(tt.input) // 提取tools字段检查 if resultMap, ok := result.(map[string]interface{}); ok { if tools, exists := resultMap["tools"]; exists { toolsJSON, _ := json.Marshal(tools) if !strings.Contains(string(toolsJSON), tt.expected) { t.Errorf("Expected tools to contain %s, got %s", tt.expected, string(toolsJSON)) } } } }) } } func TestFormatJSONForLog(t *testing.T) { input := map[string]interface{}{ "model": "claude-3", "tools": []interface{}{ map[string]interface{}{ "name": "get_weather", "description": "Get weather information", "parameters": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "location": map[string]interface{}{ "type": "string", "description": "City name", }, }, }, }, }, "messages": []interface{}{ map[string]interface{}{ "role": "user", "content": strings.Repeat("Hello ", 200), // 长内容 }, }, } result := FormatJSONForLog(input, 100) // 检查tools被简化 if !strings.Contains(result, `"get_weather"`) { t.Error("Tools should be simplified to names") } // 检查长文本被截断 if !strings.Contains(result, "...") { t.Error("Long content should be truncated") } // 检查JSON格式化 if !strings.Contains(result, "\n") { t.Error("Output should be formatted with newlines") } } func TestMaskAPIKey(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "空字符串", input: "", expected: "", }, { name: "短密钥(5字符)", input: "abc12", expected: "***", // 长度<=5时返回*** }, { name: "短密钥(6字符)", input: "abc123", expected: "abc***23", }, { name: "长密钥", input: "sk-1234567890abcdef", expected: "sk-12345***bcdef", }, { name: "超短密钥", input: "key", expected: "***", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := MaskAPIKey(tt.input) if result != tt.expected { t.Errorf("Expected %s, got %s", tt.expected, result) } }) } } ================================================ FILE: backend-go/internal/utils/stream_synthesizer.go ================================================ package utils import ( "encoding/json" "regexp" "sort" "strconv" "strings" ) // StreamSynthesizer 流式响应内容合成器 type StreamSynthesizer struct { serviceType string synthesizedContent strings.Builder toolCallAccumulator map[int]*ToolCall parseFailed bool // responses专用累积器 responsesText map[int]*strings.Builder } // ToolCall 工具调用累积器 type ToolCall struct { ID string Name string Arguments string } // NewStreamSynthesizer 创建新的流合成器 func NewStreamSynthesizer(serviceType string) *StreamSynthesizer { return &StreamSynthesizer{ serviceType: serviceType, toolCallAccumulator: make(map[int]*ToolCall), responsesText: make(map[int]*strings.Builder), } } // ProcessLine 处理SSE流的一行 func (s *StreamSynthesizer) ProcessLine(line string) { trimmedLine := strings.TrimSpace(line) if trimmedLine == "" { return } // 使用正则匹配SSE data字段 dataRegex := regexp.MustCompile(`^data:\s*(.*)$`) matches := dataRegex.FindStringSubmatch(trimmedLine) if len(matches) < 2 { return } jsonStr := strings.TrimSpace(matches[1]) if jsonStr == "[DONE]" || jsonStr == "" { return } // 解析JSON - 不再因失败而停止处理 var data map[string]interface{} if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { // 记录解析失败但继续处理后续行,而不是完全停止 if !s.parseFailed { s.parseFailed = true s.synthesizedContent.WriteString("\n[解析警告: 部分JSON解析失败,将显示原始文本内容]") } return } // 如果之前解析失败,但现在成功了,重置失败标记 if s.parseFailed { s.parseFailed = false } // 根据服务类型解析 switch s.serviceType { case "gemini": s.processGemini(data) case "openai": s.processOpenAI(data) case "claude": s.processClaude(data) case "responses": s.processResponses(data) } } // processResponses 处理OpenAI Responses流 func (s *StreamSynthesizer) processResponses(data map[string]interface{}) { typeStr, _ := data["type"].(string) // 辅助方法:获取对应 output_index 的 builder getBuilder := func(index int) *strings.Builder { if s.responsesText[index] == nil { s.responsesText[index] = &strings.Builder{} } return s.responsesText[index] } // 获取 output_index getIndex := func() int { if idx, ok := data["output_index"].(float64); ok { return int(idx) } return 0 } switch typeStr { case "response.output_text.delta": if delta, ok := data["delta"].(string); ok { builder := getBuilder(getIndex()) builder.WriteString(delta) } case "response.output_text.done": builder := getBuilder(getIndex()) if text, ok := data["text"].(string); ok && text != "" { builder.Reset() builder.WriteString(text) } case "response.completed": // 兜底:从最终响应提取文本 if respObj, ok := data["response"].(map[string]interface{}); ok { if outputArr, ok := respObj["output"].([]interface{}); ok { for i, item := range outputArr { itemMap, ok := item.(map[string]interface{}) if !ok { continue } if itemMap["type"] != "message" { continue } contentArr, ok := itemMap["content"].([]interface{}) if !ok { continue } for _, c := range contentArr { cm, ok := c.(map[string]interface{}) if !ok { continue } if cm["type"] != "output_text" { continue } if text, ok := cm["text"].(string); ok && text != "" { builder := getBuilder(i) builder.Reset() builder.WriteString(text) break } } } } } case "response.output_item.added": // 记录函数调用元数据(用于后续拼接日志) if item, ok := data["item"].(map[string]interface{}); ok { if itemType, _ := item["type"].(string); itemType == "function_call" { index := getIndex() if s.toolCallAccumulator[index] == nil { s.toolCallAccumulator[index] = &ToolCall{} } acc := s.toolCallAccumulator[index] if id, ok := item["id"].(string); ok && id != "" { acc.ID = id } if name, ok := item["name"].(string); ok && name != "" { acc.Name = name } } } case "response.function_call_arguments.delta": index := getIndex() if s.toolCallAccumulator[index] == nil { s.toolCallAccumulator[index] = &ToolCall{} } acc := s.toolCallAccumulator[index] if id, ok := data["item_id"].(string); ok && id != "" { acc.ID = id } if delta, ok := data["delta"].(string); ok { acc.Arguments += delta } case "response.function_call_arguments.done": index := getIndex() if s.toolCallAccumulator[index] == nil { s.toolCallAccumulator[index] = &ToolCall{} } acc := s.toolCallAccumulator[index] if id, ok := data["item_id"].(string); ok && id != "" { acc.ID = id } if args, ok := data["arguments"].(string); ok && args != "" { acc.Arguments = args } if item, ok := data["item"].(map[string]interface{}); ok { if name, ok := item["name"].(string); ok && name != "" { acc.Name = name } } } } // processGemini 处理Gemini格式 func (s *StreamSynthesizer) processGemini(data map[string]interface{}) { candidates, ok := data["candidates"].([]interface{}) if !ok || len(candidates) == 0 { return } candidate, ok := candidates[0].(map[string]interface{}) if !ok { return } content, ok := candidate["content"].(map[string]interface{}) if !ok { return } parts, ok := content["parts"].([]interface{}) if !ok { return } for _, part := range parts { partMap, ok := part.(map[string]interface{}) if !ok { continue } // 文本内容 if text, ok := partMap["text"].(string); ok { s.synthesizedContent.WriteString(text) } // 函数调用 if functionCall, ok := partMap["functionCall"].(map[string]interface{}); ok { name, _ := functionCall["name"].(string) args, _ := functionCall["args"] argsJSON, _ := json.Marshal(args) s.synthesizedContent.WriteString("\nTool Call: ") s.synthesizedContent.WriteString(name) s.synthesizedContent.WriteString("(") s.synthesizedContent.Write(argsJSON) s.synthesizedContent.WriteString(")") } } } // processOpenAI 处理OpenAI格式 func (s *StreamSynthesizer) processOpenAI(data map[string]interface{}) { choices, ok := data["choices"].([]interface{}) if !ok || len(choices) == 0 { return } choice, ok := choices[0].(map[string]interface{}) if !ok { return } delta, ok := choice["delta"].(map[string]interface{}) if !ok { return } // 文本内容 if content, ok := delta["content"].(string); ok { s.synthesizedContent.WriteString(content) } // 工具调用 if toolCalls, ok := delta["tool_calls"].([]interface{}); ok { for _, tc := range toolCalls { toolCallMap, ok := tc.(map[string]interface{}) if !ok { continue } index := 0 if idx, ok := toolCallMap["index"].(float64); ok { index = int(idx) } if s.toolCallAccumulator[index] == nil { s.toolCallAccumulator[index] = &ToolCall{} } accumulated := s.toolCallAccumulator[index] if id, ok := toolCallMap["id"].(string); ok { accumulated.ID = id } if function, ok := toolCallMap["function"].(map[string]interface{}); ok { if name, ok := function["name"].(string); ok { accumulated.Name = name } if args, ok := function["arguments"].(string); ok { accumulated.Arguments += args } } } } } // processClaude 处理Claude格式 func (s *StreamSynthesizer) processClaude(data map[string]interface{}) { eventType, _ := data["type"].(string) switch eventType { case "message_start": // 从 message_start 中提取初始内容(如果有) if msg, ok := data["message"].(map[string]interface{}); ok { if content, ok := msg["content"].([]interface{}); ok { for _, c := range content { if cm, ok := c.(map[string]interface{}); ok { if text, ok := cm["text"].(string); ok { s.synthesizedContent.WriteString(text) } } } } } case "content_block_start": contentBlock, ok := data["content_block"].(map[string]interface{}) if !ok { return } blockIndex := 0 if idx, ok := data["index"].(float64); ok { blockIndex = int(idx) } blockType, _ := contentBlock["type"].(string) switch blockType { case "tool_use": if s.toolCallAccumulator[blockIndex] == nil { s.toolCallAccumulator[blockIndex] = &ToolCall{} } accumulated := s.toolCallAccumulator[blockIndex] if id, ok := contentBlock["id"].(string); ok { accumulated.ID = id } if name, ok := contentBlock["name"].(string); ok { accumulated.Name = name } case "text": // text 类型的 content_block_start 可能包含初始文本 if text, ok := contentBlock["text"].(string); ok && text != "" { s.synthesizedContent.WriteString(text) } } case "content_block_delta": delta, ok := data["delta"].(map[string]interface{}) if !ok { return } deltaType, _ := delta["type"].(string) switch deltaType { case "text_delta": if text, ok := delta["text"].(string); ok { s.synthesizedContent.WriteString(text) } case "input_json_delta": if partialJSON, ok := delta["partial_json"].(string); ok { blockIndex := 0 if idx, ok := data["index"].(float64); ok { blockIndex = int(idx) } if s.toolCallAccumulator[blockIndex] == nil { s.toolCallAccumulator[blockIndex] = &ToolCall{} } accumulated := s.toolCallAccumulator[blockIndex] accumulated.Arguments += partialJSON } case "thinking_delta": // thinking 内容不记录到合成内容中(可选:如需记录可取消注释) // if thinking, ok := delta["thinking"].(string); ok { // s.synthesizedContent.WriteString(thinking) // } } case "message_delta": // message_delta 通常包含 stop_reason 和 usage,不包含文本内容 // 但某些情况下可能有额外数据,这里做兜底处理 if delta, ok := data["delta"].(map[string]interface{}); ok { if text, ok := delta["text"].(string); ok { s.synthesizedContent.WriteString(text) } } } } // GetSynthesizedContent 获取合成的内容 func (s *StreamSynthesizer) GetSynthesizedContent() string { // 不再完全失败,即使有解析错误也返回部分结果 var result string if s.serviceType == "responses" && len(s.responsesText) > 0 { var builder strings.Builder keys := make([]int, 0, len(s.responsesText)) for k := range s.responsesText { keys = append(keys, k) } sort.Ints(keys) for i, k := range keys { text := s.responsesText[k].String() if text == "" { continue } if i > 0 && builder.Len() > 0 { builder.WriteString("\n") } builder.WriteString(text) } result = builder.String() } else { result = s.synthesizedContent.String() } // 添加工具调用信息 if len(s.toolCallAccumulator) > 0 { // 修复分裂的工具调用:检测并合并元数据和参数分离的情况 s.mergeSplitToolCalls() // 按 index 排序输出,避免 map 遍历顺序不稳定 indices := make([]int, 0, len(s.toolCallAccumulator)) for idx := range s.toolCallAccumulator { indices = append(indices, idx) } sort.Ints(indices) var toolCallsBuilder strings.Builder for _, index := range indices { tool := s.toolCallAccumulator[index] args := tool.Arguments if args == "" { args = "{}" } name := tool.Name if name == "" { name = "unknown_function" } id := tool.ID if id == "" { id = "tool_" + strconv.Itoa(index) } toolCallsBuilder.WriteString("\nTool Call: ") toolCallsBuilder.WriteString(name) toolCallsBuilder.WriteString("(") // 尝试格式化JSON var parsedArgs interface{} if err := json.Unmarshal([]byte(args), &parsedArgs); err == nil { prettyArgs, _ := json.Marshal(parsedArgs) toolCallsBuilder.Write(prettyArgs) } else { toolCallsBuilder.WriteString(args) } toolCallsBuilder.WriteString(") [ID: ") toolCallsBuilder.WriteString(id) toolCallsBuilder.WriteString("]") } result += toolCallsBuilder.String() } return result } // mergeSplitToolCalls 修复分裂的工具调用 // 问题场景:上游返回的工具调用被意外分成两个 content_block: // - 第一个 block 有 name 和 id,但参数为空 "{}" // - 第二个 block 没有 name(显示为 unknown_function),但有完整参数 // 此方法检测并合并这种情况 func (s *StreamSynthesizer) mergeSplitToolCalls() { if len(s.toolCallAccumulator) < 2 { return } // 收集所有索引并排序 indices := make([]int, 0, len(s.toolCallAccumulator)) for idx := range s.toolCallAccumulator { indices = append(indices, idx) } sort.Ints(indices) // 检测分裂模式:有 name 但参数为空/"{}" 的 block,后面紧跟无 name 但有参数的 block toDelete := make(map[int]bool) for i := 0; i < len(indices)-1; i++ { currIdx := indices[i] nextIdx := indices[i+1] // 约束:只合并连续的 index(防止误合并不相关的调用) if nextIdx != currIdx+1 { continue } curr := s.toolCallAccumulator[currIdx] next := s.toolCallAccumulator[nextIdx] // 检测分裂条件: // 1. 当前 block 有 name 和 id,但参数为空或只有 "{}" // 2. 下一个 block 没有 name,但有实际参数 // 3. 如果 next 有 ID,必须与 curr 相同(或 curr 无 ID) currArgsEmpty := curr.Arguments == "" || curr.Arguments == "{}" nextHasNoName := next.Name == "" nextHasArgs := next.Arguments != "" && next.Arguments != "{}" idMatch := next.ID == "" || curr.ID == "" || next.ID == curr.ID if curr.Name != "" && currArgsEmpty && nextHasNoName && nextHasArgs && idMatch { // 合并:将 next 的参数移到 curr,补全缺失字段 curr.Arguments = next.Arguments if curr.ID == "" && next.ID != "" { curr.ID = next.ID } toDelete[nextIdx] = true // 跳过下一个,因为已经处理了 i++ } } // 删除已合并的 block for idx := range toDelete { delete(s.toolCallAccumulator, idx) } } // IsParseFailed 检查解析是否失败 func (s *StreamSynthesizer) IsParseFailed() bool { return s.parseFailed } // HasToolCalls 检查是否有工具调用被处理 func (s *StreamSynthesizer) HasToolCalls() bool { return len(s.toolCallAccumulator) > 0 } ================================================ FILE: backend-go/internal/utils/token_counter.go ================================================ package utils import ( "encoding/json" "unicode" "github.com/BenedictKing/claude-proxy/internal/types" ) // EstimateTokens 估算文本的 token 数量 // 使用字符估算法: // - 中文/日文/韩文:约 1.5 字符/token // - 英文及其他:约 3.5 字符/token func EstimateTokens(text string) int { if text == "" { return 0 } cjkCount := 0 otherCount := 0 for _, r := range text { if isCJK(r) { cjkCount++ } else if !unicode.IsSpace(r) { otherCount++ } } // CJK: ~1.5 字符/token, 其他: ~3.5 字符/token cjkTokens := float64(cjkCount) / 1.5 otherTokens := float64(otherCount) / 3.5 return int(cjkTokens + otherTokens + 0.5) // 四舍五入 } // EstimateMessagesTokens 估算消息数组的 token 数量 func EstimateMessagesTokens(messages interface{}) int { if messages == nil { return 0 } // 序列化为 JSON 后估算 data, err := json.Marshal(messages) if err != nil { return 0 } // 每条消息额外开销约 4 tokens msgCount := 0 if arr, ok := messages.([]interface{}); ok { msgCount = len(arr) } return EstimateTokens(string(data)) + msgCount*4 } // EstimateRequestTokens 从请求体估算输入 token func EstimateRequestTokens(bodyBytes []byte) int { if len(bodyBytes) == 0 { return 0 } var req map[string]interface{} if err := json.Unmarshal(bodyBytes, &req); err != nil { return EstimateTokens(string(bodyBytes)) } total := 0 // system prompt if system, ok := req["system"]; ok { if str, ok := system.(string); ok { total += EstimateTokens(str) } else if arr, ok := system.([]interface{}); ok { for _, item := range arr { if m, ok := item.(map[string]interface{}); ok { if text, ok := m["text"].(string); ok { total += EstimateTokens(text) } } } } } // messages if messages, ok := req["messages"]; ok { total += EstimateMessagesTokens(messages) } // tools (每个工具约 100-200 tokens) if tools, ok := req["tools"].([]interface{}); ok { total += len(tools) * 150 } return total } // EstimateResponseTokens 从响应内容估算输出 token func EstimateResponseTokens(content interface{}) int { if content == nil { return 0 } // 字符串内容 if str, ok := content.(string); ok { return EstimateTokens(str) } // 内容数组 if arr, ok := content.([]interface{}); ok { total := 0 for _, item := range arr { if m, ok := item.(map[string]interface{}); ok { if text, ok := m["text"].(string); ok { total += EstimateTokens(text) } // tool_use 的 input 也计入 if input, ok := m["input"]; ok { data, _ := json.Marshal(input) total += EstimateTokens(string(data)) } } } return total } // 其他情况序列化后估算 data, err := json.Marshal(content) if err != nil { return 0 } return EstimateTokens(string(data)) } // isCJK 判断是否为中日韩字符 func isCJK(r rune) bool { return unicode.Is(unicode.Han, r) || unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) || unicode.Is(unicode.Hangul, r) } // ============== Responses API Token 估算 ============== // EstimateResponsesRequestTokens 从 Responses API 请求体估算输入 token // 支持 instructions、input (string 或 []item) 格式 func EstimateResponsesRequestTokens(bodyBytes []byte) int { if len(bodyBytes) == 0 { return 0 } var req map[string]interface{} if err := json.Unmarshal(bodyBytes, &req); err != nil { return EstimateTokens(string(bodyBytes)) } total := 0 // instructions (系统指令) if instructions, ok := req["instructions"].(string); ok { total += EstimateTokens(instructions) } // input 字段处理 if input := req["input"]; input != nil { total += estimateResponsesInputTokens(input) } // tools (每个工具约 100-200 tokens) if tools, ok := req["tools"].([]interface{}); ok { total += len(tools) * 150 } return total } // estimateResponsesInputTokens 估算 Responses input 字段的 token func estimateResponsesInputTokens(input interface{}) int { switch v := input.(type) { case string: // 简单字符串输入 return EstimateTokens(v) case []interface{}: // 消息数组格式 total := 0 for _, item := range v { if m, ok := item.(map[string]interface{}); ok { // 每条消息额外开销约 4 tokens total += 4 // 处理 content 字段 if content := m["content"]; content != nil { total += estimateContentTokens(content) } // 处理 tool_use if toolUse, ok := m["tool_use"].(map[string]interface{}); ok { data, _ := json.Marshal(toolUse) total += EstimateTokens(string(data)) } } } return total default: // 其他情况序列化后估算 data, err := json.Marshal(input) if err != nil { return 0 } return EstimateTokens(string(data)) } } // estimateContentTokens 估算 content 字段的 token func estimateContentTokens(content interface{}) int { switch v := content.(type) { case string: return EstimateTokens(v) case []interface{}: total := 0 for _, block := range v { if b, ok := block.(map[string]interface{}); ok { if text, ok := b["text"].(string); ok { total += EstimateTokens(text) } } } return total default: data, err := json.Marshal(content) if err != nil { return 0 } return EstimateTokens(string(data)) } } // EstimateResponsesOutputTokens 从 Responses API 响应估算输出 token // 支持 []ResponsesItem 格式 func EstimateResponsesOutputTokens(output interface{}) int { if output == nil { return 0 } // 处理 []types.ResponsesItem 类型 if items, ok := output.([]types.ResponsesItem); ok { total := 0 for _, item := range items { total += estimateResponsesItemTokens(item) } return total } // 处理 []interface{} 类型 if arr, ok := output.([]interface{}); ok { total := 0 for _, item := range arr { if m, ok := item.(map[string]interface{}); ok { // 处理 content 字段 if content := m["content"]; content != nil { total += estimateContentTokens(content) } // 处理 tool_use if toolUse, ok := m["tool_use"].(map[string]interface{}); ok { data, _ := json.Marshal(toolUse) total += EstimateTokens(string(data)) } // 处理 function_call 类型 if m["type"] == "function_call" { if args, ok := m["arguments"].(string); ok { total += EstimateTokens(args) } if name, ok := m["name"].(string); ok { total += EstimateTokens(name) + 2 // 函数名 + 开销 } } // 处理 reasoning 类型 if m["type"] == "reasoning" { if summary, ok := m["summary"].([]interface{}); ok { for _, s := range summary { if sm, ok := s.(map[string]interface{}); ok { if text, ok := sm["text"].(string); ok { total += EstimateTokens(text) } } } } } } } return total } // 其他情况序列化后估算 data, err := json.Marshal(output) if err != nil { return 0 } return EstimateTokens(string(data)) } // estimateResponsesItemTokens 估算单个 ResponsesItem 的 token 数 func estimateResponsesItemTokens(item types.ResponsesItem) int { total := 0 // 处理 content 字段 if item.Content != nil { total += estimateContentTokens(item.Content) } // 处理 tool_use if item.ToolUse != nil { data, _ := json.Marshal(item.ToolUse) total += EstimateTokens(string(data)) } // 如果是特殊类型且 content/tool_use 都为空,序列化整个结构估算 // 这处理 function_call、reasoning 等类型,其数据可能在其他字段中 if total == 0 && item.Type != "" && item.Type != "message" && item.Type != "text" { data, _ := json.Marshal(item) total = EstimateTokens(string(data)) } return total } ================================================ FILE: backend-go/internal/utils/token_counter_test.go ================================================ package utils import ( "encoding/json" "testing" "github.com/BenedictKing/claude-proxy/internal/types" ) func TestEstimateTokens(t *testing.T) { tests := []struct { name string text string expected int }{ {"empty", "", 0}, {"english", "Hello world", 3}, // ~11 chars / 3.5 = ~3 {"chinese", "你好世界", 2}, // 4 chars / 1.5 = ~2.7 -> 3 {"mixed", "Hello 你好", 3}, // 5 other + 2 cjk = ~1.4 + ~1.3 = ~3 } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := EstimateTokens(tt.text) // 允许 ±2 的误差 if result < tt.expected-2 || result > tt.expected+2 { t.Errorf("EstimateTokens(%q) = %d, want ~%d", tt.text, result, tt.expected) } }) } } func TestEstimateResponsesRequestTokens(t *testing.T) { tests := []struct { name string request map[string]interface{} minExpected int }{ { name: "simple_string_input", request: map[string]interface{}{ "model": "gpt-4", "input": "Hello, how are you?", }, minExpected: 5, }, { name: "with_instructions", request: map[string]interface{}{ "model": "gpt-4", "instructions": "You are a helpful assistant.", "input": "Hello", }, minExpected: 8, }, { name: "with_array_input", request: map[string]interface{}{ "model": "gpt-4", "input": []interface{}{ map[string]interface{}{ "type": "message", "role": "user", "content": "Hello, how are you today?", }, }, }, minExpected: 6, }, { name: "with_tools", request: map[string]interface{}{ "model": "gpt-4", "input": "Use the tool", "tools": []interface{}{ map[string]interface{}{"name": "search"}, map[string]interface{}{"name": "compute"}, }, }, minExpected: 300, // 2 tools * 150 = 300 }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { bodyBytes, _ := json.Marshal(tt.request) result := EstimateResponsesRequestTokens(bodyBytes) if result < tt.minExpected { t.Errorf("EstimateResponsesRequestTokens() = %d, want >= %d", result, tt.minExpected) } }) } } func TestEstimateResponsesOutputTokens(t *testing.T) { tests := []struct { name string output interface{} minExpected int }{ { name: "nil_output", output: nil, minExpected: 0, }, { name: "message_with_text", output: []interface{}{ map[string]interface{}{ "type": "message", "content": []interface{}{ map[string]interface{}{ "type": "output_text", "text": "Hello, I am doing well!", }, }, }, }, minExpected: 5, }, { name: "function_call", output: []interface{}{ map[string]interface{}{ "type": "function_call", "name": "search", "arguments": `{"query": "weather"}`, }, }, minExpected: 5, }, { name: "reasoning_with_summary", output: []interface{}{ map[string]interface{}{ "type": "reasoning", "summary": []interface{}{ map[string]interface{}{ "type": "summary_text", "text": "This is my reasoning process", }, }, }, }, minExpected: 5, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := EstimateResponsesOutputTokens(tt.output) if result < tt.minExpected { t.Errorf("EstimateResponsesOutputTokens() = %d, want >= %d", result, tt.minExpected) } }) } } func TestEstimateResponsesOutputTokensWithTypedItems(t *testing.T) { // 测试 []types.ResponsesItem 类型的直接处理 items := []types.ResponsesItem{ { Type: "message", Role: "assistant", Content: "Hello, I am doing well!", }, { Type: "text", Content: []types.ContentBlock{ {Type: "output_text", Text: "This is output text"}, }, }, } result := EstimateResponsesOutputTokens(items) if result < 5 { t.Errorf("EstimateResponsesOutputTokens([]types.ResponsesItem) = %d, want >= 5", result) } } func TestEstimateRequestTokens(t *testing.T) { tests := []struct { name string request map[string]interface{} minExpected int }{ { name: "messages_api_request", request: map[string]interface{}{ "model": "claude-3", "system": "You are a helpful assistant.", "messages": []interface{}{ map[string]interface{}{ "role": "user", "content": "Hello!", }, }, }, minExpected: 8, }, { name: "with_system_array", request: map[string]interface{}{ "model": "claude-3", "system": []interface{}{ map[string]interface{}{ "type": "text", "text": "You are helpful.", }, }, }, minExpected: 4, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { bodyBytes, _ := json.Marshal(tt.request) result := EstimateRequestTokens(bodyBytes) if result < tt.minExpected { t.Errorf("EstimateRequestTokens() = %d, want >= %d", result, tt.minExpected) } }) } } ================================================ FILE: backend-go/internal/warmup/url_manager.go ================================================ // Package warmup 提供多端点渠道的 URL 管理和动态排序功能 package warmup import ( "log" "sort" "sync" "time" ) // URLLatencyResult 单个 URL 的结果(兼容旧接口) type URLLatencyResult struct { URL string OriginalIdx int // 原始索引(用于指标记录) Success bool // 是否可用(未在冷却期内) } // URLState URL 状态信息 type URLState struct { URL string OriginalIdx int // 原始索引(用于指标记录) FailCount int // 连续失败次数 LastFailTime time.Time // 最后失败时间 LastSuccessTime time.Time // 最后成功时间 TotalRequests int64 // 总请求数 TotalFailures int64 // 总失败数 } // ChannelURLState 渠道 URL 状态 type ChannelURLState struct { ChannelIndex int URLs []*URLState UpdatedAt time.Time } // URLManager URL 管理器(非阻塞,基于 failover 动态排序) type URLManager struct { mu sync.RWMutex channelStates map[int]*ChannelURLState // key: channelIndex failureCooldown time.Duration // 失败冷却时间(过后允许重试) maxFailCount int // 最大连续失败次数(超过则移到末尾) } // NewURLManager 创建 URL 管理器 func NewURLManager(failureCooldown time.Duration, maxFailCount int) *URLManager { if failureCooldown <= 0 { failureCooldown = 30 * time.Second // 默认 30 秒冷却 } if maxFailCount <= 0 { maxFailCount = 3 // 默认连续 3 次失败后移到末尾 } return &URLManager{ channelStates: make(map[int]*ChannelURLState), failureCooldown: failureCooldown, maxFailCount: maxFailCount, } } // GetSortedURLs 获取排序后的 URL 列表(非阻塞,立即返回) // 排序规则: // 1. 成功的 URL 优先 // 2. 冷却期过后的失败 URL 可重试 // 3. 仍在冷却期的失败 URL 放到最后 func (m *URLManager) GetSortedURLs(channelIndex int, urls []string) []URLLatencyResult { if len(urls) == 0 { return nil } if len(urls) == 1 { return []URLLatencyResult{{URL: urls[0], OriginalIdx: 0, Success: true}} } m.mu.Lock() defer m.mu.Unlock() // 确保渠道状态存在并同步 URL 列表 state := m.ensureChannelState(channelIndex, urls) // 每次获取时重新排序,确保冷却期过后的 URL 能被正确提升 m.sortURLs(state) // 构建排序后的结果 now := time.Now() results := make([]URLLatencyResult, len(state.URLs)) for i, urlState := range state.URLs { results[i] = URLLatencyResult{ URL: urlState.URL, OriginalIdx: urlState.OriginalIdx, Success: urlState.FailCount == 0 || now.Sub(urlState.LastFailTime) >= m.failureCooldown, } } return results } // MarkSuccess 标记 URL 成功 func (m *URLManager) MarkSuccess(channelIndex int, url string) { m.mu.Lock() defer m.mu.Unlock() state, ok := m.channelStates[channelIndex] if !ok { return } for _, urlState := range state.URLs { if urlState.URL == url { urlState.FailCount = 0 urlState.LastSuccessTime = time.Now() urlState.TotalRequests++ break } } // 成功后重新排序:成功的 URL 提升到前面 m.sortURLs(state) state.UpdatedAt = time.Now() } // MarkFailure 标记 URL 失败 func (m *URLManager) MarkFailure(channelIndex int, url string) { m.mu.Lock() defer m.mu.Unlock() state, ok := m.channelStates[channelIndex] if !ok { return } now := time.Now() for _, urlState := range state.URLs { if urlState.URL == url { urlState.FailCount++ urlState.LastFailTime = now urlState.TotalRequests++ urlState.TotalFailures++ log.Printf("[URLManager] URL 失败: 渠道 [%d], URL: %s, 连续失败: %d", channelIndex, url, urlState.FailCount) break } } // 失败后重新排序:失败的 URL 移到后面 m.sortURLs(state) state.UpdatedAt = time.Now() } // ensureChannelState 确保渠道状态存在,并同步 URL 列表 func (m *URLManager) ensureChannelState(channelIndex int, urls []string) *ChannelURLState { state, ok := m.channelStates[channelIndex] if !ok { // 初始化新渠道状态 state = &ChannelURLState{ ChannelIndex: channelIndex, URLs: make([]*URLState, len(urls)), UpdatedAt: time.Now(), } for i, url := range urls { state.URLs[i] = &URLState{ URL: url, OriginalIdx: i, } } m.channelStates[channelIndex] = state return state } // 检查 URL 列表是否变化(配置热重载场景) if !m.urlsMatch(state.URLs, urls) { log.Printf("[URLManager] 检测到渠道 [%d] URL 配置变化,重置状态", channelIndex) state = &ChannelURLState{ ChannelIndex: channelIndex, URLs: make([]*URLState, len(urls)), UpdatedAt: time.Now(), } for i, url := range urls { state.URLs[i] = &URLState{ URL: url, OriginalIdx: i, } } m.channelStates[channelIndex] = state } return state } // urlsMatch 检查 URL 列表是否匹配 func (m *URLManager) urlsMatch(states []*URLState, urls []string) bool { if len(states) != len(urls) { return false } urlSet := make(map[string]bool) for _, url := range urls { urlSet[url] = true } for _, state := range states { if !urlSet[state.URL] { return false } } return true } // sortURLs 对 URL 列表排序 // 排序规则: // 1. 无失败记录的 URL 在最前(按原始索引排序) // 2. 冷却期已过的失败 URL 次之(按失败次数升序) // 3. 仍在冷却期的失败 URL 在最后(按冷却剩余时间升序) func (m *URLManager) sortURLs(state *ChannelURLState) { now := time.Now() sort.SliceStable(state.URLs, func(i, j int) bool { ui, uj := state.URLs[i], state.URLs[j] // 无失败记录的优先 iNoFail := ui.FailCount == 0 jNoFail := uj.FailCount == 0 if iNoFail != jNoFail { return iNoFail } if iNoFail && jNoFail { // 都无失败,按原始索引 return ui.OriginalIdx < uj.OriginalIdx } // 都有失败记录,检查冷却期 iCooldownPassed := now.Sub(ui.LastFailTime) >= m.failureCooldown jCooldownPassed := now.Sub(uj.LastFailTime) >= m.failureCooldown if iCooldownPassed != jCooldownPassed { return iCooldownPassed // 冷却期过了的优先 } if iCooldownPassed && jCooldownPassed { // 都过了冷却期,失败次数少的优先 if ui.FailCount != uj.FailCount { return ui.FailCount < uj.FailCount } return ui.OriginalIdx < uj.OriginalIdx } // 都在冷却期内,剩余冷却时间短的优先 iRemaining := m.failureCooldown - now.Sub(ui.LastFailTime) jRemaining := m.failureCooldown - now.Sub(uj.LastFailTime) return iRemaining < jRemaining }) } // InvalidateChannel 使渠道状态失效 func (m *URLManager) InvalidateChannel(channelIndex int) { m.mu.Lock() defer m.mu.Unlock() delete(m.channelStates, channelIndex) log.Printf("[URLManager] 渠道 [%d] 状态已清除", channelIndex) } // InvalidateAll 清除所有状态 func (m *URLManager) InvalidateAll() { m.mu.Lock() defer m.mu.Unlock() m.channelStates = make(map[int]*ChannelURLState) log.Printf("[URLManager] 所有渠道状态已清除") } // GetStats 获取统计信息 func (m *URLManager) GetStats() map[string]interface{} { m.mu.RLock() defer m.mu.RUnlock() channelStats := make(map[int]interface{}) for idx, state := range m.channelStates { urlStats := make([]map[string]interface{}, len(state.URLs)) for i, urlState := range state.URLs { urlStats[i] = map[string]interface{}{ "url": urlState.URL, "original_idx": urlState.OriginalIdx, "fail_count": urlState.FailCount, "total_requests": urlState.TotalRequests, "total_failures": urlState.TotalFailures, "last_fail_time": urlState.LastFailTime, "last_success_time": urlState.LastSuccessTime, } } channelStats[idx] = map[string]interface{}{ "urls": urlStats, "updated_at": state.UpdatedAt, } } return map[string]interface{}{ "total_channels": len(m.channelStates), "failure_cooldown": m.failureCooldown.String(), "max_fail_count": m.maxFailCount, "channels": channelStats, } } ================================================ FILE: backend-go/main.go ================================================ package main import ( "context" "embed" "fmt" "log" "net/http" "os" "os/signal" "syscall" "time" "github.com/BenedictKing/claude-proxy/internal/config" "github.com/BenedictKing/claude-proxy/internal/handlers" "github.com/BenedictKing/claude-proxy/internal/handlers/gemini" "github.com/BenedictKing/claude-proxy/internal/handlers/messages" "github.com/BenedictKing/claude-proxy/internal/handlers/responses" "github.com/BenedictKing/claude-proxy/internal/logger" "github.com/BenedictKing/claude-proxy/internal/metrics" "github.com/BenedictKing/claude-proxy/internal/middleware" "github.com/BenedictKing/claude-proxy/internal/scheduler" "github.com/BenedictKing/claude-proxy/internal/session" "github.com/BenedictKing/claude-proxy/internal/warmup" "github.com/gin-gonic/gin" "github.com/joho/godotenv" ) //go:embed all:frontend/dist var frontendFS embed.FS func main() { // 加载环境变量 if err := godotenv.Load(); err != nil { log.Println("没有找到 .env 文件,使用环境变量或默认值") } // 设置版本信息到 handlers 包 handlers.SetVersionInfo(Version, BuildTime, GitCommit) // 初始化配置管理器 envCfg := config.NewEnvConfig() // 初始化日志系统(必须在其他初始化之前) logCfg := &logger.Config{ LogDir: envCfg.LogDir, LogFile: envCfg.LogFile, MaxSize: envCfg.LogMaxSize, MaxBackups: envCfg.LogMaxBackups, MaxAge: envCfg.LogMaxAge, Compress: envCfg.LogCompress, Console: envCfg.LogToConsole, } if err := logger.Setup(logCfg); err != nil { log.Fatalf("初始化日志系统失败: %v", err) } cfgManager, err := config.NewConfigManager(".config/config.json") if err != nil { log.Fatalf("初始化配置管理器失败: %v", err) } defer cfgManager.Close() // 初始化会话管理器(Responses API 专用) sessionManager := session.NewSessionManager( 24*time.Hour, // 24小时过期 100, // 最多100条消息 100000, // 最多100k tokens ) log.Printf("[Session-Init] 会话管理器已初始化") // 初始化指标持久化存储(可选) var metricsStore *metrics.SQLiteStore if envCfg.MetricsPersistenceEnabled { var err error metricsStore, err = metrics.NewSQLiteStore(&metrics.SQLiteStoreConfig{ DBPath: ".config/metrics.db", RetentionDays: envCfg.MetricsRetentionDays, }) if err != nil { log.Printf("[Metrics-Init] 警告: 初始化指标持久化存储失败: %v,将使用纯内存模式", err) metricsStore = nil } } else { log.Printf("[Metrics-Init] 指标持久化已禁用,使用纯内存模式") } // 初始化多渠道调度器(Messages、Responses 和 Gemini 使用独立的指标管理器) var messagesMetricsManager, responsesMetricsManager, geminiMetricsManager *metrics.MetricsManager if metricsStore != nil { messagesMetricsManager = metrics.NewMetricsManagerWithPersistence( envCfg.MetricsWindowSize, envCfg.MetricsFailureThreshold, metricsStore, "messages") responsesMetricsManager = metrics.NewMetricsManagerWithPersistence( envCfg.MetricsWindowSize, envCfg.MetricsFailureThreshold, metricsStore, "responses") geminiMetricsManager = metrics.NewMetricsManagerWithPersistence( envCfg.MetricsWindowSize, envCfg.MetricsFailureThreshold, metricsStore, "gemini") } else { messagesMetricsManager = metrics.NewMetricsManagerWithConfig(envCfg.MetricsWindowSize, envCfg.MetricsFailureThreshold) responsesMetricsManager = metrics.NewMetricsManagerWithConfig(envCfg.MetricsWindowSize, envCfg.MetricsFailureThreshold) geminiMetricsManager = metrics.NewMetricsManagerWithConfig(envCfg.MetricsWindowSize, envCfg.MetricsFailureThreshold) } traceAffinityManager := session.NewTraceAffinityManager() // 初始化 URL 管理器(非阻塞,动态排序) urlManager := warmup.NewURLManager(30*time.Second, 3) // 30秒冷却期,连续3次失败后移到末尾 log.Printf("[URLManager-Init] URL管理器已初始化 (冷却期: 30秒, 最大连续失败: 3)") channelScheduler := scheduler.NewChannelScheduler(cfgManager, messagesMetricsManager, responsesMetricsManager, geminiMetricsManager, traceAffinityManager, urlManager) log.Printf("[Scheduler-Init] 多渠道调度器已初始化 (失败率阈值: %.0f%%, 滑动窗口: %d)", messagesMetricsManager.GetFailureThreshold()*100, messagesMetricsManager.GetWindowSize()) // 设置 Gin 模式 if envCfg.IsProduction() { gin.SetMode(gin.ReleaseMode) } // 创建路由器(使用自定义 Logger,根据 QUIET_POLLING_LOGS 配置过滤轮询日志) r := gin.New() r.Use(middleware.FilteredLogger(envCfg)) r.Use(gin.Recovery()) // 配置 CORS r.Use(middleware.CORSMiddleware(envCfg)) // Web UI 访问控制中间件 r.Use(middleware.WebAuthMiddleware(envCfg, cfgManager)) // 健康检查端点(固定路径 /health,与 Dockerfile HEALTHCHECK 保持一致) r.GET("/health", handlers.HealthCheck(envCfg, cfgManager)) // 配置保存端点 r.POST("/admin/config/save", handlers.SaveConfigHandler(cfgManager)) // 开发信息端点 if envCfg.IsDevelopment() { r.GET("/admin/dev/info", handlers.DevInfo(envCfg, cfgManager)) } // Web 管理界面 API 路由 apiGroup := r.Group("/api") { // Messages 渠道管理 apiGroup.GET("/messages/channels", messages.GetUpstreams(cfgManager)) apiGroup.POST("/messages/channels", messages.AddUpstream(cfgManager)) apiGroup.PUT("/messages/channels/:id", messages.UpdateUpstream(cfgManager, channelScheduler)) apiGroup.DELETE("/messages/channels/:id", messages.DeleteUpstream(cfgManager, channelScheduler)) apiGroup.POST("/messages/channels/:id/keys", messages.AddApiKey(cfgManager)) apiGroup.DELETE("/messages/channels/:id/keys/:apiKey", messages.DeleteApiKey(cfgManager)) apiGroup.POST("/messages/channels/:id/keys/:apiKey/top", messages.MoveApiKeyToTop(cfgManager)) apiGroup.POST("/messages/channels/:id/keys/:apiKey/bottom", messages.MoveApiKeyToBottom(cfgManager)) // Messages 多渠道调度 API apiGroup.POST("/messages/channels/reorder", messages.ReorderChannels(cfgManager)) apiGroup.PATCH("/messages/channels/:id/status", messages.SetChannelStatus(cfgManager)) apiGroup.POST("/messages/channels/:id/resume", handlers.ResumeChannel(channelScheduler, false)) apiGroup.POST("/messages/channels/:id/promotion", messages.SetChannelPromotion(cfgManager)) apiGroup.GET("/messages/channels/metrics", handlers.GetChannelMetricsWithConfig(messagesMetricsManager, cfgManager, false)) apiGroup.GET("/messages/channels/metrics/history", handlers.GetChannelMetricsHistory(messagesMetricsManager, cfgManager, false)) apiGroup.GET("/messages/channels/:id/keys/metrics/history", handlers.GetChannelKeyMetricsHistory(messagesMetricsManager, cfgManager, false)) apiGroup.GET("/messages/channels/scheduler/stats", handlers.GetSchedulerStats(channelScheduler)) apiGroup.GET("/messages/global/stats/history", handlers.GetGlobalStatsHistory(messagesMetricsManager)) apiGroup.GET("/messages/channels/dashboard", handlers.GetChannelDashboard(cfgManager, channelScheduler)) apiGroup.GET("/messages/ping/:id", messages.PingChannel(cfgManager)) apiGroup.GET("/messages/ping", messages.PingAllChannels(cfgManager)) // Responses 渠道管理 apiGroup.GET("/responses/channels", responses.GetUpstreams(cfgManager)) apiGroup.POST("/responses/channels", responses.AddUpstream(cfgManager)) apiGroup.PUT("/responses/channels/:id", responses.UpdateUpstream(cfgManager, channelScheduler)) apiGroup.DELETE("/responses/channels/:id", responses.DeleteUpstream(cfgManager, channelScheduler)) apiGroup.POST("/responses/channels/:id/keys", responses.AddApiKey(cfgManager)) apiGroup.DELETE("/responses/channels/:id/keys/:apiKey", responses.DeleteApiKey(cfgManager)) apiGroup.POST("/responses/channels/:id/keys/:apiKey/top", responses.MoveApiKeyToTop(cfgManager)) apiGroup.POST("/responses/channels/:id/keys/:apiKey/bottom", responses.MoveApiKeyToBottom(cfgManager)) // Responses 多渠道调度 API apiGroup.POST("/responses/channels/reorder", responses.ReorderChannels(cfgManager)) apiGroup.PATCH("/responses/channels/:id/status", responses.SetChannelStatus(cfgManager)) apiGroup.POST("/responses/channels/:id/resume", handlers.ResumeChannel(channelScheduler, true)) apiGroup.POST("/responses/channels/:id/promotion", handlers.SetResponsesChannelPromotion(cfgManager)) apiGroup.GET("/responses/channels/metrics", handlers.GetChannelMetricsWithConfig(responsesMetricsManager, cfgManager, true)) apiGroup.GET("/responses/channels/metrics/history", handlers.GetChannelMetricsHistory(responsesMetricsManager, cfgManager, true)) apiGroup.GET("/responses/channels/:id/keys/metrics/history", handlers.GetChannelKeyMetricsHistory(responsesMetricsManager, cfgManager, true)) apiGroup.GET("/responses/global/stats/history", handlers.GetGlobalStatsHistory(responsesMetricsManager)) // Gemini 渠道管理 apiGroup.GET("/gemini/channels", gemini.GetUpstreams(cfgManager)) apiGroup.POST("/gemini/channels", gemini.AddUpstream(cfgManager)) apiGroup.PUT("/gemini/channels/:id", gemini.UpdateUpstream(cfgManager, channelScheduler)) apiGroup.DELETE("/gemini/channels/:id", gemini.DeleteUpstream(cfgManager, channelScheduler)) apiGroup.POST("/gemini/channels/:id/keys", gemini.AddApiKey(cfgManager)) apiGroup.DELETE("/gemini/channels/:id/keys/:apiKey", gemini.DeleteApiKey(cfgManager)) apiGroup.POST("/gemini/channels/:id/keys/:apiKey/top", gemini.MoveApiKeyToTop(cfgManager)) apiGroup.POST("/gemini/channels/:id/keys/:apiKey/bottom", gemini.MoveApiKeyToBottom(cfgManager)) // Gemini 多渠道调度 API apiGroup.POST("/gemini/channels/reorder", gemini.ReorderChannels(cfgManager)) apiGroup.PATCH("/gemini/channels/:id/status", gemini.SetChannelStatus(cfgManager)) apiGroup.POST("/gemini/channels/:id/promotion", gemini.SetChannelPromotion(cfgManager)) apiGroup.PUT("/gemini/loadbalance", gemini.UpdateLoadBalance(cfgManager)) apiGroup.GET("/gemini/channels/dashboard", gemini.GetDashboard(cfgManager, channelScheduler)) apiGroup.GET("/gemini/channels/metrics", handlers.GetGeminiChannelMetrics(geminiMetricsManager, cfgManager)) apiGroup.GET("/gemini/channels/metrics/history", handlers.GetGeminiChannelMetricsHistory(geminiMetricsManager, cfgManager)) apiGroup.GET("/gemini/channels/:id/keys/metrics/history", handlers.GetGeminiChannelKeyMetricsHistory(geminiMetricsManager, cfgManager)) apiGroup.GET("/gemini/global/stats/history", handlers.GetGlobalStatsHistory(geminiMetricsManager)) apiGroup.GET("/gemini/ping/:id", gemini.PingChannel(cfgManager)) apiGroup.GET("/gemini/ping", gemini.PingAllChannels(cfgManager)) // Fuzzy 模式设置 apiGroup.GET("/settings/fuzzy-mode", handlers.GetFuzzyMode(cfgManager)) apiGroup.PUT("/settings/fuzzy-mode", handlers.SetFuzzyMode(cfgManager)) } // 代理端点 - Messages API r.POST("/v1/messages", messages.Handler(envCfg, cfgManager, channelScheduler)) r.POST("/v1/messages/count_tokens", messages.CountTokensHandler(envCfg, cfgManager, channelScheduler)) // 代理端点 - Models API(转发到上游) r.GET("/v1/models", messages.ModelsHandler(envCfg, cfgManager, channelScheduler)) r.GET("/v1/models/:model", messages.ModelsDetailHandler(envCfg, cfgManager, channelScheduler)) // 代理端点 - Responses API r.POST("/v1/responses", responses.Handler(envCfg, cfgManager, sessionManager, channelScheduler)) r.POST("/v1/responses/compact", responses.CompactHandler(envCfg, cfgManager, sessionManager, channelScheduler)) // 代理端点 - Gemini API (原生协议) // 使用通配符捕获 model:action 格式,如 gemini-pro:generateContent // 路径格式:/v1beta/models/{model}:generateContent (Gemini 原生格式) r.POST("/v1beta/models/*modelAction", gemini.Handler(envCfg, cfgManager, channelScheduler)) // 静态文件服务 (嵌入的前端) if envCfg.EnableWebUI { handlers.ServeFrontend(r, frontendFS) } else { // 纯 API 模式 r.GET("/", func(c *gin.Context) { c.JSON(200, gin.H{ "name": "Claude API Proxy", "mode": "API Only", "version": "1.0.0", "endpoints": gin.H{ "health": "/health", "proxy": "/v1/messages", "config": "/admin/config/save", }, "message": "Web界面已禁用,此服务器运行在纯API模式下", }) }) } // 启动服务器 addr := fmt.Sprintf(":%d", envCfg.Port) fmt.Printf("\n[Server-Startup] Claude API代理服务器已启动\n") fmt.Printf("[Server-Info] 版本: %s\n", Version) if BuildTime != "unknown" { fmt.Printf("[Server-Info] 构建时间: %s\n", BuildTime) } if GitCommit != "unknown" { fmt.Printf("[Server-Info] Git提交: %s\n", GitCommit) } fmt.Printf("[Server-Info] 管理界面: http://localhost:%d\n", envCfg.Port) fmt.Printf("[Server-Info] API 地址: http://localhost:%d/v1\n", envCfg.Port) fmt.Printf("[Server-Info] Claude Messages: POST /v1/messages\n") fmt.Printf("[Server-Info] Codex Responses: POST /v1/responses\n") fmt.Printf("[Server-Info] Gemini API: POST /v1beta/models/{model}:generateContent\n") fmt.Printf("[Server-Info] Gemini API: POST /v1beta/models/{model}:streamGenerateContent\n") fmt.Printf("[Server-Info] 健康检查: GET /health\n") fmt.Printf("[Server-Info] 环境: %s\n", envCfg.Env) // 检查是否使用默认密码,给予提示 if envCfg.ProxyAccessKey == "your-proxy-access-key" { fmt.Printf("[Server-Warn] 访问密钥: your-proxy-access-key (默认值,建议通过 .env 文件修改)\n") } fmt.Printf("\n") // 创建 HTTP 服务器 srv := &http.Server{ Addr: addr, Handler: r, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second, } // 用于传递关闭结果 shutdownDone := make(chan struct{}) // 优雅关闭:监听系统信号 go func() { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) <-sigChan signal.Stop(sigChan) // 停止信号监听,避免资源泄漏 log.Println("[Server-Shutdown] 收到关闭信号,正在优雅关闭服务器...") // 创建超时上下文 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { log.Printf("[Server-Shutdown] 警告: 服务器关闭时发生错误: %v", err) } else { log.Println("[Server-Shutdown] 服务器已安全关闭") } // 关闭指标持久化存储 if metricsStore != nil { if err := metricsStore.Close(); err != nil { log.Printf("[Metrics-Shutdown] 警告: 关闭指标存储时发生错误: %v", err) } else { log.Println("[Metrics-Shutdown] 指标存储已安全关闭") } } close(shutdownDone) }() // 启动服务器(阻塞直到关闭) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("服务器启动失败: %v", err) } // 等待关闭完成(带超时保护,避免死锁) select { case <-shutdownDone: // 正常关闭完成 case <-time.After(15 * time.Second): log.Println("[Server-Shutdown] 警告: 等待关闭超时") } } ================================================ FILE: backend-go/version.go ================================================ package main // 版本信息变量 - 在构建时通过 -ldflags 注入 // 实际值从根目录 VERSION 文件读取 var ( // Version 当前版本号(构建时从 VERSION 文件注入) Version = "v0.0.0-dev" // BuildTime 构建时间(构建时注入) BuildTime = "unknown" // GitCommit Git提交哈希(构建时注入) GitCommit = "unknown" ) ================================================ FILE: docker-compose.yml ================================================ services: # Claude API 代理服务 (一体化架构: 后端 + 前端界面) claude-proxy: image: crpi-i19l8zl0ugidq97v.cn-hangzhou.personal.cr.aliyuncs.com/bene/claude-proxy:latest # 本地构建使用以下配置(注释掉 image 行): # build: # context: . # dockerfile: Dockerfile # # 中国网络使用: # # dockerfile: Dockerfile_China # target: runtime container_name: claude-proxy ports: # 统一端口:前端界面 + API + 管理接口 - '3000:3000' volumes: # 配置目录持久化 - ./.config:/app/.config # 可选:日志持久化 - ./logs:/app/logs environment: # 基础配置 - ENV=production # 是否启用Web管理界面 (true=一体化模式, false=纯API模式) - ENABLE_WEB_UI=true # 代理访问密钥 - 用于验证客户端对代理服务器的访问权限 - PROXY_ACCESS_KEY=your-super-strong-secret-key # 日志级别 (error, warn, info, debug) - LOG_LEVEL=warn # 是否启用请求日志 - ENABLE_REQUEST_LOGS=false # 是否启用响应日志 - ENABLE_RESPONSE_LOGS=false # 健康检查配置 healthcheck: test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:3000/health'] interval: 30s timeout: 10s retries: 3 start_period: 40s # 资源限制 (Go 应用内存占用更低) deploy: resources: limits: memory: 256M cpus: '0.5' reservations: memory: 64M cpus: '0.25' # 重启策略 restart: unless-stopped # 网络配置(可选,使用默认bridge网络) # networks: # default: # driver: bridge ================================================ FILE: frontend/.env.example ================================================ # 开发环境配置示例 # 复制此文件为 .env 并根据需要修改 # 后端API服务器地址(需与 backend-go/.env 中的 PORT 一致) VITE_BACKEND_URL=http://localhost:3000 # 前端开发服务器端口 VITE_FRONTEND_PORT=5173 # API路径配置 VITE_API_BASE_PATH=/api VITE_PROXY_API_PATH=/v1 # 开发模式标识 VITE_APP_ENV=development ================================================ FILE: frontend/CLAUDE.md ================================================ # frontend 模块文档 [← 根目录](../CLAUDE.md) ## 模块职责 Vue 3 + Vuetify 3 Web 管理界面:渠道配置、实时监控、拖拽排序、主题切换。 ## 启动命令 ```bash bun run dev # 开发服务器 bun run build # 生产构建 bun run preview # 预览构建 ``` ## 核心组件 | 组件 | 职责 | |------|------| | `App.vue` | 根组件,认证和布局 | | `ChannelOrchestration.vue` | 渠道编排主界面 | | `ChannelCard.vue` | 渠道卡片(状态、密钥、指标) | | `AddChannelModal.vue` | 添加/编辑渠道对话框 | ## API 服务 `src/services/api.ts` 封装后端交互: - `fetchChannels()` / `addChannel()` / `updateChannel()` / `deleteChannel()` - `pingChannel()` / `pingAllChannels()` - `reorderChannels()` / `setChannelStatus()` ## 主题配置 编辑 `src/plugins/vuetify.ts` 中的 `lightTheme` 和 `darkTheme`。 ## 图标系统 项目使用 **SVG 按需导入** 方案,从 `@mdi/js` 导入单个图标 path,而非完整字体文件,显著减小打包体积。 **配置文件**: `src/plugins/vuetify.ts` **新增图标步骤**: 1. 从 `@mdi/js` 添加导入(驼峰命名) 2. 在 `iconMap` 中添加映射(kebab-case) ```typescript // 1. 导入 import { mdiNewIcon } from '@mdi/js' // 2. 映射 const iconMap = { 'new-icon': mdiNewIcon, } ``` **使用方式**: 模板中使用 `mdi-xxx` 格式 ```vue mdi-new-icon ``` **图标查找**: https://pictogrammers.com/library/mdi/ ## 构建产物 生产构建输出到 `dist/`,会被嵌入到 Go 后端二进制文件中(`embed.FS`)。 ================================================ FILE: frontend/ESLINT.md ================================================ # ESLint 配置说明 ## 已安装的包 - `eslint` - ESLint 核心 - `@eslint/js` - ESLint JavaScript 推荐规则 - `eslint-plugin-vue` - Vue 3 专用规则 - `vue-eslint-parser` - Vue 文件解析器 - `@typescript-eslint/parser` - TypeScript 解析器 - `@typescript-eslint/eslint-plugin` - TypeScript 规则 - `eslint-config-prettier` - 禁用与 Prettier 冲突的规则 - `eslint-plugin-prettier` - 将 Prettier 作为 ESLint 规则运行 ## 可用命令 ```bash # 检查代码 npm run lint # 自动修复可修复的问题 npm run lint:fix # 格式化代码(Prettier) npm run format ``` ## 配置特点 ### 1. ESLint 9+ Flat Config 格式 使用最新的 Flat Config 格式(`eslint.config.js`),不再使用旧的 `.eslintrc` 格式。 ### 2. Vue 3 支持 - 使用 `eslint-plugin-vue` 的推荐规则 - 支持 Vue 3 Composition API - 自动检测 Vue 组件问题 ### 3. TypeScript 支持 - 完整的 TypeScript 类型检查 - 自动检测未使用的变量(以 `_` 开头的变量会被忽略) - 警告使用 `any` 类型 ### 4. Prettier 集成 - 自动禁用与 Prettier 冲突的规则 - 保持代码风格一致性 - 可以通过 `npm run format` 格式化代码 ### 5. 浏览器环境支持 配置了常用的浏览器全局变量: - `window`, `document`, `navigator` - `localStorage`, `sessionStorage` - `setTimeout`, `setInterval` - `fetch`, `URL`, `AbortController` - 等等 ## 主要规则 ### Vue 规则 - ✅ 允许单词组件名(`vue/multi-word-component-names: off`) - ⚠️ 警告使用 `v-html` - ⚠️ 建议显式声明 `emits` - ❌ 强制自闭合标签规范 ### TypeScript 规则 - ⚠️ 警告使用 `any` 类型 - ⚠️ 警告未使用的变量(以 `_` 开头除外) ### 通用规则 - ⚠️ 生产环境警告 `console.log` - ❌ 生产环境禁止 `debugger` - ⚠️ 建议使用 `const` 而非 `let` - ❌ 禁止使用 `var` ## 忽略的文件 以下文件/目录会被自动忽略: - `dist/**` - 构建产物 - `node_modules/**` - 依赖包 - `*.config.js` / `*.config.ts` - 配置文件 - `coverage/**` - 测试覆盖率报告 - `.vite/**` - Vite 缓存 ## 与 Prettier 的关系 ESLint 负责代码质量检查(逻辑错误、最佳实践等),Prettier 负责代码格式化(缩进、引号、分号等)。两者通过 `eslint-config-prettier` 完美集成,不会产生冲突。 ## 建议的工作流 1. **开发时**:编辑器实时显示 ESLint 警告/错误 2. **提交前**:运行 `npm run lint:fix` 自动修复问题 3. **CI/CD**:在持续集成中运行 `npm run lint` 确保代码质量 ## IDE 集成 ### VS Code 安装 ESLint 扩展: ``` ext install dbaeumer.vscode-eslint ``` 在 `.vscode/settings.json` 中添加: ```json { "editor.codeActionsOnSave": { "source.fixAll.eslint": true }, "eslint.validate": [ "javascript", "typescript", "vue" ] } ``` ### WebStorm / IntelliJ IDEA ESLint 支持已内置,在设置中启用即可: `Settings → Languages & Frameworks → JavaScript → Code Quality Tools → ESLint` ## 自定义规则 如需修改规则,编辑 `eslint.config.js` 文件。例如: ```javascript { rules: { // 关闭某个规则 'vue/multi-word-component-names': 'off', // 修改规则级别(off / warn / error) 'no-console': 'warn', // 带选项的规则 '@typescript-eslint/no-unused-vars': [ 'warn', { argsIgnorePattern: '^_' } ] } } ``` ## 常见问题 ### Q: 为什么有些规则显示警告而不是错误? A: 警告不会阻止代码运行,但会提醒您注意潜在问题。错误则必须修复。 ### Q: 如何临时禁用某个规则? A: 使用 ESLint 注释: ```javascript // eslint-disable-next-line no-console console.log('debug info') /* eslint-disable vue/multi-word-component-names */ // 多行代码 /* eslint-enable vue/multi-word-component-names */ ``` ### Q: ESLint 和 Prettier 冲突怎么办? A: 已通过 `eslint-config-prettier` 解决冲突,不应该出现此问题。如果遇到,请检查配置顺序。 ## 参考资源 - [ESLint 官方文档](https://eslint.org/) - [eslint-plugin-vue 文档](https://eslint.vuejs.org/) - [TypeScript ESLint 文档](https://typescript-eslint.io/) - [Prettier 官方文档](https://prettier.io/) ================================================ FILE: frontend/eslint.config.js ================================================ import js from '@eslint/js' import pluginVue from 'eslint-plugin-vue' import vueParser from 'vue-eslint-parser' import tsParser from '@typescript-eslint/parser' import tsPlugin from '@typescript-eslint/eslint-plugin' import prettierConfig from 'eslint-config-prettier' export default [ // 基础 JavaScript 推荐规则 js.configs.recommended, // Vue 3 推荐规则 ...pluginVue.configs['flat/recommended'], // Prettier 配置(禁用与 Prettier 冲突的规则) prettierConfig, // 全局配置 { files: ['**/*.{js,ts,vue}'], languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { // 浏览器环境 window: 'readonly', document: 'readonly', navigator: 'readonly', console: 'readonly', localStorage: 'readonly', sessionStorage: 'readonly', setTimeout: 'readonly', setInterval: 'readonly', clearTimeout: 'readonly', clearInterval: 'readonly', confirm: 'readonly', alert: 'readonly', prompt: 'readonly', fetch: 'readonly', URL: 'readonly', URLSearchParams: 'readonly', AbortController: 'readonly', AbortSignal: 'readonly', RequestInit: 'readonly', Response: 'readonly', Request: 'readonly', Headers: 'readonly', FormData: 'readonly', Blob: 'readonly', File: 'readonly', Event: 'readonly', KeyboardEvent: 'readonly', MouseEvent: 'readonly', // Node.js 环境 process: 'readonly', __dirname: 'readonly', __filename: 'readonly', module: 'readonly', require: 'readonly' } } }, // TypeScript 文件配置 { files: ['**/*.ts'], languageOptions: { parser: tsParser, parserOptions: { ecmaVersion: 'latest', sourceType: 'module' } }, plugins: { '@typescript-eslint': tsPlugin }, rules: { ...tsPlugin.configs.recommended.rules, // TypeScript 特定规则 '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-unused-vars': [ 'warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' } ] } }, // Vue 文件配置 { files: ['**/*.vue'], languageOptions: { parser: vueParser, parserOptions: { ecmaVersion: 'latest', sourceType: 'module', parser: tsParser, extraFileExtensions: ['.vue'] } }, rules: { // Vue 3 特定规则 'vue/multi-word-component-names': 'off', // 允许单词组件名 'vue/no-v-html': 'warn', // 警告使用 v-html 'vue/require-default-prop': 'off', // 不强制要求 prop 默认值 'vue/require-explicit-emits': 'warn', // 建议显式声明 emits // 代码风格(与 Prettier 不冲突的规则) 'vue/html-self-closing': [ 'error', { html: { void: 'always', normal: 'never', component: 'always' }, svg: 'always', math: 'always' } ], 'vue/max-attributes-per-line': 'off', // 由 Prettier 处理 'vue/singleline-html-element-content-newline': 'off', // 由 Prettier 处理 'vue/html-indent': 'off' // 由 Prettier 处理 } }, // 通用规则 { rules: { // 代码质量 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 'no-unused-vars': [ 'warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' } ], 'no-undef': 'error', 'prefer-const': 'warn', 'no-var': 'error', // 代码风格(不与 Prettier 冲突) 'no-multiple-empty-lines': [ 'error', { max: 1, maxEOF: 0 } ], 'eol-last': ['error', 'always'] } }, // 忽略文件 { ignores: [ 'dist/**', 'node_modules/**', '*.config.js', '*.config.ts', 'coverage/**', '.vite/**', '.nuxt/**', '.output/**' ] } ] ================================================ FILE: frontend/index.html ================================================ Claude API Proxy 管理面板
================================================ FILE: frontend/package.json ================================================ { "name": "claude-proxy-frontend", "version": "1.1.1", "license": "MIT", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", "type-check": "vue-tsc --noEmit", "lint": "eslint . --ext .vue,.js,.ts", "lint:fix": "eslint . --ext .vue,.js,.ts --fix", "format": "prettier --write \"src/**/*.{js,ts,vue,json,css,scss,md}\"" }, "dependencies": { "@mdi/js": "^7.4.47", "apexcharts": "^5.3.6", "pinia": "^3.0.4", "pinia-plugin-persistedstate": "^4.7.1", "vue": "^3.5.26", "vue-router": "4", "vue3-apexcharts": "^1.10.0", "vuedraggable": "^4.1.0", "vuetify": "^3.11.6" }, "devDependencies": { "@eslint/js": "^9.39.2", "@typescript-eslint/eslint-plugin": "^8.53.0", "@typescript-eslint/parser": "^8.53.0", "@vitejs/plugin-vue": "^6.0.3", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.5", "eslint-plugin-vue": "^10.7.0", "sass-embedded": "^1.97.2", "typescript": "^5.9.3", "vite": "^7.3.1", "vite-plugin-vuetify": "^2.1.2", "vitest": "^4.0.17", "vue-eslint-parser": "^10.2.0", "vue-tsc": "^3.2.2" } } ================================================ FILE: frontend/src/App.vue ================================================ ================================================ FILE: frontend/src/assets/style.css ================================================ /* 全局基础样式 */ html { font-family: 'Courier New', Consolas, 'Liberation Mono', monospace; } /* 过渡动画 */ .fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; } .fade-enter-from, .fade-leave-to { opacity: 0; } ================================================ FILE: frontend/src/components/AddChannelModal.vue ================================================ ================================================ FILE: frontend/src/components/ChannelCard.vue ================================================ ================================================ FILE: frontend/src/components/ChannelMetricsChart.vue ================================================ ================================================ FILE: frontend/src/components/ChannelOrchestration.vue ================================================ ================================================ FILE: frontend/src/components/ChannelStatusBadge.vue ================================================ ================================================ FILE: frontend/src/components/GlobalStatsChart.vue ================================================ ================================================ FILE: frontend/src/components/KeyTrendChart.vue ================================================ ================================================ FILE: frontend/src/composables/useTheme.ts ================================================ import { useTheme as useVuetifyTheme } from 'vuetify' // 复古像素主题配置 export const RETRO_THEME = { name: '复古像素', font: '"Courier New", Consolas, "Liberation Mono", monospace' } export function useAppTheme() { const _vuetifyTheme = useVuetifyTheme() // 应用复古像素主题 function applyRetroTheme() { document.documentElement.style.setProperty('--app-font', RETRO_THEME.font) } // 初始化 function init() { applyRetroTheme() } return { init } } ================================================ FILE: frontend/src/env.d.ts ================================================ /// // Allow importing .vue files in TS declare module '*.vue' { import type { DefineComponent } from 'vue' // eslint-disable-next-line @typescript-eslint/no-explicit-any const component: DefineComponent, Record, any> export default component } ================================================ FILE: frontend/src/main.ts ================================================ import { createApp } from 'vue' import { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' import vuetify from './plugins/vuetify' import router from './router' import App from './App.vue' import './assets/style.css' import { useAuthStore } from './stores/auth' const app = createApp(App) const pinia = createPinia() pinia.use(piniaPluginPersistedstate) app.use(pinia) app.use(vuetify) app.use(router) // 初始化 AuthStore(从 localStorage 恢复状态) const authStore = useAuthStore() authStore.initializeAuth() app.mount('#app') ================================================ FILE: frontend/src/plugins/vuetify.ts ================================================ import { createVuetify } from 'vuetify' import { h } from 'vue' import type { IconSet, IconProps, ThemeDefinition } from 'vuetify' import * as components from 'vuetify/components' import * as directives from 'vuetify/directives' // 引入样式 import 'vuetify/styles' // 从 @mdi/js 按需导入使用的图标 (SVG) // 📝 维护说明: 新增图标时需要: // 1. 从 @mdi/js 添加导入 (驼峰命名,如 mdiNewIcon) // 2. 在 iconMap 中添加映射 (如 'new-icon': mdiNewIcon) // 图标查找: https://pictogrammers.com/library/mdi/ import { mdiSwapVerticalBold, mdiPlayCircle, mdiDragVertical, mdiOpenInNew, mdiKey, mdiRefresh, mdiDotsVertical, mdiPencil, mdiSpeedometer, mdiSpeedometerSlow, mdiRocketLaunch, mdiPauseCircle, mdiStopCircle, mdiDelete, mdiPlaylistRemove, mdiArchiveOutline, mdiPlus, mdiCheckCircle, mdiAlertCircle, mdiHelpCircle, mdiCloseCircle, mdiTag, mdiInformation, mdiCog, mdiWeb, mdiShieldAlert, mdiText, mdiSwapHorizontal, mdiArrowRight, mdiClose, mdiArrowUpBold, mdiArrowDownBold, mdiCheck, mdiContentCopy, mdiAlert, mdiWeatherNight, mdiWhiteBalanceSunny, mdiLogout, mdiServerNetwork, mdiHeartPulse, mdiChevronDown, mdiChevronUp, mdiChevronLeft, mdiChevronRight, mdiTune, mdiRotateRight, mdiDice6, mdiBackupRestore, mdiKeyPlus, mdiPin, mdiPinOutline, mdiKeyChain, mdiRobot, mdiRobotOutline, mdiMessageProcessing, mdiDiamondStone, mdiApi, mdiLightningBolt, mdiFormTextbox, mdiMenuDown, mdiMenuUp, mdiCheckboxMarked, mdiCheckboxBlankOutline, mdiMinusBox, mdiCircle, mdiRadioboxMarked, mdiRadioboxBlank, mdiStar, mdiStarOutline, mdiStarHalf, mdiPageFirst, mdiPageLast, mdiUnfoldMoreHorizontal, mdiLoading, mdiClockOutline, mdiCalendar, mdiPaperclip, mdiEyedropper, mdiShieldRefresh, mdiShieldOffOutline, mdiAlertCircleOutline, mdiChartTimelineVariant, mdiChartAreaspline, mdiChartLine, mdiCodeBraces, mdiDatabase, mdiSignature, mdiArrowCollapseUp, mdiArrowCollapseDown, } from '@mdi/js' // 图标名称到 SVG path 的映射 (使用 kebab-case) const iconMap: Record = { // Vuetify 内部使用的图标别名 'complete': mdiCheck, 'cancel': mdiCloseCircle, 'close': mdiClose, 'delete': mdiDelete, 'clear': mdiClose, 'success': mdiCheckCircle, 'info': mdiInformation, 'warning': mdiAlert, 'error': mdiAlertCircle, 'prev': mdiChevronLeft, 'next': mdiChevronRight, 'checkboxOn': mdiCheckboxMarked, 'checkboxOff': mdiCheckboxBlankOutline, 'checkboxIndeterminate': mdiMinusBox, 'delimiter': mdiCircle, 'sortAsc': mdiArrowUpBold, 'sortDesc': mdiArrowDownBold, 'expand': mdiChevronDown, 'menu': mdiMenuDown, 'subgroup': mdiMenuDown, 'dropdown': mdiMenuDown, 'radioOn': mdiRadioboxMarked, 'radioOff': mdiRadioboxBlank, 'edit': mdiPencil, 'ratingEmpty': mdiStarOutline, 'ratingFull': mdiStar, 'ratingHalf': mdiStarHalf, 'loading': mdiLoading, 'first': mdiPageFirst, 'last': mdiPageLast, 'unfold': mdiUnfoldMoreHorizontal, 'file': mdiPaperclip, 'plus': mdiPlus, 'minus': mdiMinusBox, 'calendar': mdiCalendar, 'treeviewCollapse': mdiMenuDown, 'treeviewExpand': mdiMenuUp, 'eyeDropper': mdiEyedropper, // 布局与导航 'swap-vertical-bold': mdiSwapVerticalBold, 'drag-vertical': mdiDragVertical, 'open-in-new': mdiOpenInNew, 'chevron-down': mdiChevronDown, 'chevron-up': mdiChevronUp, 'chevron-left': mdiChevronLeft, 'chevron-right': mdiChevronRight, 'dots-vertical': mdiDotsVertical, 'logout': mdiLogout, 'archive-outline': mdiArchiveOutline, 'menu-down': mdiMenuDown, 'menu-up': mdiMenuUp, // 操作按钮 'pencil': mdiPencil, 'refresh': mdiRefresh, 'check': mdiCheck, 'content-copy': mdiContentCopy, 'arrow-up-bold': mdiArrowUpBold, 'arrow-down-bold': mdiArrowDownBold, 'arrow-right': mdiArrowRight, 'swap-horizontal': mdiSwapHorizontal, 'rotate-right': mdiRotateRight, 'backup-restore': mdiBackupRestore, // 状态图标 'play-circle': mdiPlayCircle, 'pause-circle': mdiPauseCircle, 'stop-circle': mdiStopCircle, 'check-circle': mdiCheckCircle, 'alert-circle': mdiAlertCircle, 'alert-circle-outline': mdiAlertCircleOutline, 'close-circle': mdiCloseCircle, 'help-circle': mdiHelpCircle, 'alert': mdiAlert, // 防护盾牌图标 'shield-refresh': mdiShieldRefresh, 'shield-off-outline': mdiShieldOffOutline, // 功能图标 'key': mdiKey, 'key-plus': mdiKeyPlus, 'key-chain': mdiKeyChain, 'speedometer': mdiSpeedometer, 'speedometer-slow': mdiSpeedometerSlow, 'rocket-launch': mdiRocketLaunch, 'playlist-remove': mdiPlaylistRemove, 'tag': mdiTag, 'information': mdiInformation, 'cog': mdiCog, 'web': mdiWeb, 'shield-alert': mdiShieldAlert, 'text': mdiText, 'tune': mdiTune, 'dice-6': mdiDice6, 'heart-pulse': mdiHeartPulse, 'server-network': mdiServerNetwork, 'pin': mdiPin, 'pin-outline': mdiPinOutline, 'lightning-bolt': mdiLightningBolt, 'form-textbox': mdiFormTextbox, 'clock-outline': mdiClockOutline, 'paperclip': mdiPaperclip, 'eye-dropper': mdiEyedropper, // 主题切换 'weather-night': mdiWeatherNight, 'white-balance-sunny': mdiWhiteBalanceSunny, // 服务类型图标 'robot': mdiRobot, 'robot-outline': mdiRobotOutline, 'message-processing': mdiMessageProcessing, 'diamond-stone': mdiDiamondStone, 'api': mdiApi, // 复选框和单选框 'checkbox-marked': mdiCheckboxMarked, 'checkbox-blank-outline': mdiCheckboxBlankOutline, 'minus-box': mdiMinusBox, 'radiobox-marked': mdiRadioboxMarked, 'radiobox-blank': mdiRadioboxBlank, // 评分 'star': mdiStar, 'star-outline': mdiStarOutline, 'star-half': mdiStarHalf, // 分页 'page-first': mdiPageFirst, 'page-last': mdiPageLast, // 其他 'unfold-more-horizontal': mdiUnfoldMoreHorizontal, 'circle': mdiCircle, // 图表与数据 'chart-timeline-variant': mdiChartTimelineVariant, 'chart-areaspline': mdiChartAreaspline, 'chart-line': mdiChartLine, 'code-braces': mdiCodeBraces, 'database': mdiDatabase, // 签名图标 'signature': mdiSignature, // 置顶/置底操作 'arrow-collapse-up': mdiArrowCollapseUp, 'arrow-collapse-down': mdiArrowCollapseDown, } // 自定义 SVG iconset - 处理 mdi-xxx 字符串格式 const customSvgIconSet: IconSet = { component: (props: IconProps) => { // 获取图标名称,去掉 mdi- 前缀 let iconName = props.icon as string if (iconName.startsWith('mdi-')) { iconName = iconName.substring(4) } // 查找对应的 SVG path const svgPath = iconMap[iconName] if (!svgPath) { if (import.meta.env.DEV) { console.warn(`[Vuetify Icon] 未找到图标: ${iconName},请在 vuetify.ts 的 iconMap 中添加映射`) } return h('span', `[${iconName}]`) } return h('svg', { class: 'v-icon__svg', xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', role: 'img', 'aria-hidden': 'true', style: { fontSize: 'inherit', width: '1em', height: '1em', }, }, [ h('path', { d: svgPath, fill: 'currentColor', }) ]) } } // 🎨 精心设计的现代化配色方案 // Light Theme - 清新专业,柔和渐变 const lightTheme: ThemeDefinition = { dark: false, colors: { // 主色调 - 现代蓝紫渐变感 primary: '#6366F1', // Indigo - 沉稳专业 secondary: '#8B5CF6', // Violet - 辅助强调 accent: '#EC4899', // Pink - 活力点缀 // 语义色彩 - 清晰易辨 info: '#3B82F6', // Blue success: '#10B981', // Emerald warning: '#F59E0B', // Amber error: '#EF4444', // Red // 表面色 - 柔和分层 background: '#F8FAFC', // Slate-50 surface: '#FFFFFF', // Pure white cards 'surface-variant': '#F1F5F9', // Slate-100 for secondary surfaces 'on-surface': '#1E293B', // Slate-800 'on-background': '#334155' // Slate-700 } } // Dark Theme - 深邃优雅,护眼舒适 const darkTheme: ThemeDefinition = { dark: true, colors: { // 主色调 - 亮度适中,不刺眼 primary: '#818CF8', // Indigo-400 secondary: '#A78BFA', // Violet-400 accent: '#F472B6', // Pink-400 // 语义色彩 - 暗色适配 info: '#60A5FA', // Blue-400 success: '#34D399', // Emerald-400 warning: '#FBBF24', // Amber-400 error: '#F87171', // Red-400 // 表面色 - 深色层次分明 background: '#0F172A', // Slate-900 surface: '#1E293B', // Slate-800 'surface-variant': '#334155', // Slate-700 'on-surface': '#F1F5F9', // Slate-100 'on-background': '#E2E8F0' // Slate-200 } } export default createVuetify({ components, directives, icons: { defaultSet: 'mdi', sets: { mdi: customSvgIconSet } }, theme: { defaultTheme: 'light', themes: { light: lightTheme, dark: darkTheme } } }) ================================================ FILE: frontend/src/router/index.ts ================================================ import { createRouter, createWebHistory } from 'vue-router' import { useAuthStore } from '@/stores/auth' const routes = [ { path: '/', redirect: '/channels/messages' // 默认跳转到 Messages }, { path: '/channels/:type', // 动态参数匹配 messages/responses/gemini component: () => import('@/views/ChannelsView.vue'), // 懒加载 props: true, // 将路由参数作为 props 传递 meta: { requiresAuth: true } } ] const router = createRouter({ history: createWebHistory(), // 使用 HTML5 History 模式 routes }) // 认证守卫(可选,认证逻辑已在 App.vue 中处理) router.beforeEach((to, from, next) => { const authStore = useAuthStore() if (to.meta.requiresAuth && !authStore.isAuthenticated) { // 认证对话框已在 App.vue 中处理,无需重定向 next() } else { next() } }) export default router ================================================ FILE: frontend/src/services/api.ts ================================================ // API服务模块 import { useAuthStore } from '@/stores/auth' export class ApiError extends Error { readonly status: number readonly details?: unknown constructor(message: string, status: number, details?: unknown) { super(message) this.name = 'ApiError' this.status = status this.details = details } } // 从环境变量读取配置 const getApiBase = () => { // 在生产环境中,API调用会直接请求当前域名 if (import.meta.env.PROD) { return '/api' } // 在开发环境中,支持从环境变量配置后端地址 const backendUrl = import.meta.env.VITE_BACKEND_URL const apiBasePath = import.meta.env.VITE_API_BASE_PATH || '/api' if (backendUrl) { return `${backendUrl}${apiBasePath}` } // fallback到默认配置 return '/api' } const API_BASE = getApiBase() // 打印当前API配置(仅开发环境) if (import.meta.env.DEV) { console.log('🔗 API Configuration:', { API_BASE, BACKEND_URL: import.meta.env.VITE_BACKEND_URL, IS_DEV: import.meta.env.DEV, IS_PROD: import.meta.env.PROD }) } // 渠道状态枚举 export type ChannelStatus = 'active' | 'suspended' | 'disabled' // 渠道指标 // 分时段统计 export interface TimeWindowStats { requestCount: number successCount: number failureCount: number successRate: number inputTokens?: number outputTokens?: number cacheCreationTokens?: number cacheReadTokens?: number cacheHitRate?: number } export interface ChannelMetrics { channelIndex: number requestCount: number successCount: number failureCount: number successRate: number // 0-100 errorRate: number // 0-100 consecutiveFailures: number latency: number // ms lastSuccessAt?: string lastFailureAt?: string // 分时段统计 (15m, 1h, 6h, 24h) timeWindows?: { '15m': TimeWindowStats '1h': TimeWindowStats '6h': TimeWindowStats '24h': TimeWindowStats } } export interface Channel { name: string serviceType: 'openai' | 'gemini' | 'claude' | 'responses' baseUrl: string baseUrls?: string[] // 多 BaseURL 支持(failover 模式) apiKeys: string[] description?: string website?: string insecureSkipVerify?: boolean modelMapping?: Record latency?: number status?: ChannelStatus | 'healthy' | 'error' | 'unknown' | '' index: number pinned?: boolean // 多渠道调度相关字段 priority?: number // 渠道优先级(数字越小优先级越高) metrics?: ChannelMetrics // 实时指标 suspendReason?: string // 熔断原因 promotionUntil?: string // 促销期截止时间(ISO 格式) latencyTestTime?: number // 延迟测试时间戳(用于 5 分钟后自动清除显示) lowQuality?: boolean // 低质量渠道标记:启用后强制本地估算 token,偏差>5%时使用本地值 injectDummyThoughtSignature?: boolean // Gemini 特定:为 functionCall 注入 dummy thought_signature(兼容第三方 API) stripThoughtSignature?: boolean // Gemini 特定:移除 thought_signature 字段(兼容旧版 Gemini API) } export interface ChannelsResponse { channels: Channel[] current: number loadBalance: string } // 渠道仪表盘响应(合并 channels + metrics + stats) export interface ChannelDashboardResponse { channels: Channel[] loadBalance: string metrics: ChannelMetrics[] stats: { multiChannelMode: boolean activeChannelCount: number traceAffinityCount: number traceAffinityTTL: string failureThreshold: number windowSize: number circuitRecoveryTime: string } recentActivity?: ChannelRecentActivity[] // 最近 15 分钟分段活跃度 } export interface PingResult { success: boolean latency: number status: string error?: string } // 历史数据点(用于时间序列图表) export interface HistoryDataPoint { timestamp: string requestCount: number successCount: number failureCount: number successRate: number } // 渠道历史指标响应 export interface MetricsHistoryResponse { channelIndex: number channelName: string dataPoints: HistoryDataPoint[] } // Key 级别历史数据点(包含 Token 数据) export interface KeyHistoryDataPoint { timestamp: string requestCount: number successCount: number failureCount: number successRate: number inputTokens: number outputTokens: number cacheCreationTokens: number cacheReadTokens: number } // 单个 Key 的历史数据 export interface KeyHistoryData { keyMask: string color: string dataPoints: KeyHistoryDataPoint[] } // 渠道 Key 级别历史指标响应 export interface ChannelKeyMetricsHistoryResponse { channelIndex: number channelName: string keys: KeyHistoryData[] } // ============== 全局统计类型 ============== // 全局历史数据点(包含 Token 数据) export interface GlobalHistoryDataPoint { timestamp: string requestCount: number successCount: number failureCount: number successRate: number inputTokens: number outputTokens: number cacheCreationTokens: number cacheReadTokens: number } // 全局统计汇总 export interface GlobalStatsSummary { totalRequests: number totalSuccess: number totalFailure: number totalInputTokens: number totalOutputTokens: number totalCacheCreationTokens: number totalCacheReadTokens: number avgSuccessRate: number duration: string } // 全局统计响应 export interface GlobalStatsHistoryResponse { dataPoints: GlobalHistoryDataPoint[] summary: GlobalStatsSummary } // ============== 渠道实时活跃度类型 ============== // 活跃度分段数据(每 6 秒一段) export interface ActivitySegment { requestCount: number successCount: number failureCount: number inputTokens: number outputTokens: number } // 渠道最近活跃度数据 export interface ChannelRecentActivity { channelIndex: number segments: ActivitySegment[] // 150 段,每段 6 秒,从旧到新(共 15 分钟) rpm: number // 15分钟平均 RPM tpm: number // 15分钟平均 TPM } // ============== 上游模型列表类型 ============== export interface ModelEntry { id: string object: string created: number owned_by: string } export interface ModelsResponse { object: string data: ModelEntry[] } /** * 构建上游的 /v1/models 端点 URL * 参考:backend-go/internal/handlers/messages/models.go:240-257 */ function buildModelsURL(baseURL: string): string { // 处理 # 后缀(跳过版本前缀) const skipVersionPrefix = baseURL.endsWith('#') if (skipVersionPrefix) { baseURL = baseURL.slice(0, -1) } baseURL = baseURL.replace(/\/$/, '') // 检查是否已有版本后缀(如 /v1, /v2) const versionPattern = /\/v\d+[a-z]*$/ const hasVersionSuffix = versionPattern.test(baseURL) // 构建端点 let endpoint = '/models' if (!hasVersionSuffix && !skipVersionPrefix) { endpoint = '/v1' + endpoint } return baseURL + endpoint } /** * 直接从上游获取模型列表(前端直连) */ export async function fetchUpstreamModels( baseUrl: string, apiKey: string ): Promise { const url = buildModelsURL(baseUrl) const response = await fetch(url, { method: 'GET', headers: { 'Authorization': `Bearer ${apiKey}` }, signal: AbortSignal.timeout(10000) // 10秒超时 }) if (!response.ok) { let errorMessage = `${response.status} ${response.statusText}` let errorDetails: unknown = null try { const errorText = await response.text() if (errorText) { const errorJson = JSON.parse(errorText) // 解析上游错误格式: { "error": { "code": "", "message": "...", "type": "..." } } if (errorJson.error && errorJson.error.message) { errorMessage = errorJson.error.message errorDetails = errorJson.error } else if (errorJson.message) { errorMessage = errorJson.message errorDetails = errorJson } } } catch { // 解析失败,使用默认错误消息 } throw new ApiError(errorMessage, response.status, errorDetails) } return await response.json() } class ApiService { // 获取当前 API Key(从 AuthStore) private getApiKey(): string | null { const authStore = useAuthStore() return authStore.apiKey } private async parseResponseBody(response: Response): Promise { const text = await response.text() if (!text) return null try { return JSON.parse(text) } catch { return text } } // eslint-disable-next-line @typescript-eslint/no-explicit-any private async request(url: string, options: RequestInit = {}): Promise { const headers: Record = { 'Content-Type': 'application/json', ...(options.headers as Record) } // 从 AuthStore 获取 API 密钥并添加到请求头 const apiKey = this.getApiKey() if (apiKey) { headers['x-api-key'] = apiKey } const response = await fetch(`${API_BASE}${url}`, { ...options, headers }) if (!response.ok) { const errorBody = await this.parseResponseBody(response) const errorMessage = (typeof errorBody === 'object' && errorBody && 'error' in errorBody && typeof (errorBody as { error?: unknown }).error === 'string' ? (errorBody as { error: string }).error : typeof errorBody === 'object' && errorBody && 'message' in errorBody && typeof (errorBody as { message?: unknown }).message === 'string' ? (errorBody as { message: string }).message : typeof errorBody === 'string' ? errorBody : null) || `Request failed (${response.status})` // 如果是401错误,清除认证信息并提示用户重新登录 if (response.status === 401) { const authStore = useAuthStore() authStore.clearAuth() // 记录认证失败(前端日志) if (import.meta.env.DEV) { console.warn('🔒 认证失败 - 时间:', new Date().toISOString()) } throw new ApiError('认证失败,请重新输入访问密钥', response.status, errorBody) } throw new ApiError(errorMessage, response.status, errorBody) } if (response.status === 204) return null return this.parseResponseBody(response) } async getChannels(): Promise { return this.request('/messages/channels') } async addChannel(channel: Omit): Promise { await this.request('/messages/channels', { method: 'POST', body: JSON.stringify(channel) }) } async updateChannel(id: number, channel: Partial): Promise { await this.request(`/messages/channels/${id}`, { method: 'PUT', body: JSON.stringify(channel) }) } async deleteChannel(id: number): Promise { await this.request(`/messages/channels/${id}`, { method: 'DELETE' }) } async addApiKey(channelId: number, apiKey: string): Promise { await this.request(`/messages/channels/${channelId}/keys`, { method: 'POST', body: JSON.stringify({ apiKey }) }) } async removeApiKey(channelId: number, apiKey: string): Promise { await this.request(`/messages/channels/${channelId}/keys/${encodeURIComponent(apiKey)}`, { method: 'DELETE' }) } async pingChannel(id: number): Promise { return this.request(`/messages/ping/${id}`) } async pingAllChannels(): Promise> { return this.request('/messages/ping') } async updateLoadBalance(strategy: string): Promise { await this.request('/loadbalance', { method: 'PUT', body: JSON.stringify({ strategy }) }) } async updateResponsesLoadBalance(strategy: string): Promise { await this.request('/responses/loadbalance', { method: 'PUT', body: JSON.stringify({ strategy }) }) } // ============== Responses 渠道管理 API ============== async getResponsesChannels(): Promise { return this.request('/responses/channels') } async addResponsesChannel(channel: Omit): Promise { await this.request('/responses/channels', { method: 'POST', body: JSON.stringify(channel) }) } async updateResponsesChannel(id: number, channel: Partial): Promise { await this.request(`/responses/channels/${id}`, { method: 'PUT', body: JSON.stringify(channel) }) } async deleteResponsesChannel(id: number): Promise { await this.request(`/responses/channels/${id}`, { method: 'DELETE' }) } async addResponsesApiKey(channelId: number, apiKey: string): Promise { await this.request(`/responses/channels/${channelId}/keys`, { method: 'POST', body: JSON.stringify({ apiKey }) }) } async removeResponsesApiKey(channelId: number, apiKey: string): Promise { await this.request(`/responses/channels/${channelId}/keys/${encodeURIComponent(apiKey)}`, { method: 'DELETE' }) } async moveApiKeyToTop(channelId: number, apiKey: string): Promise { await this.request(`/messages/channels/${channelId}/keys/${encodeURIComponent(apiKey)}/top`, { method: 'POST' }) } async moveApiKeyToBottom(channelId: number, apiKey: string): Promise { await this.request(`/messages/channels/${channelId}/keys/${encodeURIComponent(apiKey)}/bottom`, { method: 'POST' }) } async moveResponsesApiKeyToTop(channelId: number, apiKey: string): Promise { await this.request(`/responses/channels/${channelId}/keys/${encodeURIComponent(apiKey)}/top`, { method: 'POST' }) } async moveResponsesApiKeyToBottom(channelId: number, apiKey: string): Promise { await this.request(`/responses/channels/${channelId}/keys/${encodeURIComponent(apiKey)}/bottom`, { method: 'POST' }) } // ============== 多渠道调度 API ============== // 重新排序渠道优先级 async reorderChannels(order: number[]): Promise { await this.request('/messages/channels/reorder', { method: 'POST', body: JSON.stringify({ order }) }) } // 设置渠道状态 async setChannelStatus(channelId: number, status: ChannelStatus): Promise { await this.request(`/messages/channels/${channelId}/status`, { method: 'PATCH', body: JSON.stringify({ status }) }) } // 恢复熔断渠道(重置错误计数) async resumeChannel(channelId: number): Promise { await this.request(`/messages/channels/${channelId}/resume`, { method: 'POST' }) } // 获取渠道指标 async getChannelMetrics(): Promise { return this.request('/messages/channels/metrics') } // 获取调度器统计信息 async getSchedulerStats(type?: 'messages' | 'responses' | 'gemini'): Promise<{ multiChannelMode: boolean activeChannelCount: number traceAffinityCount: number traceAffinityTTL: string failureThreshold: number windowSize: number }> { // Gemini 暂无调度器统计,返回默认值 if (type === 'gemini') { return { multiChannelMode: false, activeChannelCount: 0, traceAffinityCount: 0, traceAffinityTTL: '0s', failureThreshold: 0, windowSize: 0 } } const query = type === 'responses' ? '?type=responses' : '' return this.request(`/messages/channels/scheduler/stats${query}`) } // 获取渠道仪表盘数据(合并 channels + metrics + stats) async getChannelDashboard(type: 'messages' | 'responses' | 'gemini' = 'messages'): Promise { // Gemini 使用降级实现:组合 getChannels + getMetrics if (type === 'gemini') { return this.getGeminiChannelDashboard() } const query = type === 'responses' ? '?type=responses' : '' return this.request(`/messages/channels/dashboard${query}`) } // ============== Responses 多渠道调度 API ============== // 重新排序 Responses 渠道优先级 async reorderResponsesChannels(order: number[]): Promise { await this.request('/responses/channels/reorder', { method: 'POST', body: JSON.stringify({ order }) }) } // 设置 Responses 渠道状态 async setResponsesChannelStatus(channelId: number, status: ChannelStatus): Promise { await this.request(`/responses/channels/${channelId}/status`, { method: 'PATCH', body: JSON.stringify({ status }) }) } // 恢复 Responses 熔断渠道 async resumeResponsesChannel(channelId: number): Promise { await this.request(`/responses/channels/${channelId}/resume`, { method: 'POST' }) } // 获取 Responses 渠道指标 async getResponsesChannelMetrics(): Promise { return this.request('/responses/channels/metrics') } // ============== 促销期管理 API ============== // 设置 Messages 渠道促销期 async setChannelPromotion(channelId: number, durationSeconds: number): Promise { await this.request(`/messages/channels/${channelId}/promotion`, { method: 'POST', body: JSON.stringify({ duration: durationSeconds }) }) } // 设置 Responses 渠道促销期 async setResponsesChannelPromotion(channelId: number, durationSeconds: number): Promise { await this.request(`/responses/channels/${channelId}/promotion`, { method: 'POST', body: JSON.stringify({ duration: durationSeconds }) }) } // ============== Fuzzy 模式 API ============== // 获取 Fuzzy 模式状态 async getFuzzyMode(): Promise<{ fuzzyModeEnabled: boolean }> { return this.request('/settings/fuzzy-mode') } // 设置 Fuzzy 模式状态 async setFuzzyMode(enabled: boolean): Promise { await this.request('/settings/fuzzy-mode', { method: 'PUT', body: JSON.stringify({ enabled }) }) } // ============== 历史指标 API ============== // 获取 Messages 渠道历史指标(用于时间序列图表) async getChannelMetricsHistory(duration: '1h' | '6h' | '24h' = '24h'): Promise { return this.request(`/messages/channels/metrics/history?duration=${duration}`) } // 获取 Responses 渠道历史指标 async getResponsesChannelMetricsHistory(duration: '1h' | '6h' | '24h' = '24h'): Promise { return this.request(`/responses/channels/metrics/history?duration=${duration}`) } // ============== Key 级别历史指标 API ============== // 获取 Messages 渠道 Key 级别历史指标(用于 Key 趋势图表) async getChannelKeyMetricsHistory(channelId: number, duration: '1h' | '6h' | '24h' | 'today' = '6h'): Promise { return this.request(`/messages/channels/${channelId}/keys/metrics/history?duration=${duration}`) } // 获取 Responses 渠道 Key 级别历史指标 async getResponsesChannelKeyMetricsHistory(channelId: number, duration: '1h' | '6h' | '24h' | 'today' = '6h'): Promise { return this.request(`/responses/channels/${channelId}/keys/metrics/history?duration=${duration}`) } // ============== 全局统计 API ============== // 获取 Messages 全局统计历史 async getMessagesGlobalStats(duration: '1h' | '6h' | '24h' | 'today' = '24h'): Promise { return this.request(`/messages/global/stats/history?duration=${duration}`) } // 获取 Responses 全局统计历史 async getResponsesGlobalStats(duration: '1h' | '6h' | '24h' | 'today' = '24h'): Promise { return this.request(`/responses/global/stats/history?duration=${duration}`) } // ============== Gemini 渠道管理 API ============== async getGeminiChannels(): Promise { return this.request('/gemini/channels') } async addGeminiChannel(channel: Omit): Promise { await this.request('/gemini/channels', { method: 'POST', body: JSON.stringify(channel) }) } async updateGeminiChannel(id: number, channel: Partial): Promise { await this.request(`/gemini/channels/${id}`, { method: 'PUT', body: JSON.stringify(channel) }) } async deleteGeminiChannel(id: number): Promise { await this.request(`/gemini/channels/${id}`, { method: 'DELETE' }) } async addGeminiApiKey(channelId: number, apiKey: string): Promise { await this.request(`/gemini/channels/${channelId}/keys`, { method: 'POST', body: JSON.stringify({ apiKey }) }) } async removeGeminiApiKey(channelId: number, apiKey: string): Promise { await this.request(`/gemini/channels/${channelId}/keys/${encodeURIComponent(apiKey)}`, { method: 'DELETE' }) } async moveGeminiApiKeyToTop(channelId: number, apiKey: string): Promise { await this.request(`/gemini/channels/${channelId}/keys/${encodeURIComponent(apiKey)}/top`, { method: 'POST' }) } async moveGeminiApiKeyToBottom(channelId: number, apiKey: string): Promise { await this.request(`/gemini/channels/${channelId}/keys/${encodeURIComponent(apiKey)}/bottom`, { method: 'POST' }) } // ============== Gemini 多渠道调度 API ============== async reorderGeminiChannels(order: number[]): Promise { await this.request('/gemini/channels/reorder', { method: 'POST', body: JSON.stringify({ order }) }) } async setGeminiChannelStatus(channelId: number, status: ChannelStatus): Promise { await this.request(`/gemini/channels/${channelId}/status`, { method: 'PATCH', body: JSON.stringify({ status }) }) } // Gemini 恢复渠道(降级实现:后端未实现 resume 端点,直接设置状态为 active) async resumeGeminiChannel(channelId: number): Promise { await this.setGeminiChannelStatus(channelId, 'active') } async getGeminiChannelMetrics(): Promise { return this.request('/gemini/channels/metrics') } async setGeminiChannelPromotion(channelId: number, durationSeconds: number): Promise { await this.request(`/gemini/channels/${channelId}/promotion`, { method: 'POST', body: JSON.stringify({ duration: durationSeconds }) }) } async updateGeminiLoadBalance(strategy: string): Promise { await this.request('/gemini/loadbalance', { method: 'PUT', body: JSON.stringify({ strategy }) }) } // ============== Gemini 历史指标 API ============== // 获取 Gemini 渠道历史指标 async getGeminiChannelMetricsHistory(duration: '1h' | '6h' | '24h' = '24h'): Promise { return this.request(`/gemini/channels/metrics/history?duration=${duration}`) } // 获取 Gemini 渠道 Key 级别历史指标 async getGeminiChannelKeyMetricsHistory(channelId: number, duration: '1h' | '6h' | '24h' | 'today' = '6h'): Promise { return this.request(`/gemini/channels/${channelId}/keys/metrics/history?duration=${duration}`) } // 获取 Gemini 全局统计历史 async getGeminiGlobalStats(duration: '1h' | '6h' | '24h' | 'today' = '24h'): Promise { return this.request(`/gemini/global/stats/history?duration=${duration}`) } async pingGeminiChannel(id: number): Promise { return this.request(`/gemini/ping/${id}`) } async pingAllGeminiChannels(): Promise> { const resp = await this.request('/gemini/ping') // 后端返回 { channels: [...] },需要提取并转换字段名 return (resp.channels || []).map((ch: { index: number; name: string; latency: number; success: boolean }) => ({ id: ch.index, name: ch.name, latency: ch.latency, status: ch.success ? 'healthy' : 'error' })) } // Gemini Dashboard(使用后端统一接口) async getGeminiChannelDashboard(): Promise { return this.request('/gemini/channels/dashboard') } } // 健康检查响应类型 export interface HealthResponse { version?: { version: string buildTime: string gitCommit: string } timestamp: string uptime: number mode: string } /** * 获取健康检查信息(包含版本号) * 注意:/health 端点不需要认证,直接请求根路径 */ export const fetchHealth = async (): Promise => { const baseUrl = import.meta.env.PROD ? '' : (import.meta.env.VITE_BACKEND_URL || '') const response = await fetch(`${baseUrl}/health`) if (!response.ok) { throw new Error(`Health check failed: ${response.status}`) } return response.json() } export const api = new ApiService() export default api ================================================ FILE: frontend/src/services/version.ts ================================================ /** * 版本检查服务 * 参考 gpt-load 项目实现 */ const CACHE_KEY = 'claude-proxy-version-info' const CACHE_DURATION = 30 * 60 * 1000 // 30分钟缓存 const ERROR_CACHE_DURATION = 5 * 60 * 1000 // 错误状态缓存5分钟,避免频繁请求 const GITHUB_API_TIMEOUT = 10000 // 10秒超时 export interface GitHubRelease { tag_name: string html_url: string published_at: string name: string prerelease?: boolean } export interface VersionInfo { currentVersion: string latestVersion: string | null isLatest: boolean hasUpdate: boolean releaseUrl: string | null lastCheckTime: number status: 'checking' | 'latest' | 'update-available' | 'error' } // 预发布版本标识正则(如 -rc1, -beta, -alpha 等) const PRERELEASE_PATTERN = /-(alpha|beta|rc|dev|pre|canary|nightly)/i class VersionService { private currentVersion: string = '' /** * 检查是否为预发布版本 */ private isPrerelease(version: string): boolean { return PRERELEASE_PATTERN.test(version) } /** * 设置当前版本(从 /health 端点获取) */ setCurrentVersion(version: string): void { this.currentVersion = version } /** * 获取当前版本 */ getCurrentVersion(): string { return this.currentVersion } /** * 从缓存获取版本信息 */ private getCachedVersionInfo(): VersionInfo | null { try { const cached = localStorage.getItem(CACHE_KEY) if (!cached) { return null } const versionInfo: VersionInfo = JSON.parse(cached) const now = Date.now() // 根据状态选择不同的缓存时长 const cacheDuration = versionInfo.status === 'error' ? ERROR_CACHE_DURATION : CACHE_DURATION // 检查缓存是否过期 if (now - versionInfo.lastCheckTime > cacheDuration) { return null } // 检查缓存中的版本号是否与当前应用版本号一致 if (versionInfo.currentVersion !== this.currentVersion) { this.clearCache() return null } return versionInfo } catch (error) { console.warn('Failed to parse cached version info:', error) localStorage.removeItem(CACHE_KEY) return null } } /** * 保存版本信息到缓存 */ private setCachedVersionInfo(info: VersionInfo): void { try { localStorage.setItem(CACHE_KEY, JSON.stringify(info)) } catch (error) { console.warn('Failed to cache version info:', error) } } /** * 清除缓存 */ clearCache(): void { localStorage.removeItem(CACHE_KEY) } /** * 版本比较 * @returns -1: current < latest (有更新), 0: 相等, 1: current > latest */ private compareVersions(current: string, latest: string): number { // 移除 'v' 前缀,按 '.' 分割成数组 const currentParts = current.replace(/^v/, '').split('.').map(Number) const latestParts = latest.replace(/^v/, '').split('.').map(Number) // 遍历每一位版本号 for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) { const currentPart = currentParts[i] || 0 const latestPart = latestParts[i] || 0 if (currentPart < latestPart) { return -1 // 当前版本更低 } if (currentPart > latestPart) { return 1 // 当前版本更高 } } return 0 // 版本相同 } /** * 从 GitHub API 获取最新正式版本(过滤预发布版本) */ private async fetchLatestVersion(): Promise { try { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), GITHUB_API_TIMEOUT) // 使用 /releases 端点获取最近的发布列表,然后过滤出第一个正式版本 const response = await fetch( 'https://api.github.com/repos/BenedictKing/claude-proxy/releases?per_page=10', { headers: { Accept: 'application/vnd.github.v3+json', }, signal: controller.signal, } ) clearTimeout(timeoutId) if (response.status === 200) { const releases: GitHubRelease[] = await response.json() // 过滤掉预发布版本,返回第一个正式版本 const stableRelease = releases.find( release => !release.prerelease && !this.isPrerelease(release.tag_name) ) return stableRelease || null } return null } catch (error) { console.warn('Failed to fetch latest version from GitHub:', error) return null } } /** * 检查更新 */ async checkForUpdates(): Promise { // 如果没有当前版本,返回错误状态 if (!this.currentVersion) { return { currentVersion: '', latestVersion: null, isLatest: false, hasUpdate: false, releaseUrl: null, lastCheckTime: Date.now(), status: 'error', } } // 先检查缓存 const cached = this.getCachedVersionInfo() if (cached) { return cached } // 创建初始状态 const versionInfo: VersionInfo = { currentVersion: this.currentVersion, latestVersion: null, isLatest: false, hasUpdate: false, releaseUrl: null, lastCheckTime: Date.now(), status: 'checking', } // 获取最新版本 try { const release = await this.fetchLatestVersion() if (release) { const comparison = this.compareVersions(this.currentVersion, release.tag_name) versionInfo.latestVersion = release.tag_name versionInfo.releaseUrl = release.html_url versionInfo.isLatest = comparison >= 0 versionInfo.hasUpdate = comparison < 0 versionInfo.status = comparison < 0 ? 'update-available' : 'latest' // 成功时缓存结果(30分钟) this.setCachedVersionInfo(versionInfo) } else { versionInfo.status = 'error' // 错误时也缓存(5分钟),避免频繁请求 GitHub API this.setCachedVersionInfo(versionInfo) } } catch (error) { console.warn('Version check failed:', error) versionInfo.status = 'error' // 错误时也缓存(5分钟),避免频繁请求 GitHub API this.setCachedVersionInfo(versionInfo) } return versionInfo } } // 导出单例 export const versionService = new VersionService() ================================================ FILE: frontend/src/stores/auth.ts ================================================ import { defineStore } from 'pinia' import { ref, computed } from 'vue' /** * 认证状态管理 Store * * 职责: * - 管理 API Key 的存储和读取 * - 管理认证错误和安全状态(失败次数、锁定时间等) * - 管理认证 UI 状态(加载中、自动认证等) * - 提供响应式的认证状态 * - 自动持久化到 localStorage */ export const useAuthStore = defineStore('auth', () => { // ===== 状态 ===== // API Key const apiKey = ref(null) // 认证错误消息 const authError = ref('') // 认证失败次数 const authAttempts = ref(0) // 认证锁定时间(存储时间戳) const authLockoutTime = ref(null) // 自动认证进行中 const isAutoAuthenticating = ref(true) // 初始化为true,防止登录框闪现 // 初始化完成标志 const isInitialized = ref(false) // 认证加载状态 const authLoading = ref(false) // 认证输入框值 const authKeyInput = ref('') // ===== 计算属性 ===== const isAuthenticated = computed(() => !!apiKey.value) // 检查是否被锁定 const isAuthLocked = computed(() => { if (!authLockoutTime.value) return false return Date.now() < authLockoutTime.value }) // ===== 操作方法 ===== function setApiKey(key: string | null) { apiKey.value = key // 同时保存到旧的 localStorage key 以保持兼容性 if (key) { localStorage.setItem('proxyAccessKey', key) } else { localStorage.removeItem('proxyAccessKey') } } function clearAuth() { apiKey.value = null // 清除旧的 localStorage key localStorage.removeItem('proxyAccessKey') } function initializeAuth() { // 优先从旧的 localStorage key 读取(兼容性) const oldKey = localStorage.getItem('proxyAccessKey') if (oldKey) { apiKey.value = oldKey return } // 如果没有旧 key,尝试从 Pinia 持久化恢复 // (由 persistedstate 插件自动处理) } function setAuthError(error: string) { authError.value = error } function incrementAuthAttempts() { authAttempts.value++ } function resetAuthAttempts() { authAttempts.value = 0 } function setAuthLockout(lockoutTime: Date | null) { authLockoutTime.value = lockoutTime ? lockoutTime.getTime() : null } function setAutoAuthenticating(value: boolean) { isAutoAuthenticating.value = value } function setInitialized(value: boolean) { isInitialized.value = value } function setAuthLoading(value: boolean) { authLoading.value = value } function setAuthKeyInput(value: string) { authKeyInput.value = value } return { // 状态 apiKey, authError, authAttempts, authLockoutTime, isAutoAuthenticating, isInitialized, authLoading, authKeyInput, // 计算属性 isAuthenticated, isAuthLocked, // 方法 setApiKey, clearAuth, initializeAuth, setAuthError, incrementAuthAttempts, resetAuthAttempts, setAuthLockout, setAutoAuthenticating, setInitialized, setAuthLoading, setAuthKeyInput, } }, { // 持久化配置 persist: { key: 'claude-proxy-auth', storage: localStorage, // 仅持久化必要字段,排除瞬态 UI 状态和敏感输入 pick: ['apiKey', 'authAttempts', 'authLockoutTime'], }, }) ================================================ FILE: frontend/src/stores/channel.ts ================================================ import { defineStore } from 'pinia' import { ref, computed, watch } from 'vue' import { useRouter } from 'vue-router' import { api, type Channel, type ChannelsResponse, type ChannelMetrics, type ChannelDashboardResponse } from '@/services/api' /** * 渠道数据管理 Store * * 职责: * - 管理三种 API 类型的渠道数据(Messages/Responses/Gemini) * - 管理渠道指标和统计数据 * - 提供渠道操作方法(添加、编辑、删除、测试延迟等) * - 管理自动刷新定时器 */ export const useChannelStore = defineStore('channel', () => { // ===== 状态 ===== // 当前选中的 API 类型 type ApiTab = 'messages' | 'responses' | 'gemini' const activeTab = ref('messages') // 路由同步:从路由读取当前类型 const router = useRouter() const currentChannelType = computed(() => { const route = router.currentRoute.value const type = route.params.type as ApiTab return (type === 'messages' || type === 'responses' || type === 'gemini') ? type : 'messages' }) // 监听路由变化,同步 activeTab(确保兼容性) watch(currentChannelType, (newType) => { activeTab.value = newType }, { immediate: true }) // 三种 API 类型的渠道数据 const channelsData = ref({ channels: [], current: -1, loadBalance: 'round-robin' }) const responsesChannelsData = ref({ channels: [], current: -1, loadBalance: 'round-robin' }) const geminiChannelsData = ref({ channels: [], current: -1, loadBalance: 'round-robin' }) // Dashboard 数据缓存结构(每个 tab 独立缓存) interface DashboardCache { metrics: ChannelMetrics[] stats: ChannelDashboardResponse['stats'] | undefined recentActivity: ChannelDashboardResponse['recentActivity'] | undefined } const dashboardCache = ref>({ messages: { metrics: [], stats: undefined, recentActivity: undefined }, responses: { metrics: [], stats: undefined, recentActivity: undefined }, gemini: { metrics: [], stats: undefined, recentActivity: undefined } }) // 批量延迟测试加载状态 const isPingingAll = ref(false) // 最后一次刷新状态(用于 systemStatus 更新) const lastRefreshSuccess = ref(true) // 自动刷新定时器(串行 setTimeout,避免重入) let autoRefreshTimer: ReturnType | null = null let autoRefreshRunning = false const AUTO_REFRESH_INTERVAL = 2000 // 2秒 // 刷新并发控制:同一时间只允许一个 refresh 在跑;期间再次调用会被合并成一次后续刷新 let refreshLoopPromise: Promise | null = null let refreshRequested = false // ===== 计算属性 ===== // 根据当前 Tab 返回对应的渠道数据 const currentChannelsData = computed(() => { switch (activeTab.value) { case 'messages': return channelsData.value case 'responses': return responsesChannelsData.value case 'gemini': return geminiChannelsData.value default: return channelsData.value } }) // 根据当前 Tab 返回对应的 Dashboard 数据(独立缓存,避免切换闪烁) const currentDashboardMetrics = computed(() => dashboardCache.value[activeTab.value].metrics) const currentDashboardStats = computed(() => dashboardCache.value[activeTab.value].stats) const currentDashboardRecentActivity = computed(() => dashboardCache.value[activeTab.value].recentActivity) // 活跃渠道数(仅 active 状态) const activeChannelCount = computed(() => { const data = currentChannelsData.value if (!data.channels) return 0 return data.channels.filter(ch => ch.status === 'active' || ch.status === undefined || ch.status === '').length }) // 参与故障转移的渠道数(active + suspended) const failoverChannelCount = computed(() => { const data = currentChannelsData.value if (!data.channels) return 0 return data.channels.filter(ch => ch.status !== 'disabled').length }) // ===== 辅助方法 ===== // 合并渠道数据,保留本地的延迟测试结果 const LATENCY_VALID_DURATION = 5 * 60 * 1000 // 5 分钟有效期 function mergeChannelsWithLocalData(newChannels: Channel[], existingChannels: Channel[] | undefined): Channel[] { if (!existingChannels) return newChannels const now = Date.now() return newChannels.map(newCh => { const existingCh = existingChannels.find(ch => ch.index === newCh.index) // 只有在 5 分钟有效期内才保留本地延迟测试结果 if (existingCh?.latencyTestTime && (now - existingCh.latencyTestTime) < LATENCY_VALID_DURATION) { return { ...newCh, latency: existingCh.latency, latencyTestTime: existingCh.latencyTestTime } } return newCh }) } // ===== 操作方法 ===== /** * 刷新渠道数据 */ async function refreshChannels() { refreshRequested = true if (refreshLoopPromise) return refreshLoopPromise const doRefresh = async (tab: ApiTab) => { try { // Gemini 使用专用的 dashboard API(降级实现) if (tab === 'gemini') { const dashboard = await api.getGeminiChannelDashboard() geminiChannelsData.value = { channels: mergeChannelsWithLocalData(dashboard.channels, geminiChannelsData.value.channels), current: geminiChannelsData.value.current, loadBalance: dashboard.loadBalance } // 更新 Gemini tab 的独立缓存 dashboardCache.value.gemini = { metrics: dashboard.metrics, stats: dashboard.stats, recentActivity: dashboard.recentActivity } lastRefreshSuccess.value = true return } // Messages / Responses 使用合并的 dashboard 接口 const dashboard = await api.getChannelDashboard(tab) if (tab === 'messages') { channelsData.value = { channels: mergeChannelsWithLocalData(dashboard.channels, channelsData.value.channels), current: channelsData.value.current, // 保留当前选中状态 loadBalance: dashboard.loadBalance } // 更新 Messages tab 的独立缓存 dashboardCache.value.messages = { metrics: dashboard.metrics, stats: dashboard.stats, recentActivity: dashboard.recentActivity } } else { responsesChannelsData.value = { channels: mergeChannelsWithLocalData(dashboard.channels, responsesChannelsData.value.channels), current: responsesChannelsData.value.current, // 保留当前选中状态 loadBalance: dashboard.loadBalance } // 更新 Responses tab 的独立缓存 dashboardCache.value.responses = { metrics: dashboard.metrics, stats: dashboard.stats, recentActivity: dashboard.recentActivity } } lastRefreshSuccess.value = true } catch (error) { lastRefreshSuccess.value = false throw error } } refreshLoopPromise = (async () => { try { while (refreshRequested) { refreshRequested = false const tab = activeTab.value await doRefresh(tab) } } finally { refreshLoopPromise = null } })() return refreshLoopPromise } /** * 保存渠道(添加或更新) */ async function saveChannel( channel: Omit, editingChannelIndex: number | null, options?: { isQuickAdd?: boolean } ) { const isResponses = activeTab.value === 'responses' const isGemini = activeTab.value === 'gemini' if (editingChannelIndex !== null) { // 更新现有渠道 if (isGemini) { await api.updateGeminiChannel(editingChannelIndex, channel) } else if (isResponses) { await api.updateResponsesChannel(editingChannelIndex, channel) } else { await api.updateChannel(editingChannelIndex, channel) } return { success: true, message: '渠道更新成功' } } else { // 添加新渠道 if (isGemini) { await api.addGeminiChannel(channel) } else if (isResponses) { await api.addResponsesChannel(channel) } else { await api.addChannel(channel) } // 快速添加模式:将新渠道设为第一优先级并设置5分钟促销期 if (options?.isQuickAdd) { await refreshChannels() // 先刷新获取新渠道的 index const data = isGemini ? geminiChannelsData.value : (isResponses ? responsesChannelsData.value : channelsData.value) // 找到新添加的渠道(应该是列表中 index 最大的 active 状态渠道) const activeChannels = data.channels?.filter(ch => ch.status !== 'disabled') || [] if (activeChannels.length > 0) { // 新添加的渠道会分配到最大的 index const newChannel = activeChannels.reduce((max, ch) => ch.index > max.index ? ch : max, activeChannels[0]) try { // 1. 重新排序:将新渠道放到第一位 const otherIndexes = activeChannels .filter(ch => ch.index !== newChannel.index) .sort((a, b) => (a.priority ?? a.index) - (b.priority ?? b.index)) .map(ch => ch.index) const newOrder = [newChannel.index, ...otherIndexes] if (isGemini) { await api.reorderGeminiChannels(newOrder) } else if (isResponses) { await api.reorderResponsesChannels(newOrder) } else { await api.reorderChannels(newOrder) } // 2. 设置5分钟促销期(300秒) if (isGemini) { await api.setGeminiChannelPromotion(newChannel.index, 300) } else if (isResponses) { await api.setResponsesChannelPromotion(newChannel.index, 300) } else { await api.setChannelPromotion(newChannel.index, 300) } return { success: true, message: '渠道添加成功', quickAddMessage: `渠道 ${channel.name} 已设为最高优先级,5分钟内优先使用` } } catch (err) { console.warn('设置快速添加优先级失败:', err) // 不影响主流程 } } } return { success: true, message: '渠道添加成功' } } } /** * 删除渠道 */ async function deleteChannel(channelId: number) { if (activeTab.value === 'gemini') { await api.deleteGeminiChannel(channelId) } else if (activeTab.value === 'responses') { await api.deleteResponsesChannel(channelId) } else { await api.deleteChannel(channelId) } await refreshChannels() return { success: true, message: '渠道删除成功' } } /** * 测试单个渠道延迟 */ async function pingChannel(channelId: number) { const result = activeTab.value === 'gemini' ? await api.pingGeminiChannel(channelId) : await api.pingChannel(channelId) const data = activeTab.value === 'gemini' ? geminiChannelsData.value : (activeTab.value === 'messages' ? channelsData.value : responsesChannelsData.value) const channel = data.channels?.find(c => c.index === channelId) if (channel) { channel.latency = result.latency channel.latencyTestTime = Date.now() // 记录测试时间,用于 5 分钟后清除 } return { success: true } } /** * 批量测试所有渠道延迟 */ async function pingAllChannels() { if (isPingingAll.value) return { success: false, message: '正在测试中' } isPingingAll.value = true try { const results = activeTab.value === 'gemini' ? await api.pingAllGeminiChannels() : await api.pingAllChannels() const data = activeTab.value === 'gemini' ? geminiChannelsData.value : (activeTab.value === 'messages' ? channelsData.value : responsesChannelsData.value) const now = Date.now() results.forEach(result => { const channel = data.channels?.find(c => c.index === result.id) if (channel) { channel.latency = result.latency channel.latencyTestTime = now // 记录测试时间,用于 5 分钟后清除 } }) return { success: true } } finally { isPingingAll.value = false } } /** * 更新负载均衡策略 */ async function updateLoadBalance(strategy: string) { if (activeTab.value === 'gemini') { await api.updateGeminiLoadBalance(strategy) geminiChannelsData.value.loadBalance = strategy } else if (activeTab.value === 'messages') { await api.updateLoadBalance(strategy) channelsData.value.loadBalance = strategy } else { await api.updateResponsesLoadBalance(strategy) responsesChannelsData.value.loadBalance = strategy } return { success: true, message: `负载均衡策略已更新为: ${strategy}` } } /** * 启动自动刷新定时器 */ function startAutoRefresh() { stopAutoRefresh() autoRefreshRunning = true const tick = async () => { if (!autoRefreshRunning) return try { await refreshChannels() } catch (error) { console.warn('自动刷新失败:', error) } finally { if (autoRefreshRunning) { autoRefreshTimer = setTimeout(() => { void tick() }, AUTO_REFRESH_INTERVAL) } } } autoRefreshTimer = setTimeout(() => { void tick() }, AUTO_REFRESH_INTERVAL) } /** * 停止自动刷新定时器 */ function stopAutoRefresh() { autoRefreshRunning = false if (!autoRefreshTimer) return clearTimeout(autoRefreshTimer) autoRefreshTimer = null } /** * 清空所有渠道数据(用于注销) */ function clearChannels() { channelsData.value = { channels: [], current: -1, loadBalance: 'round-robin' } responsesChannelsData.value = { channels: [], current: -1, loadBalance: 'round-robin' } geminiChannelsData.value = { channels: [], current: -1, loadBalance: 'round-robin' } // 清空所有 tab 的独立缓存 dashboardCache.value = { messages: { metrics: [], stats: undefined, recentActivity: undefined }, responses: { metrics: [], stats: undefined, recentActivity: undefined }, gemini: { metrics: [], stats: undefined, recentActivity: undefined } } // 重置状态标志,避免注销后状态残留 lastRefreshSuccess.value = true isPingingAll.value = false } // ===== 返回公开接口 ===== return { // 状态 activeTab, channelsData, responsesChannelsData, geminiChannelsData, isPingingAll, lastRefreshSuccess, // 计算属性 currentChannelsData, currentDashboardMetrics, currentDashboardStats, currentDashboardRecentActivity, activeChannelCount, failoverChannelCount, // 方法 refreshChannels, saveChannel, deleteChannel, pingChannel, pingAllChannels, updateLoadBalance, startAutoRefresh, stopAutoRefresh, clearChannels, } }) ================================================ FILE: frontend/src/stores/dialog.ts ================================================ import { defineStore } from 'pinia' import { ref } from 'vue' import type { Channel } from '@/services/api' /** * 对话框状态管理 Store * * 职责: * - 管理添加/编辑渠道对话框状态 * - 管理添加 API 密钥对话框状态 * - 管理对话框相关的临时数据(编辑中的渠道、新密钥等) */ export const useDialogStore = defineStore('dialog', () => { // ===== 状态 ===== // 添加/编辑渠道对话框 const showAddChannelModal = ref(false) const editingChannel = ref(null) // 添加 API 密钥对话框 const showAddKeyModal = ref(false) const selectedChannelForKey = ref(-1) const newApiKey = ref('') // ===== 操作方法 ===== /** * 打开添加渠道对话框 */ function openAddChannelModal() { editingChannel.value = null showAddChannelModal.value = true } /** * 打开编辑渠道对话框 */ function openEditChannelModal(channel: Channel) { editingChannel.value = channel showAddChannelModal.value = true } /** * 关闭渠道对话框 */ function closeAddChannelModal() { showAddChannelModal.value = false editingChannel.value = null } /** * 打开添加密钥对话框 */ function openAddKeyModal(channelId: number) { selectedChannelForKey.value = channelId newApiKey.value = '' showAddKeyModal.value = true } /** * 关闭密钥对话框 */ function closeAddKeyModal() { showAddKeyModal.value = false selectedChannelForKey.value = -1 newApiKey.value = '' } /** * 重置所有对话框状态 */ function resetDialogState() { showAddChannelModal.value = false editingChannel.value = null showAddKeyModal.value = false selectedChannelForKey.value = -1 newApiKey.value = '' } return { // 状态 showAddChannelModal, editingChannel, showAddKeyModal, selectedChannelForKey, newApiKey, // 方法 openAddChannelModal, openEditChannelModal, closeAddChannelModal, openAddKeyModal, closeAddKeyModal, resetDialogState, } }) ================================================ FILE: frontend/src/stores/index.ts ================================================ /** * Pinia Stores 统一导出 */ export { useAuthStore } from './auth' export { useChannelStore } from './channel' export { usePreferencesStore } from './preferences' export { useDialogStore } from './dialog' export { useSystemStore } from './system' ================================================ FILE: frontend/src/stores/preferences.ts ================================================ import { defineStore } from 'pinia' import { ref } from 'vue' /** * 用户偏好设置 Store * * 职责: * - 管理暗色模式偏好(light/dark/auto) * - 管理 Fuzzy 模式开关 * - 管理全局统计面板展开状态 * - 自动持久化到 localStorage */ export const usePreferencesStore = defineStore('preferences', () => { // ===== 状态 ===== // 暗色模式偏好 const darkModePreference = ref<'light' | 'dark' | 'auto'>('auto') // Fuzzy 模式开关 const fuzzyModeEnabled = ref(true) // 全局统计面板展开状态 const showGlobalStats = ref(false) // ===== 操作方法 ===== /** * 设置暗色模式 */ function setDarkMode(mode: 'light' | 'dark' | 'auto') { darkModePreference.value = mode } /** * 切换暗色模式(循环切换) */ function toggleDarkMode() { const modes: Array<'light' | 'dark' | 'auto'> = ['light', 'dark', 'auto'] const currentIndex = modes.indexOf(darkModePreference.value) const nextIndex = (currentIndex + 1) % modes.length darkModePreference.value = modes[nextIndex] } /** * 设置 Fuzzy 模式 */ function setFuzzyMode(enabled: boolean) { fuzzyModeEnabled.value = enabled } /** * 切换 Fuzzy 模式 */ function toggleFuzzyMode() { fuzzyModeEnabled.value = !fuzzyModeEnabled.value } /** * 切换全局统计面板 */ function toggleGlobalStats() { showGlobalStats.value = !showGlobalStats.value } return { // 状态 darkModePreference, fuzzyModeEnabled, showGlobalStats, // 方法 setDarkMode, toggleDarkMode, setFuzzyMode, toggleFuzzyMode, toggleGlobalStats, } }, { // 持久化配置 persist: { key: 'claude-proxy-preferences', // 使用条件判断避免在非浏览器环境(SSR、Node 测试)中崩溃 storage: typeof window !== 'undefined' ? localStorage : undefined, }, }) ================================================ FILE: frontend/src/stores/system.ts ================================================ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { VersionInfo } from '@/services/version' /** * 系统状态管理 Store * * 职责: * - 管理系统运行状态(running/error/connecting) * - 管理版本信息和版本检查状态 * - 管理 Fuzzy 模式加载状态 */ export const useSystemStore = defineStore('system', () => { // ===== 状态 ===== // 系统连接状态 type SystemStatus = 'running' | 'error' | 'connecting' const systemStatus = ref('connecting') // 版本信息 const versionInfo = ref({ currentVersion: '', latestVersion: null, isLatest: false, hasUpdate: false, releaseUrl: null, lastCheckTime: 0, status: 'checking', }) // 版本检查加载状态 const isCheckingVersion = ref(false) // Fuzzy 模式加载状态 const fuzzyModeLoading = ref(false) const fuzzyModeLoadError = ref(false) // ===== 计算属性 ===== const systemStatusText = computed(() => { switch (systemStatus.value) { case 'running': return '运行中' case 'error': return '连接失败' case 'connecting': return '连接中' default: return '未知' } }) const systemStatusDesc = computed(() => { switch (systemStatus.value) { case 'running': return '服务正常运行' case 'error': return '无法连接后端' case 'connecting': return '正在连接后端' default: return '' } }) // ===== 操作方法 ===== /** * 设置系统状态 */ function setSystemStatus(status: SystemStatus) { systemStatus.value = status } /** * 设置版本信息 */ function setVersionInfo(info: VersionInfo) { versionInfo.value = info } /** * 更新当前版本号 */ function setCurrentVersion(version: string) { versionInfo.value.currentVersion = version } /** * 设置版本检查状态 */ function setCheckingVersion(checking: boolean) { isCheckingVersion.value = checking } /** * 设置 Fuzzy 模式加载状态 */ function setFuzzyModeLoading(loading: boolean) { fuzzyModeLoading.value = loading } /** * 设置 Fuzzy 模式加载错误状态 */ function setFuzzyModeLoadError(error: boolean) { fuzzyModeLoadError.value = error } /** * 重置系统状态 */ function resetSystemState() { systemStatus.value = 'connecting' versionInfo.value = { currentVersion: '', latestVersion: null, isLatest: false, hasUpdate: false, releaseUrl: null, lastCheckTime: 0, status: 'checking', } isCheckingVersion.value = false fuzzyModeLoading.value = false fuzzyModeLoadError.value = false } return { // 状态 systemStatus, versionInfo, isCheckingVersion, fuzzyModeLoading, fuzzyModeLoadError, // 计算属性 systemStatusText, systemStatusDesc, // 方法 setSystemStatus, setVersionInfo, setCurrentVersion, setCheckingVersion, setFuzzyModeLoading, setFuzzyModeLoadError, resetSystemState, } }) ================================================ FILE: frontend/src/styles/settings.scss ================================================ // Vuetify 样式变量配置 - 复古像素主题 // 复古像素主题:无圆角 $border-radius-root: 0px !default; $btn-border-radius: 0px !default; $card-border-radius: 0px !default; $sheet-border-radius: 0px !default; // 自定义颜色 - 复古高对比度 $primary: #6366F1 !default; $secondary: #8B5CF6 !default; $accent: #EC4899 !default; $success: #10B981 !default; $info: #3B82F6 !default; $warning: #F59E0B !default; $error: #EF4444 !default; // 字体配置 - 等宽字体 $body-font-family: 'Courier New', Consolas, 'Liberation Mono', monospace !default; $headings-font-family: 'Courier New', Consolas, 'Liberation Mono', monospace !default; @use 'vuetify/settings'; ================================================ FILE: frontend/src/utils/quickInputParser.test.ts ================================================ /** * 快速添加渠道 - 输入解析测试 * * 测试 isValidApiKey 和 isValidUrl 工具函数 */ import { describe, it, expect } from 'vitest' import { isValidApiKey, isValidUrl, parseQuickInput } from './quickInputParser' describe('API Key 识别', () => { describe('OpenAI 格式', () => { it('应识别 OpenAI Legacy 格式 (sk-xxx)', () => { expect(isValidApiKey('sk-7nKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1234')).toBe(true) expect(isValidApiKey('sk-abcdef1234567890abcdef1234567890abcdef123456')).toBe(true) }) it('应识别 OpenAI Project 格式 (sk-proj-xxx)', () => { expect(isValidApiKey('sk-proj-Aw9' + 'x'.repeat(100))).toBe(true) expect(isValidApiKey('sk-proj-' + 'abcdef1234567890'.repeat(8))).toBe(true) }) }) describe('Anthropic Claude 格式', () => { it('应识别 Anthropic 格式 (sk-ant-api03-xxx)', () => { expect(isValidApiKey('sk-ant-api03-bK9' + 'x'.repeat(80))).toBe(true) expect(isValidApiKey('sk-ant-api03-' + 'abcdef1234567890'.repeat(6))).toBe(true) }) }) describe('Google Gemini 格式', () => { it('应识别 AIza 开头的 key', () => { // Google API Key 总长度 39 字符: AIza (4) + 35 字符 expect(isValidApiKey('AIzaSyDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx')).toBe(true) expect(isValidApiKey('AIzaXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX')).toBe(true) }) it('不应识别非 AIza 开头的类似格式', () => { expect(isValidApiKey('AIzbSyDxxx')).toBe(false) expect(isValidApiKey('Aiza1234567')).toBe(false) }) }) describe('OpenRouter 格式', () => { it('应识别 OpenRouter 格式 (sk-or-v1-xxx)', () => { // OpenRouter 使用混合大小写字母数字 expect(isValidApiKey('sk-or-v1-0ndQl1opjKLMNOPqrs' + 'x'.repeat(40))).toBe(true) expect(isValidApiKey('sk-or-v1-' + 'AbCdEf123456'.repeat(5))).toBe(true) }) }) describe('Hugging Face 格式', () => { it('应识别 hf_ 前缀', () => { expect(isValidApiKey('hf_AVd' + 'x'.repeat(31))).toBe(true) expect(isValidApiKey('hf_' + 'abcdef1234567890'.repeat(2) + 'ab')).toBe(true) }) }) describe('Groq 格式', () => { it('应识别 gsk_ 前缀', () => { expect(isValidApiKey('gsk_8sX' + 'x'.repeat(49))).toBe(true) expect(isValidApiKey('gsk_' + 'abcdef1234567890'.repeat(3) + 'abcd')).toBe(true) }) }) describe('Perplexity 格式', () => { it('应识别 pplx- 前缀', () => { expect(isValidApiKey('pplx-f9a' + 'x'.repeat(40))).toBe(true) expect(isValidApiKey('pplx-' + 'abcdef1234567890'.repeat(3))).toBe(true) }) }) describe('Replicate 格式', () => { it('应识别 r8_ 前缀', () => { expect(isValidApiKey('r8_G7b' + 'x'.repeat(20))).toBe(true) expect(isValidApiKey('r8_abcdef1234567890abcdef')).toBe(true) }) }) describe('智谱 AI 格式 (id.secret)', () => { it('应识别智谱 AI 的 id.secret 格式', () => { expect(isValidApiKey('269abc123456789012345678.r8abcdef1234')).toBe(true) expect(isValidApiKey('abcdefghij1234567890abcd.secretkey123456')).toBe(true) }) }) describe('火山引擎格式', () => { it('应识别火山引擎 Ark UUID 格式', () => { expect(isValidApiKey('550e8400-e29b-41d4-a716-446655440000')).toBe(true) expect(isValidApiKey('123e4567-e89b-12d3-a456-426614174000')).toBe(true) }) it('应识别火山引擎 IAM AK 格式', () => { expect(isValidApiKey('AKLTNmYyYz' + 'x'.repeat(20))).toBe(true) expect(isValidApiKey('AKLTabcdefghij1234567890abcdefgh')).toBe(true) }) }) describe('通用前缀格式 (xx-xxx / xx_xxx)', () => { it('应识别包含数字或混合大小写的后缀', () => { expect(isValidApiKey('sk-proj-abc123xyz')).toBe(true) expect(isValidApiKey('sk-1234567890abcdef')).toBe(true) expect(isValidApiKey('sk-abcDEFghiJKL')).toBe(true) expect(isValidApiKey('ut_abc123456789')).toBe(true) expect(isValidApiKey('api-key12345678901')).toBe(true) expect(isValidApiKey('cr_xxxxxxxxx123')).toBe(true) }) it('不应识别单字母前缀', () => { expect(isValidApiKey('s-1234567890123')).toBe(false) expect(isValidApiKey('u_1234567890123')).toBe(false) }) it('不应识别无分隔符的字符串', () => { expect(isValidApiKey('sk123')).toBe(false) expect(isValidApiKey('apikey')).toBe(false) }) it('不应识别分隔符后无内容的字符串', () => { expect(isValidApiKey('sk-')).toBe(false) expect(isValidApiKey('ut_')).toBe(false) }) }) describe('宽松兜底格式(常见前缀 + 任意后缀)', () => { it('应识别 sk- 前缀的短密钥', () => { expect(isValidApiKey('sk-111')).toBe(true) expect(isValidApiKey('sk-x')).toBe(true) expect(isValidApiKey('sk-abc')).toBe(true) expect(isValidApiKey('sk-test')).toBe(true) }) it('应识别其他常见前缀的短密钥', () => { expect(isValidApiKey('api-123')).toBe(true) expect(isValidApiKey('key-abc')).toBe(true) expect(isValidApiKey('ut_test')).toBe(true) expect(isValidApiKey('hf_short')).toBe(true) expect(isValidApiKey('gsk_x')).toBe(true) expect(isValidApiKey('cr_1')).toBe(true) expect(isValidApiKey('ms-test')).toBe(true) expect(isValidApiKey('r8_abc')).toBe(true) expect(isValidApiKey('pplx-x')).toBe(true) }) it('不应识别未知前缀的短字符串', () => { expect(isValidApiKey('xx-111')).toBe(false) expect(isValidApiKey('foo-bar')).toBe(false) expect(isValidApiKey('test_key')).toBe(false) }) }) describe('配置键名格式(应被排除)', () => { it('不应识别全大写下划线分隔的配置键名', () => { expect(isValidApiKey('API_TIMEOUT_MS')).toBe(false) expect(isValidApiKey('ANTHROPIC_BASE_URL')).toBe(false) expect(isValidApiKey('ANTHROPIC_AUTH_TOKEN')).toBe(false) expect(isValidApiKey('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC')).toBe(false) expect(isValidApiKey('DATABASE_URL')).toBe(false) expect(isValidApiKey('SECRET_KEY')).toBe(false) }) it('不应识别带数字的配置键名', () => { expect(isValidApiKey('API_V2_KEY')).toBe(false) expect(isValidApiKey('REDIS_DB_0')).toBe(false) }) }) describe('JWT 格式', () => { it('应识别有效的 JWT', () => { const validJwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U' expect(isValidApiKey(validJwt)).toBe(true) }) it('应识别简短但有效的 JWT 格式', () => { // 至少 20 字符,有两个点 expect(isValidApiKey('eyJhbGciOiJIUzI1Ni.eyJzdWIiOiIxMjM0.xxx')).toBe(true) }) it('不应识别只有一个点的 JWT', () => { expect(isValidApiKey('eyJhbGciOiJIUzI1NiIs.xxx')).toBe(false) }) it('不应识别过短的 JWT', () => { expect(isValidApiKey('eyJ.xxx.yyy')).toBe(false) }) }) describe('长字符串格式 (≥32 字符,需同时包含字母和数字)', () => { it('应识别 32+ 字符的字母数字混合字符串', () => { expect(isValidApiKey('abcdefghijklmnopqrstuvwxyz123456')).toBe(true) expect(isValidApiKey('ABCDEFGHIJKLMNOPQRSTUVWXYZ123456')).toBe(true) expect(isValidApiKey('72f988bf7ab9e0f0a1234567890abcde')).toBe(true) // Azure OpenAI 风格 }) it('应识别包含下划线和横线的长字符串', () => { expect(isValidApiKey('abcdefghijklmnop_qrstuvwxyz-12345')).toBe(true) }) it('不应识别少于 32 字符的无前缀字符串', () => { expect(isValidApiKey('a'.repeat(31))).toBe(false) expect(isValidApiKey('shortkey')).toBe(false) }) it('不应识别纯字母的长字符串', () => { expect(isValidApiKey('a'.repeat(32))).toBe(false) expect(isValidApiKey('abcdefghijklmnopqrstuvwxyzabcdef')).toBe(false) }) it('不应识别包含特殊字符的字符串', () => { expect(isValidApiKey('a'.repeat(30) + '!@')).toBe(false) expect(isValidApiKey('abcdefghijklmnopqrstuvwxyz12345!')).toBe(false) }) }) describe('无效输入', () => { it('不应识别普通单词', () => { expect(isValidApiKey('hello')).toBe(false) expect(isValidApiKey('world')).toBe(false) expect(isValidApiKey('test')).toBe(false) }) it('不应识别 URL', () => { expect(isValidApiKey('http://localhost')).toBe(false) expect(isValidApiKey('https://api.example.com')).toBe(false) }) it('不应识别空字符串', () => { expect(isValidApiKey('')).toBe(false) }) it('不应识别纯数字', () => { expect(isValidApiKey('12345678901234567890123456789012')).toBe(false) }) }) }) describe('URL 识别', () => { describe('有效 URL', () => { it('应识别 localhost', () => { expect(isValidUrl('http://localhost')).toBe(true) expect(isValidUrl('http://localhost/')).toBe(true) expect(isValidUrl('http://localhost:3000')).toBe(true) expect(isValidUrl('http://localhost:3000/')).toBe(true) expect(isValidUrl('http://localhost:5688/v1')).toBe(true) }) it('应识别域名', () => { expect(isValidUrl('https://api.openai.com')).toBe(true) expect(isValidUrl('https://api.openai.com/')).toBe(true) expect(isValidUrl('https://api.openai.com/v1')).toBe(true) expect(isValidUrl('https://api.anthropic.com/v1')).toBe(true) }) it('应识别带端口的域名', () => { expect(isValidUrl('http://example.com:8080')).toBe(true) expect(isValidUrl('https://api.example.com:443/v1')).toBe(true) }) it('应识别 IP 地址', () => { expect(isValidUrl('http://127.0.0.1')).toBe(true) expect(isValidUrl('http://192.168.1.1:8080')).toBe(true) }) it('应识别子域名', () => { expect(isValidUrl('https://api.v2.example.com')).toBe(true) expect(isValidUrl('https://a.b.c.d.example.com/path')).toBe(true) }) }) describe('无效 URL', () => { it('不应识别不完整的 URL', () => { expect(isValidUrl('http://')).toBe(false) expect(isValidUrl('https://')).toBe(false) expect(isValidUrl('http:///')).toBe(false) }) it('不应识别无协议的 URL', () => { expect(isValidUrl('localhost')).toBe(false) expect(isValidUrl('api.openai.com')).toBe(false) expect(isValidUrl('//api.openai.com')).toBe(false) }) it('不应识别无效协议', () => { expect(isValidUrl('ftp://example.com')).toBe(false) expect(isValidUrl('ws://example.com')).toBe(false) }) it('不应识别无效域名格式', () => { expect(isValidUrl('http://-example.com')).toBe(false) expect(isValidUrl('http://example-.com')).toBe(false) }) }) }) describe('综合解析场景', () => { it('应正确解析 URL + 多个 API Key', () => { const input = ` https://api.openai.com/v1 sk-key1abc123456 sk-key2def789012 sk-key3ghi345678 ` const result = parseQuickInput(input) expect(result.detectedBaseUrl).toBe('https://api.openai.com/v1') expect(result.detectedApiKeys).toEqual(['sk-key1abc123456', 'sk-key2def789012', 'sk-key3ghi345678']) }) it('应正确解析 localhost URL', () => { const input = 'http://localhost:5688 sk-1234567890ab sk-abcdef123456' const result = parseQuickInput(input) expect(result.detectedBaseUrl).toBe('http://localhost:5688') expect(result.detectedApiKeys).toEqual(['sk-1234567890ab', 'sk-abcdef123456']) }) it('应正确解析混合分隔符', () => { const input = 'https://api.example.com, sk-key1234567890; ut_key2abc123456, api-key3def789012' const result = parseQuickInput(input) expect(result.detectedBaseUrl).toBe('https://api.example.com') expect(result.detectedApiKeys).toEqual(['sk-key1234567890', 'ut_key2abc123456', 'api-key3def789012']) }) it('应忽略不完整的 URL', () => { const input = 'http:// sk-key1234567890' const result = parseQuickInput(input) expect(result.detectedBaseUrl).toBe('') expect(result.detectedApiKeys).toEqual(['sk-key1234567890']) }) it('应只取第一个 URL', () => { const input = 'https://first.com https://second.com sk-key1234567890' const result = parseQuickInput(input) expect(result.detectedBaseUrl).toBe('https://first.com') expect(result.detectedApiKeys).toEqual(['sk-key1234567890']) }) it('应去重 API Key', () => { const input = 'sk-key1234567890 sk-key1234567890 sk-key2abcdef123' const result = parseQuickInput(input) expect(result.detectedApiKeys).toEqual(['sk-key1234567890', 'sk-key2abcdef123']) }) it('应保留 # 结尾(跳过版本号)', () => { const input = 'https://api.example.com/anthropic# sk-key1234567890' const result = parseQuickInput(input) expect(result.detectedBaseUrl).toBe('https://api.example.com/anthropic#') }) it('应保留无路径的 # 结尾', () => { const input = 'https://api.example.com# sk-key1234567890' const result = parseQuickInput(input) expect(result.detectedBaseUrl).toBe('https://api.example.com#') }) it('应移除末尾斜杠', () => { const input = 'https://api.example.com/ sk-key1234567890' const result = parseQuickInput(input) expect(result.detectedBaseUrl).toBe('https://api.example.com') }) it('应正确处理 JWT 格式的 key', () => { const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U' const input = `https://api.example.com ${jwt}` const result = parseQuickInput(input) expect(result.detectedBaseUrl).toBe('https://api.example.com') expect(result.detectedApiKeys).toEqual([jwt]) }) }) describe('引号内容提取', () => { it('应从英文双引号中提取 URL 和 API Key', () => { const input = `"ANTHROPIC_AUTH_TOKEN": "sk-lACTyHP69FC46DeD8F67T3BLBkFJ4cE3879908bc4c38a336", "ANTHROPIC_BASE_URL": "https://apic1.ohmycdn.com/api/v1/ai/openai/cc-omg"` const result = parseQuickInput(input) expect(result.detectedBaseUrl).toBe('https://apic1.ohmycdn.com/api/v1/ai/openai/cc-omg') expect(result.detectedApiKeys).toContain('sk-lACTyHP69FC46DeD8F67T3BLBkFJ4cE3879908bc4c38a336') // 不应识别配置键名 expect(result.detectedApiKeys).not.toContain('ANTHROPIC_AUTH_TOKEN') expect(result.detectedApiKeys).not.toContain('ANTHROPIC_BASE_URL') }) it('应从英文单引号中提取内容', () => { const input = `'sk-test123456789012' 'https://api.example.com/v1'` const result = parseQuickInput(input) expect(result.detectedBaseUrl).toBe('https://api.example.com/v1') expect(result.detectedApiKeys).toEqual(['sk-test123456789012']) }) it('应从中文双引号中提取内容', () => { const input = `"sk-chinese123456789""https://api.example.com"` const result = parseQuickInput(input) expect(result.detectedBaseUrl).toBe('https://api.example.com') expect(result.detectedApiKeys).toEqual(['sk-chinese123456789']) }) it('应从中文单引号中提取内容', () => { const input = `'sk-chinese789012345''https://api.test.com'` const result = parseQuickInput(input) expect(result.detectedBaseUrl).toBe('https://api.test.com') expect(result.detectedApiKeys).toEqual(['sk-chinese789012345']) }) it('应正确解析完整的 Claude Code 配置格式', () => { const input = `发一个20$的key,用起来还不错,你们试试,好像是官逆 snow里获取不到模型不知道为啥 { "env": { "ANTHROPIC_AUTH_TOKEN": "sk-lACTyHP69FC46DeD8F67T3BLBkFJ4cE3879908bc4c38a336", "ANTHROPIC_BASE_URL": "https://apic1.ohmycdn.com/api/v1/ai/openai/cc-omg", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1 } }` const result = parseQuickInput(input) expect(result.detectedBaseUrl).toBe('https://apic1.ohmycdn.com/api/v1/ai/openai/cc-omg') expect(result.detectedApiKeys).toContain('sk-lACTyHP69FC46DeD8F67T3BLBkFJ4cE3879908bc4c38a336') // 不应识别配置键名 expect(result.detectedApiKeys).not.toContain('ANTHROPIC_AUTH_TOKEN') expect(result.detectedApiKeys).not.toContain('ANTHROPIC_BASE_URL') expect(result.detectedApiKeys).not.toContain('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC') }) it('应正确解析 Claude Code settings.json 格式', () => { const input = `{ "$schema": "https://json.schemastore.org/claude-code-settings.json", "env": { "API_TIMEOUT_MS": "200000", "ANTHROPIC_BASE_URL": "http://localhost:3688/", "ANTHROPIC_AUTH_TOKEN": "key", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" }, "includeCoAuthoredBy": false }` const result = parseQuickInput(input) expect(result.detectedBaseUrl).toBe('https://json.schemastore.org/claude-code-settings.json') // 不应识别任何配置键名 expect(result.detectedApiKeys).not.toContain('API_TIMEOUT_MS') expect(result.detectedApiKeys).not.toContain('ANTHROPIC_BASE_URL') expect(result.detectedApiKeys).not.toContain('ANTHROPIC_AUTH_TOKEN') expect(result.detectedApiKeys).not.toContain('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC') }) it('应忽略引号内的非 URL/Key 内容', () => { const input = `"env": { "ANTHROPIC_AUTH_TOKEN": "sk-valid1234567890" }` const result = parseQuickInput(input) expect(result.detectedBaseUrl).toBe('') expect(result.detectedApiKeys).toContain('sk-valid1234567890') expect(result.detectedApiKeys).not.toContain('ANTHROPIC_AUTH_TOKEN') }) it('应同时支持引号内容和普通分隔', () => { const input = `"sk-quoted123456789" sk-plain4567890123 https://api.example.com` const result = parseQuickInput(input) expect(result.detectedBaseUrl).toBe('https://api.example.com') expect(result.detectedApiKeys).toContain('sk-quoted123456789') expect(result.detectedApiKeys).toContain('sk-plain4567890123') }) it('应支持单边引号(只有开头引号)', () => { const input = `"http://localhost:5689` const result = parseQuickInput(input) expect(result.detectedBaseUrl).toBe('http://localhost:5689') }) it('应支持单边引号提取 API Key', () => { const input = `"sk-test1234567890` const result = parseQuickInput(input) expect(result.detectedApiKeys).toContain('sk-test1234567890') }) it('应支持单边单引号', () => { const input = `'https://api.example.com/v1` const result = parseQuickInput(input) expect(result.detectedBaseUrl).toBe('https://api.example.com/v1') }) it('应支持混合完整引号和单边引号', () => { const input = `"https://api.example.com" "sk-key12345678901` const result = parseQuickInput(input) expect(result.detectedBaseUrl).toBe('https://api.example.com') expect(result.detectedApiKeys).toContain('sk-key12345678901') }) }) ================================================ FILE: frontend/src/utils/quickInputParser.ts ================================================ /** * 快速添加渠道 - 输入解析工具 * * 用于识别 API Key 和 URL 格式 */ /** * 检测字符串是否看起来像配置键名(全大写 + 下划线分隔的单词) * 例如:API_TIMEOUT_MS, ANTHROPIC_BASE_URL, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC */ const looksLikeConfigKey = (token: string): boolean => { // 全大写字母 + 下划线,且由多个单词组成(至少包含一个下划线分隔的段) // 且每个段都是 2+ 字符的大写字母单词 if (/^[A-Z][A-Z0-9]*(_[A-Z][A-Z0-9]*)+$/.test(token)) { return true } return false } /** * 各平台 API Key 格式的专用正则匹配 * * 国际主流: * - OpenAI Legacy: sk-[a-zA-Z0-9]{48} * - OpenAI Project: sk-proj-[a-zA-Z0-9-]{100,} * - Anthropic: sk-ant-api03-[a-zA-Z0-9-]{80,} * - Google Gemini: AIza[0-9A-Za-z-_]{35} * - Azure OpenAI: 32位十六进制 * * 新兴生态: * - Hugging Face: hf_[a-zA-Z0-9]{34} * - Groq: gsk_[a-zA-Z0-9]{52} * - Perplexity: pplx-[a-zA-Z0-9]{40,} * - Replicate: r8_[a-zA-Z0-9]+ * - OpenRouter: sk-or-v1-[a-zA-Z0-9]{50,} * * 国内平台: * - DeepSeek/Moonshot/01.AI/SiliconFlow: sk-[a-zA-Z0-9]{48} (兼容 OpenAI) * - 智谱 AI: [a-z0-9]{32}\.[a-z0-9]+ (id.secret 格式) * - 火山引擎 Ark: UUID 格式 * - 火山引擎 IAM: AK 开头 */ const PLATFORM_KEY_PATTERNS: RegExp[] = [ // OpenAI Project Key (新格式,最长,优先匹配) /^sk-proj-[a-zA-Z0-9_-]{50,}$/, // Anthropic Claude /^sk-ant-api03-[a-zA-Z0-9_-]{50,}$/, // OpenRouter (混合大小写字母数字) /^sk-or-v1-[a-zA-Z0-9]{50,}$/, // OpenAI Legacy / DeepSeek / Moonshot / 01.AI / SiliconFlow /^sk-[a-zA-Z0-9]{20,}$/, // Google Gemini/PaLM (通常 39 字符,允许一定范围) /^AIza[0-9A-Za-z_-]{30,}$/, // Hugging Face /^hf_[a-zA-Z0-9]{30,}$/, // Groq /^gsk_[a-zA-Z0-9]{40,}$/, // Perplexity /^pplx-[a-zA-Z0-9]{40,}$/, // Replicate /^r8_[a-zA-Z0-9]{20,}$/, // 智谱 AI (id.secret 格式) /^[a-zA-Z0-9]{20,}\.[a-zA-Z0-9]{10,}$/, // 火山引擎 Ark (UUID 格式) /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, // 火山引擎 IAM AK /^AK[A-Z]{2,4}[a-zA-Z0-9]{20,}$/ ] /** * 检测字符串是否为有效的 API Key * * 支持的格式: * 1. 平台特定格式(优先匹配,准确度最高) * 2. 通用前缀格式:xx-xxx 或 xx_xxx(如 sk-xxx, ut_xxx, api-xxx) * 3. JWT 格式:eyJ 开头,包含两个点分隔的 base64 段 * 4. 长随机字符串:≥32 字符的字母数字串(必须包含字母和数字) * 5. 宽松兜底:常见前缀 + 任意后缀(当以上都不匹配时) * * 排除的格式: * - 配置键名:全大写 + 下划线分隔(如 API_TIMEOUT_MS) */ export const isValidApiKey = (token: string): boolean => { // 首先排除配置键名格式 if (looksLikeConfigKey(token)) { return false } // 1. 平台特定格式匹配(最准确) for (const pattern of PLATFORM_KEY_PATTERNS) { if (pattern.test(token)) { return true } } // 2. 通用前缀格式(前缀 2-6 字母 + 连字符/下划线 + 至少 10 字符后缀) // 后缀必须包含数字或混合大小写(随机特征) if (/^[a-zA-Z]{2,6}[-_][a-zA-Z0-9_-]{10,}$/.test(token)) { const suffix = token.replace(/^[a-zA-Z]{2,6}[-_]/, '') const hasDigit = /\d/.test(suffix) const hasMixedCase = /[a-z]/.test(suffix) && /[A-Z]/.test(suffix) if (hasDigit || hasMixedCase) { return true } } // 3. JWT 格式 (eyJ 开头,包含两个点分隔的 base64 段,总长度 >= 20) if (/^eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\./.test(token) && token.length >= 20) { return true } // 4. 长随机字符串(≥32 字符,必须同时包含字母和数字) if (token.length >= 32 && /^[a-zA-Z0-9_-]+$/.test(token) && /[a-zA-Z]/.test(token) && /\d/.test(token)) { return true } // 5. 宽松兜底:常见 API Key 前缀 + 任意后缀(至少 1 个字符) // 当以上严格规则都不匹配时,放松标准识别常见格式 // 支持:sk-xxx, api-xxx, key-xxx, ut_xxx, hf_xxx, gsk_xxx 等 if (/^(sk|api|key|ut|hf|gsk|cr|ms|r8|pplx)[-_].+$/i.test(token)) { return true } return false } /** * 检测字符串是否为有效的 URL * * 要求: * - 必须以 http:// 或 https:// 开头 * - 必须包含有效域名(域名段不能以横线开头或结尾) * - 支持末尾 # 标记(用于跳过自动添加 /v1) */ export const isValidUrl = (token: string): boolean => { // 域名段不能以横线开头或结尾,支持末尾 # 或 / 或直接结束 return /^https?:\/\/[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*(:\d+)?(\/|#|$)/i.test( token ) } /** * 从输入中提取所有 token * 按空白/逗号/分号/中文冒号/换行/引号(中英文)/等号/%20 分割 */ const extractTokens = (input: string): string[] => { return input .replace(/%20/g, ' ') .split(/[\n\s,;,;:="\u201c\u201d'\u2018\u2019]+/) .filter(t => t.length > 0) } /** * 根据 URL 路径检测服务类型,并返回清理后的 baseUrl * /messages → claude, /chat/completions → openai, /responses → responses */ const detectServiceTypeAndCleanUrl = ( url: string ): { serviceType: 'openai' | 'gemini' | 'claude' | 'responses' | null; cleanedUrl: string } => { try { const cleanUrl = url.replace(/#$/, '') const parsed = new URL(cleanUrl) const path = parsed.pathname.toLowerCase() // 检测端点并移除 const endpoints = ['/messages', '/chat/completions', '/responses', '/generatecontent'] for (const ep of endpoints) { if (path.includes(ep)) { // 移除端点路径,保留 /v1 等版本前缀 const idx = path.indexOf(ep) parsed.pathname = path.slice(0, idx) || '/' const serviceType = ep === '/messages' ? 'claude' : ep === '/chat/completions' ? 'openai' : ep === '/responses' ? 'responses' : 'gemini' let result = parsed.toString().replace(/\/$/, '') if (url.endsWith('#')) result += '#' return { serviceType, cleanedUrl: result } } } } catch { // 忽略解析错误 } return { serviceType: null, cleanedUrl: url } } // 保留导出以兼容可能的外部使用 export const detectServiceType = (url: string): 'openai' | 'gemini' | 'claude' | 'responses' | null => { return detectServiceTypeAndCleanUrl(url).serviceType } /** Base URL 最大数量限制 */ const MAX_BASE_URLS = 10 /** * 解析快速输入内容,提取 URL 和 API Keys * * 支持的格式: * 1. 纯文本:URL 和 API Key 以空白/逗号/分号/等号分隔 * 2. 引号包裹:从 "xxx" 或 'xxx' 中提取内容(支持 JSON 配置格式) * 3. 多 Base URL:所有符合 HTTP 链接格式的都作为 baseUrl(最多 10 个) */ export const parseQuickInput = ( input: string ): { detectedBaseUrl: string detectedBaseUrls: string[] detectedApiKeys: string[] detectedServiceType: 'openai' | 'gemini' | 'claude' | 'responses' | null } => { const detectedBaseUrls: string[] = [] let detectedServiceType: 'openai' | 'gemini' | 'claude' | 'responses' | null = null const detectedApiKeys: string[] = [] const tokens = extractTokens(input) for (const token of tokens) { if (isValidUrl(token)) { // 限制最大 URL 数量 if (detectedBaseUrls.length >= MAX_BASE_URLS) { continue } const endsWithHash = token.endsWith('#') let url = endsWithHash ? token.slice(0, -1) : token url = url.replace(/\/$/, '') const fullUrl = endsWithHash ? url + '#' : url // 检测协议并清理 URL(移除端点路径) const { serviceType, cleanedUrl } = detectServiceTypeAndCleanUrl(fullUrl) // 避免重复 if (!detectedBaseUrls.includes(cleanedUrl)) { detectedBaseUrls.push(cleanedUrl) // 使用第一个 URL 的服务类型 if (!detectedServiceType) { detectedServiceType = serviceType } } continue } if (isValidApiKey(token) && !detectedApiKeys.includes(token)) { detectedApiKeys.push(token) } } return { detectedBaseUrl: detectedBaseUrls[0] || '', detectedBaseUrls, detectedApiKeys, detectedServiceType } } ================================================ FILE: frontend/src/views/ChannelsView.vue ================================================ ================================================ FILE: frontend/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "lib": ["ES2020", "DOM", "DOM.Iterable"], "strict": true, "skipLibCheck": true, "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "preserve", "baseUrl": ".", "paths": { "@/*": ["src/*"] } }, "include": ["src/**/*.ts", "src/**/*.vue"], "exclude": ["dist", "node_modules"] } ================================================ FILE: frontend/vite.config.ts ================================================ import { defineConfig, loadEnv } from 'vite' import vue from '@vitejs/plugin-vue' import vuetify from 'vite-plugin-vuetify' import { resolve } from 'path' export default defineConfig(({ mode }) => { // 加载环境变量 const env = loadEnv(mode, process.cwd(), '') const frontendPort = parseInt(env.VITE_FRONTEND_PORT || '5173') const backendUrl = env.VITE_PROXY_TARGET || 'http://localhost:3000' return { // 使用绝对路径,适配 Go 嵌入式部署 base: '/', plugins: [ vue(), vuetify({ autoImport: false, // 禁用自动导入,使用手动配置的图标 styles: { configFile: 'src/styles/settings.scss' } }) ], resolve: { alias: { '@': resolve(__dirname, 'src') } }, server: { port: frontendPort, proxy: { '/api': { target: backendUrl, changeOrigin: true }, '/v1': { target: backendUrl, changeOrigin: true }, '/health': { target: backendUrl, changeOrigin: true } } }, css: { preprocessorOptions: { scss: { silenceDeprecations: ['import', 'global-builtin', 'if-function'] } } }, build: { outDir: 'dist', emptyOutDir: true, // 确保资源路径正确 assetsDir: 'assets', // 优化代码分割 rollupOptions: { output: { manualChunks: { 'vue-vendor': ['vue', 'vuetify'] } } } } } })