[
  {
    "path": ".claudeignore",
    "content": "\n# 忽略常规依赖\n**/node_modules\n**/frontend/node_modules\n**/dist\n**/.git\n**/.next\n**/.vercel\n\n# 忽略构建产物和二进制\n**/target\n**/bin\n**/obj\n*.mmdb\n*.pack\n*.bin\n*.rlib\n*.exe\n\n# 忽略日志\n**/logs\n*.log\n*.log.gz\n\n# 忽略外部引用\nrefs/"
  },
  {
    "path": ".dockerignore",
    "content": "# Git 相关\n.git\n.gitignore\n.github\n\n# Node 模块\nnode_modules\nfrontend/node_modules\nbackend/node_modules\n\n# 构建产物（会在 Docker 中重新构建）\nfrontend/dist\nbackend/dist\nbackend-go/dist\nbackend-go/frontend/dist\ndist\n\n# 日志和缓存\n*.log\nlogs\n.cache\n.vite\nfrontend/node_modules/.vite\n\n# 开发工具\n.vscode\n.idea\n*.swp\n*.swo\n*~\n\n# 环境配置\n.env\n.env.local\n.env.*.local\nbackend/.env\nbackend-go/.env\n\n# 配置文件（运行时生成）\n.config\nbackend-go/.config\n\n# 测试和覆盖率\ncoverage\n*.coverprofile\ncoverage.out\ncoverage.html\n\n# 临时文件\ntmp\n*.tmp\n.DS_Store\nThumbs.db\n\n# Aider 历史文件\n.aider*\n\n# Lock 文件保留\n!bun.lock\n!package-lock.json\n!go.sum\n"
  },
  {
    "path": ".gitattributes",
    "content": "# Bun lockfile - 配置 Git 以显示可读的差异\n# bun.lockb (二进制格式) - 需要 textconv 转换\n*.lockb binary diff=lockb\n# bun.lock (文本格式) - 直接作为文本处理\nbun.lock text diff\n\n# 确保跨平台行尾符一致性\n* text=auto eol=lf\n\n# 明确标记文本文件\n*.go text diff=golang\n*.js text\n*.ts text\n*.vue text\n*.json text\n*.md text\n*.yaml text\n*.yml text\n*.toml text\n*.sh text eol=lf\n\n# 明确标记二进制文件\n*.png binary\n*.jpg binary\n*.jpeg binary\n*.gif binary\n*.ico binary\n*.woff binary\n*.woff2 binary\n*.ttf binary\n*.eot binary\n"
  },
  {
    "path": ".github/workflows/docker-build.yml",
    "content": "name: Build Docker Image\n\non:\n  push:\n    tags:\n      - 'v*'\n\nenv:\n  REGISTRY: crpi-i19l8zl0ugidq97v.cn-hangzhou.personal.cr.aliyuncs.com\n  IMAGE_NAME: bene/claude-proxy\n\njobs:\n  build_docker_image:\n    name: Build Docker Image\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to Aliyun Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ secrets.ALIYUN_CR_USERNAME }}\n          password: ${{ secrets.ALIYUN_CR_PASSWORD }}\n\n      - name: Extract metadata\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: |\n            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n          flavor: |\n            latest=false\n          tags: |\n            type=ref,event=tag\n            type=raw,value=latest,enable=${{ !contains(github.ref, '-') }}\n\n      - name: Build and push Docker image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: Dockerfile\n          platforms: linux/amd64,linux/arm64\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          build-args: |\n            VERSION=${{ github.ref_name }}\n"
  },
  {
    "path": ".github/workflows/release-linux.yml",
    "content": "name: Release Linux Build\npermissions:\n  contents: write\n\non:\n  push:\n    tags:\n      - \"v*\"\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: false\n\njobs:\n  release:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Setup Node\n        uses: actions/setup-node@v4\n        with:\n          node-version: \"20.x\"\n\n      - name: Setup Bun\n        uses: oven-sh/setup-bun@v2\n\n      - name: Build Frontend\n        run: bun install && bun run build\n        working-directory: ./frontend\n\n      - name: Copy Frontend to Backend\n        run: |\n          rm -rf backend-go/frontend/dist\n          mkdir -p backend-go/frontend/dist\n          cp -r frontend/dist/* backend-go/frontend/dist/\n\n      - name: Setup Go\n        uses: actions/setup-go@v5\n        with:\n          go-version: \"1.22.x\"\n\n      - name: Build Backend for amd64\n        run: |\n          go mod tidy\n          go mod download\n          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\n        working-directory: ./backend-go\n\n      - name: Build Backend for arm64\n        run: |\n          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\n        working-directory: ./backend-go\n\n      - name: Release\n        uses: softprops/action-gh-release@v2.0.5\n        if: startsWith(github.ref, 'refs/tags/')\n        with:\n          files: |\n            backend-go/claude-proxy-linux-amd64\n            backend-go/claude-proxy-linux-arm64\n          draft: true\n          generate_release_notes: true\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/release-macos.yml",
    "content": "name: Release MacOS Build\npermissions:\n  contents: write\n\non:\n  push:\n    tags:\n      - \"v*\"\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: false\n\njobs:\n  release:\n    runs-on: macos-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Setup Node\n        uses: actions/setup-node@v4\n        with:\n          node-version: \"20.x\"\n\n      - name: Setup Bun\n        uses: oven-sh/setup-bun@v2\n\n      - name: Build Frontend\n        run: bun install && bun run build\n        working-directory: ./frontend\n\n      - name: Copy Frontend to Backend\n        run: |\n          rm -rf backend-go/frontend/dist\n          mkdir -p backend-go/frontend/dist\n          cp -r frontend/dist/* backend-go/frontend/dist/\n\n      - name: Setup Go\n        uses: actions/setup-go@v5\n        with:\n          go-version: \"1.22.x\"\n\n      - name: Build Backend arm64\n        run: |\n          go mod tidy\n          go mod download\n          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\n        working-directory: ./backend-go\n\n      - name: Build Backend amd64\n        run: |\n          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\n        working-directory: ./backend-go\n\n      - name: Release\n        uses: softprops/action-gh-release@v2.0.5\n        if: startsWith(github.ref, 'refs/tags/')\n        with:\n          files: |\n            backend-go/claude-proxy-darwin-arm64\n            backend-go/claude-proxy-darwin-amd64\n          draft: true\n          generate_release_notes: true\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/release-windows.yml",
    "content": "name: Release Windows Build\npermissions:\n  contents: write\n\non:\n  push:\n    tags:\n      - \"v*\"\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: false\n\njobs:\n  release:\n    runs-on: windows-latest\n    defaults:\n      run:\n        shell: bash\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Setup Node\n        uses: actions/setup-node@v4\n        with:\n          node-version: \"20.x\"\n\n      - name: Setup Bun\n        uses: oven-sh/setup-bun@v2\n\n      - name: Build Frontend\n        run: bun install && bun run build\n        working-directory: ./frontend\n\n      - name: Copy Frontend to Backend\n        run: |\n          rm -rf backend-go/frontend/dist\n          mkdir -p backend-go/frontend/dist\n          cp -r frontend/dist/* backend-go/frontend/dist/\n\n      - name: Setup Go\n        uses: actions/setup-go@v5\n        with:\n          go-version: \"1.22.x\"\n\n      - name: Build Backend amd64\n        run: |\n          go mod tidy\n          go mod download\n          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\n        working-directory: ./backend-go\n\n      - name: Build Backend arm64\n        run: |\n          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\n        working-directory: ./backend-go\n\n      - name: Release\n        uses: softprops/action-gh-release@v2.0.5\n        if: startsWith(github.ref, 'refs/tags/')\n        with:\n          files: |\n            backend-go/claude-proxy-windows-amd64.exe\n            backend-go/claude-proxy-windows-arm64.exe\n          draft: true\n          generate_release_notes: true\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\n\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n.pnpm-debug.log*\n\n# Diagnostic reports (https://nodejs.org/api/report.html)\n\nreport.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json\n\n# Runtime data\n\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\n\nlib-cov\n\n# Coverage directory used by tools like istanbul\n\ncoverage\n*.lcov\n\n# nyc test coverage\n\n.nyc_output\n\n# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)\n\n.grunt\n\n# Bower dependency directory (https://bower.io/)\n\nbower_components\n\n# node-waf configuration\n\n.lock-wscript\n\n# Compiled binary addons (https://nodejs.org/api/addons.html)\n\nbuild/Release\n\n# Dependency directories\n\nnode_modules/\njspm_packages/\n\n# Snowpack dependency directory (https://snowpack.dev/)\n\nweb_modules/\n\n# TypeScript cache\n\n*.tsbuildinfo\n\n# Optional npm cache directory\n\n.npm\n\n# Optional eslint cache\n\n.eslintcache\n\n# Optional stylelint cache\n\n.stylelintcache\n\n# Microbundle cache\n\n.rpt2_cache/\n.rts2_cache_cjs/\n.rts2_cache_es/\n.rts2_cache_umd/\n\n# Optional REPL history\n\n.node_repl_history\n\n# Output of 'npm pack'\n\n*.tgz\n\n# Yarn Integrity file\n\n.yarn-integrity\n\n# dotenv environment variable files\n\n.env\n.env.development.local\n.env.test.local\n.env.production.local\n.env.local\n\n# parcel-bundler cache (https://parceljs.org/)\n\n.cache\n.parcel-cache\n\n# Next.js build output\n\n.next\nout\n\n# Nuxt.js build / generate output\n\n.nuxt\ndist\n\n# Gatsby files\n\n.cache/\n\n# Comment in the public line in if your project uses Gatsby and not Next.js\n\n# https://nextjs.org/blog/next-9-1#public-directory-support\n\n# public\n\n# vuepress build output\n\n.vuepress/dist\n\n# vuepress v2.x temp and cache directory\n\n.temp\n.cache\n\n# Docusaurus cache and generated files\n\n.docusaurus\n\n# Serverless directories\n\n.serverless/\n\n# FuseBox cache\n\n.fusebox/\n\n# DynamoDB Local files\n\n.dynamodb/\n\n# TernJS port file\n\n.tern-port\n\n# Stores VSCode versions used for testing VSCode extensions\n\n.vscode-test\n\n# yarn v2\n\n.yarn/cache\n.yarn/unplugged\n.yarn/build-state.yml\n.yarn/install-state.gz\n.pnp.*\n\n# Cursor rules\n\n.cursorrules\n\n\n# VSCode\n\n.vscode/\n\nnode_modules\n\n# Output\n.output\n.vercel\n.netlify\n.svelte-kit\nbuild\n\n# OS\n.DS_Store\nThumbs.db\n\n# Env\n.env\n.env.*\n!.env.example\n!.env.test\n\n# Vite\nvite.config.js.timestamp-*  \nvite.config.ts.timestamp-*\n\n# Custom\n.claude/*\n!.claude/skills/\n.claude/skills/*\n!.claude/skills/version-bump/\n!.claude/skills/codex-review/\n!.claude/skills/github-release/\n\n.aider*\n*.log\n# 不要忽略包管理器的 lock 文件（package-lock.json, bun.lock, yarn.lock, pnpm-lock.yaml 等）\n# 这些文件对于确保依赖版本一致性至关重要\n# *.lock\n# *-lock.json\n# *-lock.yaml\n*.patch\n\n*.tsbuildinfo\n**/.config/\n\n# 测试和脚本文件\ntest-*.sh\ntest-*.js\ntest-*.ts\n.gocache/\n.gomodcache/\n\n.snow/\nrefs/\n\n# CCB/Codex 会话配置（包含本机路径等敏感信息）\n.ccb_config/\n\n# 临时调查文档\nbackend-go/cmd/\n!backend-go/cmd/stream_verify/\ndocs/claude-code-investigation-*.md\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"semi\": false,\n  \"singleQuote\": true,\n  \"trailingComma\": \"none\",\n  \"printWidth\": 120,\n  \"tabWidth\": 2,\n  \"arrowParens\": \"avoid\",\n  \"useTabs\": false,\n  \"bracketSpacing\": true,\n  \"endOfLine\": \"lf\"\n}\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# 仓库协作指南\n\n## 重要约定\n- **始终使用简体中文回复**。\n- 遵循 SOLID / KISS / DRY / YAGNI；优先修复根因，避免无关重构。\n\n## 项目结构与模块\n- `backend-go/`：主 Go 服务（Gin），构建后内嵌前端静态资源；Go 代码位于 `backend-go/internal/`。\n- `frontend/`：Vue 3 + Vite + Vuetify 管理界面；构建产物复制到 `backend-go/frontend/dist/` 并由后端 embed。\n- `dist/`：发布构建产物（Go 二进制/打包后的 UI），不要手动编辑。\n- `.config/`：运行时配置目录（`config.json` 及 `backups/`），随容器/本地持久化。\n- `refs/`：外部参考项目存档，仅供对照，默认只读。\n- 文档入口：`README.md`、`ARCHITECTURE.md`、`DEVELOPMENT.md`、`ENVIRONMENT.md`、`RELEASE.md`。\n\n## 构建/测试/开发命令\n- 全栈开发（推荐）：根目录 `make dev`（前端 `bun run dev` + 后端 `air` 热重载）。\n- 仅后端：`cd backend-go && make dev`。\n- 构建运行：\n  - 根目录 `make run` / `make build`（先构建前端再编译后端）。\n  - 后端本地构建：`cd backend-go && make build-local`。\n- 测试：`cd backend-go && make test`（或 `make test-cover` 生成覆盖率）。\n- 前端：`cd frontend && bun install` 后 `bun run dev|build|type-check`。\n- Docker：`docker-compose up -d` 默认拉取镜像；本地构建请按 `docker-compose.yml` 注释说明启用 `build`（可选 `Dockerfile_China`）。\n\n## 代码风格\n- Go：保持包职责单一、接口清晰；修改后运行 `go fmt ./...`。\n- 前端：遵循现有 Vuetify/Tailwind/Prettier 风格；TypeScript 保持 strict。\n- 配置/密钥：`.env`/`.json` 只提交示例文件（`*.example`），禁止提交真实密钥。\n\n## 测试规范\n- 新增/修改后端逻辑尽量补 `_test.go`，优先表驱动 + `httptest`。\n- 前端目前无测试框架；如增加复杂逻辑再引入轻量单测。\n\n## 安全与配置提示\n- 部署前必须设置强 `PROXY_ACCESS_KEY`；生产环境建议关闭详细请求/响应日志。\n- 代理端点统一鉴权（Header `x-api-key` / `Authorization: Bearer`）；生产环境不建议使用 query `key`。\n"
  },
  {
    "path": "ARCHITECTURE.md",
    "content": "# 项目架构与设计\n\n本文档详细介绍 Claude / Codex / Gemini API Proxy 的架构设计、技术选型和实现细节。\n\n## 项目结构\n\n项目采用一体化架构，Go 后端嵌入前端构建产物，实现单二进制部署：\n\n```\nclaude-proxy/\n├── backend-go/              # Go 后端服务（主程序）\n│   ├── main.go             # 程序入口\n│   ├── internal/           # 内部实现\n│   │   ├── config/        # 配置管理\n│   │   ├── handlers/      # HTTP 处理器\n│   │   ├── middleware/    # 中间件\n│   │   ├── providers/     # 上游服务适配器\n│   │   ├── converters/    # Responses API 协议转换器\n│   │   ├── scheduler/     # 多渠道调度器\n│   │   ├── session/       # 会话管理\n│   │   └── metrics/       # 渠道指标监控\n│   ├── .config/           # 运行时配置\n│   │   ├── config.json    # 主配置文件\n│   │   └── backups/       # 配置备份 (保留最近10个)\n│   └── .env               # 环境变量\n├── frontend/               # Vue 3 + Vuetify 前端\n│   ├── src/\n│   │   ├── components/    # Vue 组件\n│   │   ├── services/      # API 服务\n│   │   └── styles/        # 样式文件\n│   ├── public/            # 静态资源\n│   └── dist/              # 构建产物（嵌入到 Go 二进制）\n├── Makefile               # 构建和开发命令\n├── docker-compose.yml     # Docker 部署配置\n└── Dockerfile             # 容器镜像定义\n```\n\n## 核心技术栈\n\n### 后端 (backend-go/)\n\n- **运行时**: Go 1.22+\n- **框架**: Gin Web Framework\n- **配置管理**: fsnotify (热重载) + godotenv\n- **前端嵌入**: Go `embed.FS`\n- **并发模型**: 原生 Goroutine\n- **性能优势**:\n  - 启动时间: < 100ms (vs Node.js 2-3s)\n  - 内存占用: ~20MB (vs Node.js 50-100MB)\n  - 部署包大小: ~15MB (vs Node.js 200MB+)\n\n### 前端 (frontend/)\n\n- **框架**: Vue 3 (Composition API)\n- **UI 组件库**: Vuetify 3\n- **UI 主题**: 复古像素 (Neo-Brutalism)\n- **构建工具**: Vite\n- **状态管理**: Vue Composition API\n- **HTTP 客户端**: Fetch API\n\n### 构建系统\n\n- **包管理器**: Bun (推荐) / npm / pnpm\n- **构建工具**: Makefile + Shell Scripts\n- **跨平台编译**: 支持 Linux/macOS/Windows, amd64/arm64\n\n## 模块索引\n\n| 模块           | 路径                              | 职责                        |\n| -------------- | --------------------------------- | --------------------------- |\n| **后端核心**   | `backend-go/`                     | API 代理、协议转换、配置管理 |\n| **前端界面**   | `frontend/`                       | Web 管理界面、渠道配置       |\n| **提供商适配** | `backend-go/internal/providers/`  | 上游服务协议转换            |\n| **配置系统**   | `backend-go/internal/config/`     | 配置文件管理和热重载        |\n| **HTTP 处理**  | `backend-go/internal/handlers/`   | REST API 路由和业务逻辑     |\n| **中间件**     | `backend-go/internal/middleware/` | 认证、日志、CORS            |\n| **会话管理**   | `backend-go/internal/session/`    | Responses API 会话跟踪      |\n| **调度器**     | `backend-go/internal/scheduler/`  | 多渠道智能调度              |\n| **指标管理**   | `backend-go/internal/metrics/`    | 渠道健康度和性能指标        |\n\n## 设计模式\n\n### 1. 提供商模式 (Provider Pattern)\n\n所有上游 AI 服务都实现统一的 `Provider` 接口，实现协议转换：\n\n```go\ntype Provider interface {\n    // 将 Claude 格式请求转换为上游格式\n    ConvertRequest(claudeRequest *ClaudeRequest) (*UpstreamRequest, error)\n\n    // 将上游响应转换为 Claude 格式\n    ConvertResponse(upstreamResponse *UpstreamResponse) (*ClaudeResponse, error)\n\n    // 处理流式响应\n    StreamResponse(upstream io.Reader, downstream io.Writer) error\n}\n```\n\n**已实现的提供商**:\n- `OpenAI`: 支持 OpenAI API 和兼容 API\n- `Gemini`: Google Gemini API\n- `Claude`: Anthropic Claude API (直接透传)\n- `Responses`: Codex Responses API (支持会话管理)\n- `OpenAI Old`: 旧版 OpenAI API 兼容\n\n### 2. 配置管理器模式\n\n`ConfigManager` 负责配置的生命周期管理：\n\n```go\ntype ConfigManager struct {\n    config     *Config\n    configPath string\n    watcher    *fsnotify.Watcher\n    mu         sync.RWMutex\n}\n\n// 核心功能\nfunc (cm *ConfigManager) Load() error\nfunc (cm *ConfigManager) Save() error\nfunc (cm *ConfigManager) Watch() error\nfunc (cm *ConfigManager) GetNextAPIKey(channelID string) (string, error)\n```\n\n**特性**:\n- 配置文件热重载 (无需重启服务)\n- 自动备份机制 (保留最近 10 个版本)\n- 线程安全的读写操作\n- API 密钥轮询策略\n\n### 3. 会话管理模式 (Session Manager)\n\n为 Responses API 提供有状态的多轮对话支持：\n\n```go\ntype SessionManager struct {\n    sessions       map[string]*Session\n    responseMap    map[string]string  // responseID -> sessionID\n    mu             sync.RWMutex\n    expiration     time.Duration\n    maxMessages    int\n    maxTokens      int\n}\n\n// 核心功能\nfunc (sm *SessionManager) GetOrCreateSession(previousResponseID string) (*Session, error)\nfunc (sm *SessionManager) AppendMessage(sessionID string, item ResponsesItem, tokens int)\nfunc (sm *SessionManager) UpdateLastResponseID(sessionID, responseID string)\nfunc (sm *SessionManager) RecordResponseMapping(responseID, sessionID string)\n```\n\n**特性**:\n- 自动会话创建和关联\n- 基于 `previous_response_id` 的会话追踪\n- 限制消息数量（默认 100 条）\n- 限制 Token 总数（默认 100k）\n- 自动过期清理（默认 24 小时）\n- 线程安全的并发访问\n\n**会话流程**:\n1. 首次请求：创建新会话，返回 `response_id`\n2. 后续请求：通过 `previous_response_id` 查找会话\n3. 自动追加用户输入和模型输出\n4. 响应中包含 `previous_id` 链接历史\n\n### 4. 转换器模式 (Converter Pattern) 🆕\n\n**v2.0.5 新增**：为 Responses API 提供统一的协议转换架构。\n\n#### 转换器接口\n\n```go\ntype ResponsesConverter interface {\n    // 将 Responses 请求转换为上游服务格式\n    ToProviderRequest(sess *session.Session, req *types.ResponsesRequest) (interface{}, error)\n\n    // 将上游响应转换为 Responses 格式\n    FromProviderResponse(resp map[string]interface{}, sessionID string) (*types.ResponsesResponse, error)\n\n    // 获取上游服务名称\n    GetProviderName() string\n}\n```\n\n#### 已实现的转换器\n\n| 转换器 | 文件 | 转换方向 |\n|--------|------|----------|\n| `OpenAIChatConverter` | `openai_converter.go` | Responses ↔ OpenAI Chat Completions |\n| `OpenAICompletionsConverter` | `openai_converter.go` | Responses ↔ OpenAI Completions |\n| `ClaudeConverter` | `claude_converter.go` | Responses ↔ Claude Messages API |\n| `ResponsesPassthroughConverter` | `responses_passthrough.go` | Responses ↔ Responses (透传) |\n\n#### 工厂模式\n\n```go\nfunc NewConverter(serviceType string) ResponsesConverter {\n    switch serviceType {\n    case \"openai\":\n        return &OpenAIChatConverter{}\n    case \"claude\":\n        return &ClaudeConverter{}\n    case \"responses\":\n        return &ResponsesPassthroughConverter{}\n    default:\n        return &OpenAIChatConverter{}\n    }\n}\n```\n\n#### 核心转换逻辑\n\n**1. Instructions 字段处理**\n\n```go\n// OpenAI: instructions → messages[0] (role: system)\nif req.Instructions != \"\" {\n    messages = append(messages, map[string]interface{}{\n        \"role\": \"system\",\n        \"content\": req.Instructions,\n    })\n}\n\n// Claude: instructions → system 参数（独立字段）\nif req.Instructions != \"\" {\n    claudeReq[\"system\"] = req.Instructions\n}\n```\n\n**2. 嵌套 Content 数组提取**\n\n```go\nfunc extractTextFromContent(content interface{}) string {\n    // 1. 如果是 string，直接返回\n    if str, ok := content.(string); ok {\n        return str\n    }\n\n    // 2. 如果是 []ContentBlock，提取 input_text/output_text\n    if arr, ok := content.([]interface{}); ok {\n        texts := []string{}\n        for _, c := range arr {\n            if block[\"type\"] == \"input_text\" || block[\"type\"] == \"output_text\" {\n                texts = append(texts, block[\"text\"])\n            }\n        }\n        return strings.Join(texts, \"\\n\")\n    }\n\n    return \"\"\n}\n```\n\n**3. Message Type 区分**\n\n```go\nswitch item.Type {\ncase \"message\":\n    // 新格式：嵌套结构（type=message, role=user/assistant, content=[]ContentBlock）\n    role := item.Role  // 直接从 item.role 获取\n    contentText := extractTextFromContent(item.Content)\n\ncase \"text\":\n    // 旧格式：简单 string（向后兼容）\n    contentStr := extractTextFromContent(item.Content)\n    role := item.Role  // 使用 role 字段，不再依赖 [ASSISTANT] 前缀\n}\n```\n\n#### 架构优势\n\n- **易于扩展** - 新增上游只需实现 `ResponsesConverter` 接口\n- **职责清晰** - 转换逻辑与 Provider 解耦\n- **可测试性** - 每个转换器可独立测试\n- **代码复用** - 公共逻辑提取到 `extractTextFromContent` 等基础函数\n- **统一流程** - 所有上游使用相同的转换流程\n\n#### 使用示例\n\n```go\n// 在 ResponsesProvider 中使用\nconverter := converters.NewConverter(upstream.ServiceType)\nproviderReq, err := converter.ToProviderRequest(sess, &responsesReq)\n```\n\n#### 支持的 Responses API 格式\n\n```json\n{\n  \"model\": \"gpt-4\",\n  \"instructions\": \"You are a helpful assistant.\",  // ✅ 新增\n  \"input\": [\n    {\n      \"type\": \"message\",  // ✅ 新增\n      \"role\": \"user\",     // ✅ 新增\n      \"content\": [\n        {\n          \"type\": \"input_text\",  // ✅ 新增\n          \"text\": \"Hello!\"\n        }\n      ]\n    }\n  ],\n  \"previous_response_id\": \"resp_xxxxx\",\n  \"max_tokens\": 1000\n}\n```\n\n**对比旧格式**：\n\n```json\n{\n  \"model\": \"gpt-4\",\n  \"input\": [\n    {\n      \"type\": \"text\",\n      \"content\": \"Hello!\"  // 简单 string\n    },\n    {\n      \"type\": \"text\",\n      \"content\": \"[ASSISTANT]Hi there!\"  // ❌ 使用前缀 hack\n    }\n  ]\n}\n```\n\n### 5. 多渠道调度模式 (Channel Scheduler) 🆕\n\n**v2.0.11 新增**：智能多渠道调度系统，支持优先级排序、健康检查和故障转移。\n\n#### 核心组件\n\n```go\n// ChannelScheduler 多渠道调度器\ntype ChannelScheduler struct {\n    configManager           *config.ConfigManager\n    messagesMetricsManager  *metrics.MetricsManager\n    responsesMetricsManager *metrics.MetricsManager\n    traceAffinity           *session.TraceAffinityManager\n}\n\n// SelectChannel 选择最佳渠道\nfunc (s *ChannelScheduler) SelectChannel(\n    ctx context.Context,\n    userID string,\n    failedChannels map[int]bool,\n    isResponses bool,\n) (*SelectionResult, error)\n```\n\n#### 调度优先级\n\n调度器按以下优先级选择渠道：\n\n```\n1. Trace 亲和性检查\n   └─ 同一用户优先使用之前成功的渠道\n        ↓ (无亲和记录或渠道不健康)\n2. 健康检查\n   └─ 跳过失败率 >= 50% 的渠道\n        ↓ (渠道健康)\n3. 优先级排序\n   └─ 按 priority 字段排序（数字越小优先级越高）\n        ↓ (所有健康渠道都失败)\n4. 降级选择\n   └─ 选择失败率最低的渠道\n```\n\n#### 渠道状态\n\n```go\ntype UpstreamConfig struct {\n    // ... 其他字段\n    Priority int    `json:\"priority\"` // 优先级（数字越小越高）\n    Status   string `json:\"status\"`   // active | suspended | disabled\n}\n```\n\n| 状态 | 参与调度 | 故障转移 | 说明 |\n|------|----------|----------|------|\n| `active` | ✅ | ✅ | 正常运行 |\n| `suspended` | ✅ | ✅ | 暂停但保留在序列中（被健康检查跳过） |\n| `disabled` | ❌ | ❌ | 备用池，完全不参与 |\n\n> ⚠️ **状态 vs 熔断的区别**：\n> - `status: suspended` 是**配置层面**的状态，需要手动改为 `active` 才能恢复\n> - 运行时熔断（`CircuitBrokenAt`）是**指标层面**的临时保护，15 分钟后自动恢复\n> - 启动时就是 `suspended` 的渠道不会触发自动恢复逻辑\n\n#### 指标管理\n\n```go\n// MetricsManager 渠道指标管理\ntype MetricsManager struct {\n    metrics             map[int]*ChannelMetrics\n    windowSize          int           // 滑动窗口大小（默认 10）\n    failureThreshold    float64       // 失败率阈值（默认 0.5）\n    circuitRecoveryTime time.Duration // 熔断恢复时间（默认 15 分钟）\n}\n\n// 核心方法\nfunc (m *MetricsManager) RecordSuccess(channelIndex int)\nfunc (m *MetricsManager) RecordFailure(channelIndex int)\nfunc (m *MetricsManager) CalculateFailureRate(channelIndex int) float64\nfunc (m *MetricsManager) IsChannelHealthy(channelIndex int) bool\n```\n\n**滑动窗口算法**：\n- 基于最近 N 次请求（默认 10 次）计算失败率\n- 失败率 >= 50% 触发熔断，记录 `CircuitBrokenAt` 时间戳\n- 熔断恢复方式：\n  - **自动恢复**：15 分钟后后台任务自动重置滑动窗口\n  - **成功请求恢复**：任意成功请求立即清除熔断状态\n  - **手动恢复**：通过 API 调用 `Reset()` 重置\n\n#### Trace 亲和性\n\n```go\n// TraceAffinityManager 用户会话亲和性\ntype TraceAffinityManager struct {\n    affinity map[string]*TraceAffinity // key: user_id\n    ttl      time.Duration             // 默认 30 分钟\n}\n\n// 核心方法\nfunc (m *TraceAffinityManager) GetPreferredChannel(userID string) (int, bool)\nfunc (m *TraceAffinityManager) SetPreferredChannel(userID string, channelIndex int)\n```\n\n**工作原理**：\n1. 用户首次请求 → 调度器选择渠道 → 记录亲和关系\n2. 用户后续请求 → 优先使用之前成功的渠道\n3. 渠道不健康或 30 分钟无活动 → 清除亲和记录\n\n#### 调度流程图\n\n```mermaid\ngraph TD\n    A[请求到达] --> B{检查 Trace 亲和}\n    B -->|有亲和| C{渠道健康?}\n    C -->|是| D[使用亲和渠道]\n    C -->|否| E[遍历活跃渠道]\n    B -->|无亲和| E\n    E --> F{渠道健康?}\n    F -->|是| G[选择该渠道]\n    F -->|否| H[跳过，继续遍历]\n    H --> I{还有渠道?}\n    I -->|是| E\n    I -->|否| J[降级选择最佳渠道]\n    D --> K[执行请求]\n    G --> K\n    J --> K\n    K --> L{请求成功?}\n    L -->|是| M[记录成功 + 更新亲和]\n    L -->|否| N[记录失败 + 尝试下一渠道]\n```\n\n### 6. 中间件模式\n\nExpress/Gin 使用中间件架构处理横切关注点：\n\n```go\n// 认证中间件\nfunc AuthMiddleware() gin.HandlerFunc\n\n// 日志记录中间件\nfunc LoggerMiddleware() gin.HandlerFunc\n\n// 错误处理中间件\nfunc ErrorHandler() gin.HandlerFunc\n\n// CORS 中间件\nfunc CORSMiddleware() gin.HandlerFunc\n```\n\n## 数据流图\n\n```mermaid\ngraph TD\n    A[Client Request] --> B[Gin Router]\n    B --> C[Auth Middleware]\n    C --> D[Logger Middleware]\n    D --> E[Route Handler]\n    E --> F[Channel Scheduler]\n    F --> G[Trace Affinity Check]\n    G --> H[Health Check]\n    H --> I[Provider Factory]\n    I --> J[Request Converter]\n    J --> K[Upstream API]\n    K --> L[Response Converter]\n    L --> M[Metrics Recorder]\n    M --> N[Client Response]\n\n    O[Config Manager] --> F\n    P[Metrics Manager] --> H\n    Q[File Watcher] --> O\n```\n\n**Messages API 流程说明**:\n1. 客户端请求到达 Gin 路由器\n2. 通过认证和日志中间件\n3. 路由处理器调用 Channel Scheduler\n4. 调度器检查 Trace 亲和性（优先使用历史成功渠道）\n5. 健康检查过滤不健康渠道\n6. Provider 工厂创建对应的协议转换器\n7. 转换请求格式并发送到上游 API\n8. 接收上游响应并转换回 Claude 格式\n9. 记录指标（成功/失败）并返回给客户端\n\n**Responses API 特殊流程**:\n```mermaid\ngraph TD\n    A[Client Request] --> B[Responses Handler]\n    B --> C[Session Manager]\n    C --> D{检查 previous_response_id}\n    D -->|存在| E[获取现有会话]\n    D -->|不存在| F[创建新会话]\n    E --> G[Responses Provider]\n    F --> G\n    G --> H[上游 API]\n    H --> I[响应转换]\n    I --> J[更新会话历史]\n    J --> K[记录 Response Mapping]\n    K --> L[返回带 response_id 的响应]\n```\n\n**Responses API 会话管理**:\n1. 检查请求中的 `previous_response_id`\n2. 如存在，通过 `responseMap` 查找对应的会话\n3. 如不存在，创建新的会话 ID\n4. 将用户输入追加到会话历史\n5. 发送请求到上游 Responses API\n6. 将模型输出追加到会话历史\n7. 更新会话的 `last_response_id`\n8. 记录 `response_id` → `session_id` 映射\n9. 返回响应，包含 `id` (当前) 和 `previous_id` (上一轮)\n\n## 技术选型决策\n\n### 前端资源嵌入方案\n\n#### 实现对比\n\n**当前方案**:\n```go\n//go:embed frontend/dist/*\nvar frontendFS embed.FS\n\nfunc ServeStaticFiles(r *gin.Engine) {\n    // API 路由优先处理\n    r.NoRoute(func(c *gin.Context) {\n        path := c.Request.URL.Path\n\n        // 检测 API 路径\n        if isAPIPath(path) {\n            c.JSON(404, gin.H{\"error\": \"API endpoint not found\"})\n            return\n        }\n\n        // 尝试读取静态文件\n        fileContent, err := fs.ReadFile(distFS, path[1:])\n        if err == nil {\n            contentType := getContentType(path)\n            c.Data(200, contentType, fileContent)\n            return\n        }\n\n        // SPA 回退到 index.html\n        indexContent, _ := fs.ReadFile(distFS, \"index.html\")\n        c.Data(200, \"text/html; charset=utf-8\", indexContent)\n    })\n}\n```\n\n**关键优势**:\n1. ✅ **单次嵌入**: 只嵌入一次整个目录,避免重复\n2. ✅ **智能文件检测**: 先尝试读取实际文件\n3. ✅ **动态 Content-Type**: 根据扩展名返回正确类型\n4. ✅ **API 路由优先**: API 404 返回 JSON 而非 HTML\n5. ✅ **简洁代码**: 无需自定义 FileSystem 适配器\n\n#### 缓存策略\n\n**已实施**:\n- API 路由返回 JSON 格式 404 错误\n- 静态文件正确的 MIME 类型检测\n\n**待优化**:\n- HTML 文件: `Cache-Control: no-cache, no-store, must-revalidate`\n- 静态资源 (.css, .js, 字体): `Cache-Control: public, max-age=31536000, immutable`\n\n### Go vs TypeScript 重写\n\nv2.0.0 将后端完全重写为 Go 语言:\n\n| 指标            | TypeScript/Bun | Go         | 提升      |\n| --------------- | -------------- | ---------- | --------- |\n| **启动时间**    | 2-3s           | < 100ms    | **20x**   |\n| **内存占用**    | 50-100MB       | ~20MB      | **70%↓**  |\n| **部署包大小**  | 200MB+         | ~15MB      | **90%↓**  |\n| **并发处理**    | 事件循环       | Goroutine  | 原生并发  |\n| **部署依赖**    | Node.js 运行时 | 单二进制   | 零依赖    |\n\n**选择 Go 的原因**:\n- 高性能和低资源占用\n- 单二进制部署,无需运行时\n- 原生并发支持,适合高并发场景\n- 强类型系统和出色的工具链\n\n## 性能优化\n\n### 智能构建缓存\n\nMakefile 实现了智能缓存机制:\n\n```makefile\n.build-marker: $(shell find frontend/src -type f)\n\t@echo \"检测到前端文件变更,重新构建...\"\n\tcd frontend && npm run build\n\t@touch .build-marker\n\nensure-frontend-built: .build-marker\n```\n\n**性能对比**:\n\n| 场景               | 之前   | 现在      | 提升       |\n| ------------------ | ------ | --------- | ---------- |\n| 首次构建           | ~10秒  | ~10秒     | 无变化     |\n| **无变更重启**     | ~10秒  | **0.07秒** | **142x** 🚀 |\n| 有变更重新构建     | ~10秒  | ~8.5秒    | 15%提升    |\n\n### 请求头优化\n\n针对不同上游使用不同的请求头策略:\n\n- **Claude 渠道**: 保留原始请求头 (支持 `anthropic-version` 等)\n- **OpenAI/Gemini 渠道**: 最小化请求头 (仅 `Host` 和 `Content-Type`)\n\n这避免了转发无关头部导致上游 API 拒绝请求的问题。\n\n## 安全设计\n\n### 统一认证架构\n\n所有访问入口受 `PROXY_ACCESS_KEY` 保护:\n\n```go\nfunc AuthMiddleware() gin.HandlerFunc {\n    return func(c *gin.Context) {\n        // 健康检查无需认证\n        if c.Request.URL.Path == \"/health\" {\n            c.Next()\n            return\n        }\n\n        // 验证访问密钥\n        apiKey := c.GetHeader(\"x-api-key\")\n        if apiKey != expectedKey {\n            c.JSON(401, gin.H{\"error\": \"Unauthorized\"})\n            c.Abort()\n            return\n        }\n\n        c.Next()\n    }\n}\n```\n\n**受保护的入口**:\n1. 前端管理界面 (`/`)\n2. 管理 API (`/api/*`)\n3. Messages API (`/v1/messages`)\n4. Responses API (`/v1/responses`)\n\n**公开入口**:\n- 健康检查 (`/health`)\n\n### 敏感信息保护\n\n- API 密钥掩码显示 (仅显示前 8 位和后 4 位)\n- 日志中自动隐藏 `Authorization` 头\n- 配置文件权限控制 (`.config/` 目录)\n\n## 扩展性\n\n### 添加新的上游服务\n\n1. 在 `internal/providers/` 创建新的 provider 文件\n2. 实现 `Provider` 接口\n3. 在 `ProviderFactory` 注册新 provider\n4. 更新配置文件模式\n\n示例:\n```go\n// internal/providers/myapi.go\ntype MyAPIProvider struct{}\n\nfunc (p *MyAPIProvider) ConvertRequest(req *ClaudeRequest) (*UpstreamRequest, error) {\n    // 实现协议转换逻辑\n}\n\n// 在 factory 中注册\nfunc GetProvider(providerType string) Provider {\n    switch providerType {\n    case \"myapi\":\n        return &MyAPIProvider{}\n    // ...\n    }\n}\n```\n\n## 文档资源\n\n- **快速开始**: 参见 [README.md](README.md)\n- **环境配置**: 参见 [ENVIRONMENT.md](ENVIRONMENT.md)\n- **贡献指南**: 参见 [CONTRIBUTING.md](CONTRIBUTING.md)\n- **版本历史**: 参见 [CHANGELOG.md](CHANGELOG.md)\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# 版本历史\n\n> **注意**: v2.0.0 开始为 Go 语言重写版本，v1.x 为 TypeScript 版本\n\n---\n\n## [v2.5.13] - 2026-01-31\n\n### 修复\n\n- **Gemini functionDeclaration parameters 类型修复** - 修复 Gemini API 返回 400 错误的问题\n  - 问题：当 Claude 工具的 `InputSchema` 为 nil、缺少 `type` 字段或缺少 `properties` 字段时，Gemini API 拒绝请求\n  - 新增 `normalizeGeminiParameters()` 辅助函数，确保 parameters schema 符合 Gemini 要求：\n    - `parameters` 必须有 `type: \"object\"` 字段\n    - `parameters` 必须有 `properties` 字段（即使为空对象）\n  - 涉及文件：`backend-go/internal/providers/gemini.go`\n\n---\n\n## [v2.5.12] - 2026-01-30\n\n### 新增\n\n- **渠道置顶/置底功能** - 在渠道编排菜单中新增一键调整渠道位置的操作\n  - 在渠道右侧弹出菜单中添加\"置顶\"和\"置底\"选项\n  - 第一个渠道不显示\"置顶\"，最后一个渠道不显示\"置底\"\n  - 操作后立即保存到后端，复用现有 `saveOrder()` 函数\n  - 解决渠道数量较多时拖拽排序不便的问题\n  - 涉及文件：\n    - `frontend/src/components/ChannelOrchestration.vue` - 添加菜单项和处理函数\n    - `frontend/src/plugins/vuetify.ts` - 添加 `arrow-collapse-up/down` 图标\n\n- **隐式缓存读取推断** - 当上游未明确返回 `cache_read_input_tokens` 但存在显著 token 差异时，自动推断缓存命中\n  - 检测 `message_start` 与 `message_delta` 事件中 `input_tokens` 的差异\n  - 触发条件：差额 > 10% 或差额 > 10000 tokens\n  - 将差额自动填充到 `CacheReadInputTokens` 字段，使 token 统计更准确\n  - **下游转发支持**：推断的 `cache_read_input_tokens` 会写入 `message_delta` 事件并转发给下游客户端\n  - 新增 `StreamContext.MessageStartInputTokens` 字段记录初始 token 数\n  - 新增 `inferImplicitCacheRead()` 函数在流结束时执行推断\n  - 新增 `PatchTokensInEventWithCache()` 函数在修补 token 的同时写入推断的缓存值\n  - **关键修复**：\n    - `message_start` 的 `input_tokens` 不再累积到 `CollectedUsage.InputTokens`，确保差额计算正确\n    - 使用 `originalUsageData` 传递给 `PatchMessageStartInputTokensIfNeeded`，避免误判\n    - Token 修补逻辑增加隐式缓存信号检测，避免覆盖缓存命中场景下的正确低值\n    - 隐式缓存推断在转发前执行，确保下游客户端能收到推断值\n    - 仅当上游事件中不存在 `cache_read_input_tokens` 字段时才写入推断值，避免覆盖上游显式返回的 0 值\n  - 涉及文件：\n    - `backend-go/internal/handlers/common/stream.go` - 核心逻辑实现\n    - `backend-go/internal/handlers/common/stream_test.go` - 单元测试（15 个边界场景）\n\n---\n\n## [v2.5.10] - 2026-01-26\n\n### 新增\n\n- **删除渠道时自动清理指标数据** - 修复删除渠道后内存和 SQLite 指标数据残留问题\n  - 扩展 `PersistenceStore` 接口，新增按 `metrics_key` 和 `api_type` 批量删除记录的方法\n  - 新增 `MetricsManager.DeleteChannelMetrics()` 方法，支持同时清理内存和持久化数据\n  - 新增 `ChannelScheduler.DeleteChannelMetrics()` 统一删除入口\n  - 修改 `DeleteUpstream` Handler（Messages/Responses/Gemini），删除后自动调用指标清理\n  - SQLite 清理不依赖内存状态，确保即使内存中无数据也能正确清理持久化记录\n  - 删除渠道时同时清理历史 Key 的指标数据\n  - **按 `api_type` 过滤删除**：避免误删其他接口类型（messages/responses/gemini）的指标数据\n  - **分批删除**：每批 500 条，避免触发 SQLite 变量上限（999）导致删除失败\n  - **并发安全**：`flushMu` 互斥锁串行化 flush 与 delete；`asyncFlushWg` 确保 Close 前所有异步 flush 完成\n  - 涉及文件：\n    - `backend-go/internal/metrics/persistence.go` - 接口扩展（新增 apiType 参数）\n    - `backend-go/internal/metrics/sqlite_store.go` - 实现 SQLite 删除逻辑（分批 + api_type 过滤）\n    - `backend-go/internal/metrics/channel_metrics.go` - 新增删除方法，导出 `GenerateMetricsKey()`\n    - `backend-go/internal/scheduler/channel_scheduler.go` - 新增统一删除入口\n    - `backend-go/internal/handlers/*/channels.go` - 删除 Handler 改造\n    - `backend-go/main.go` - 路由注册更新\n\n- **换 Key 后历史数据累计统计** - 修复更换 API Key 后旧 Key 的历史统计数据丢失问题\n  - 新增 `UpstreamConfig.HistoricalAPIKeys` 字段，存储历史 API Key 列表\n  - 更新渠道时自动维护历史 Key 列表：被移除的 Key 进入历史列表，恢复的 Key 从历史列表移除\n  - `Add*APIKey` / `Remove*APIKey` 接口同样维护历史 Key 列表\n  - `ToResponseMultiURL()` 支持聚合历史 Key 指标（只计入总数，不影响实时失败率和熔断判断）\n  - 前端查看渠道统计时，总数包含历史 Key 数据，Key 详情列表只显示当前活跃 Key\n  - 涉及文件：\n    - `backend-go/internal/config/config.go` - 新增 `HistoricalAPIKeys` 字段\n    - `backend-go/internal/config/config_utils.go` - `Clone()` 方法深拷贝历史 Key\n    - `backend-go/internal/config/config_*.go` - 更新渠道时维护历史 Key 列表\n    - `backend-go/internal/metrics/channel_metrics.go` - 聚合逻辑支持历史 Key\n    - `backend-go/internal/handlers/channel_metrics_handler.go` - 传入历史 Key 参数\n    - `backend-go/internal/handlers/gemini/dashboard.go` - 传入历史 Key 参数\n\n---\n\n## [v2.5.9] - 2026-01-24\n\n### 新增\n\n- **前端模型映射智能选择功能** - 优化模型重定向配置体验，支持自动获取上游模型列表\n  - 前端直连上游 `/v1/models` 接口，无需后端代理\n  - 目标模型输入框改为 `v-combobox`，点击时自动获取模型列表\n  - 为每个 API Key 并行检测 models 接口状态，提高效率\n  - 在 API 密钥列表中实时显示状态标签：\n    - 成功：绿色标签显示 `models 200 (N 个)`\n    - 失败：红色标签显示 `models 错误码`，鼠标悬停显示详细错误消息\n    - 加载中：蓝色标签显示 `检测中...`\n  - 智能错误解析，支持上游标准错误格式 `{ \"error\": { \"message\": \"...\", \"code\": \"...\" } }`\n  - 合并所有成功的模型列表并去重，提供完整的模型选项\n  - 涉及文件：\n    - `frontend/src/services/api.ts` - 新增 `fetchUpstreamModels` 函数和 `buildModelsURL` 工具函数\n    - `frontend/src/components/AddChannelModal.vue` - 优化交互体验和状态管理\n\n---\n\n## [v2.5.8] - 2026-01-21\n\n### 修复\n\n- **客户端取消请求误计入失败** - 修复用户主动取消请求被错误计入渠道失败指标的问题\n  - 新增 `isClientSideError` 函数，使用 `errors.Is` 正确识别被包装的 `context.Canceled` 错误\n  - 仅识别明确的客户端取消（`context.Canceled`），连接故障（`broken pipe`、`connection reset`）继续 failover\n  - 统一口径：`SendRequest` 和 `handleSuccess` 路径均应用客户端取消判断\n  - 新增 `RecordRequestFinalizeClientCancel` 方法，客户端取消时仅计入总请求数，不计入失败数和失败率\n  - 客户端取消不重置 `ConsecutiveFailures`，保留真实的连续失败计数\n  - 涉及文件：\n    - `backend-go/internal/handlers/common/upstream_failover.go` - 错误类型判断与分流\n    - `backend-go/internal/metrics/channel_metrics.go` - 新增客户端取消记录方法\n    - `backend-go/internal/handlers/common/client_error_test.go` - 单元测试\n\n- **指标二次计数 Bug** - 修复 `RecordRequestFinalize*` fallback 路径导致的请求计数重复问题\n  - 将 `RequestCount++` 从 `RecordRequestConnected` 移至 `RecordRequestFinalize*` 阶段\n  - 采用延迟计数策略：连接时预写历史记录，完成时统一计数\n  - 确保 fallback 路径（requestID 丢失/索引越界）不会触发二次计数\n  - 涉及文件：`backend-go/internal/metrics/channel_metrics.go`\n\n### 重构\n\n- **指标记录架构优化** - 将指标记录职责从 handler 层下沉到 failover 层，实现\"连接即计数\"的实时统计\n  - 新增 `RecordRequestConnected` / `RecordRequestFinalizeSuccess` / `RecordRequestFinalizeFailure` 三阶段记录机制\n  - TCP 建连时即计入活跃请求数，响应完成后回写成功/失败与 token 数据\n  - 移除 handler 层的 `RecordSuccessWithUsage` / `RecordFailure` 调用，统一由 `upstream_failover.go` 管理\n  - 修改 `HandleSuccessFunc` 签名：返回 `(*types.Usage, error)` 而非 `*types.Usage`，支持流式响应错误处理\n  - 修改 `ProcessStreamEvents` / `HandleStreamResponse` 返回 usage，避免在 stream 层直接记录指标\n  - 新增 `pendingHistoryIdx` 映射表，支持请求 ID 到历史记录索引的快速查找\n  - 新增 `cleanupHistoryLocked` 函数，清理过期历史记录时同步修正索引\n  - 涉及文件：\n    - `backend-go/internal/handlers/common/stream.go` - 移除指标记录，返回 usage\n    - `backend-go/internal/handlers/common/upstream_failover.go` - 三阶段指标记录\n    - `backend-go/internal/handlers/messages/handler.go` - 移除指标记录调用\n    - `backend-go/internal/handlers/responses/handler.go` - 移除指标记录调用\n    - `backend-go/internal/handlers/gemini/handler.go` - 移除指标记录调用\n    - `backend-go/internal/metrics/channel_metrics.go` - 新增三阶段记录 API\n\n## [v2.5.6] - 2026-01-20\n\n### 修复\n\n- **Gemini CLI 工具调用签名兼容** - 修复多轮工具调用中签名字段位置/命名不一致导致上游返回 400 的问题（启用 `injectDummyThoughtSignature` 时会为缺失签名的 `functionCall` 注入 dummy）。\n- **Gemini CLI tools schema 兼容** - 支持 `parametersJsonSchema` 并在转发前清洗不兼容字段（`$schema` / `additionalProperties` / `const`），避免上游 400。\n- **Gemini Dashboard stripThoughtSignature 字段缺失** - Dashboard API 补齐 `stripThoughtSignature` 字段，避免配置在刷新后丢失。\n\n- **Gemini 渠道 stripThoughtSignature 字段无法保存** - 修复前端无法正确显示和保存\"移除 Thought Signature\"配置的问题\n  - 修复 `GetUpstreams` 函数返回数据中缺失 `stripThoughtSignature` 字段\n  - 修复前端图标显示问题（将 `mdi-signature-freehand` 改为 `mdi-close-circle`）\n  - 统一图标和开关颜色为 `error` 红色，与\"移除\"操作语义一致\n  - 涉及文件：\n    - `backend-go/internal/handlers/gemini/channels.go` - 添加缺失字段\n    - `frontend/src/components/AddChannelModal.vue` - 修复图标和颜色\n\n### 新增\n\n- **Gemini API thought_signature 兼容性方案** - 新增 `stripThoughtSignature` 配置项，支持兼容旧版 Gemini API\n  - 新增 `StripThoughtSignature` 配置字段（布尔值），用于移除 `thought_signature` 字段\n  - 实现 `stripThoughtSignatures()` 函数，移除所有 functionCall 的 thought_signature 字段\n  - 配置优先级：`StripThoughtSignature` > `InjectDummyThoughtSignature`\n  - 保持深拷贝机制，避免多渠道 failover 时污染后续请求\n  - 前端添加\"移除 Thought Signature\"开关（仅 Gemini 渠道显示）\n  - 涉及文件：\n    - `backend-go/internal/config/config.go` - 配置结构定义\n    - `backend-go/internal/config/config_gemini.go` - 配置更新逻辑\n    - `backend-go/internal/handlers/gemini/handler.go` - 请求处理逻辑\n    - `backend-go/internal/handlers/gemini/handler_test.go` - 单元测试\n    - `frontend/src/components/AddChannelModal.vue` - 前端开关\n    - `frontend/src/services/api.ts` - 类型定义\n\n## [v2.5.5] - 2026-01-19\n\n## [v2.5.4] - 2026-01-19\n\n### 重构\n\n- **Failover 逻辑模块化** - 将多渠道和单上游 failover 逻辑提取到公共模块，大幅减少代码重复\n  - 新增 `backend-go/internal/handlers/common/multi_channel_failover.go` - 多渠道 failover 外壳逻辑\n  - 新增 `backend-go/internal/handlers/common/upstream_failover.go` - 单上游 Key/BaseURL 轮转逻辑\n  - 重构 Messages、Responses、Gemini 三个 handler，使用统一的 failover 函数\n  - 代码行数减少：-1253 行，+475 行（净减少 778 行）\n  - 涉及文件：\n    - `backend-go/internal/handlers/messages/handler.go`\n    - `backend-go/internal/handlers/responses/handler.go`\n    - `backend-go/internal/handlers/gemini/handler.go`\n    - `backend-go/internal/scheduler/channel_scheduler.go`\n\n## [v2.5.3] - 2026-01-19\n\n### 修复\n\n- **Models API 日志标签修正** - 修正 Models API 相关日志标签，确保正确区分 Messages 和 Responses 渠道\n  - 修正 `models.go` 中 `tryModelsRequest` 和 `fetchModelsFromChannel` 函数的日志标签\n  - 使用动态 `channelType` 变量替代硬编码的 `\"Messages\"` 字符串\n  - 日志标签格式统一为 `[Messages-Models]` 或 `[Responses-Models]`\n  - 涉及文件：`backend-go/internal/handlers/messages/models.go`\n- **多渠道 failover 客户端取消检测** - 在 failover 循环中添加客户端断开检测，避免客户端已取消请求后继续尝试其他渠道\n  - 在每次渠道选择前检查 `c.Request.Context().Done()`\n  - 客户端断开时立即返回，不再进行无效的渠道 failover\n  - 涉及文件：\n    - `backend-go/internal/handlers/gemini/handler.go` - Gemini API 处理器\n    - `backend-go/internal/handlers/messages/handler.go` - Messages API 处理器\n    - `backend-go/internal/handlers/responses/handler.go` - Responses API 处理器\n\n### 新增\n\n- **响应 model 字段改写可配置化** - 新增环境变量 `REWRITE_RESPONSE_MODEL` 控制是否改写响应中的 model 字段\n  - 默认值：`false`（保持上游返回的原始 model）\n  - 启用后：当上游返回的 model 与请求的 model 不一致时，自动改写为请求的 model\n  - 适用范围：仅影响 Messages API 的流式响应，不影响 Responses API 和 Gemini API\n  - 涉及文件：\n    - `backend-go/.env.example` - 添加配置说明和默认值\n    - `backend-go/internal/config/env.go` - 添加 `RewriteResponseModel` 配置字段\n    - `backend-go/internal/handlers/common/stream.go` - 修改 `PatchMessageStartEvent` 函数，仅在配置启用时改写 model 字段\n\n## [v2.5.2] - 2026-01-19\n\n### 新增\n\n- **Gemini thought_signature 可配置化** - 新增渠道级配置开关 `injectDummyThoughtSignature`\n  - 新增 `ensureThoughtSignatures` 函数：为所有缺失 `thought_signature` 的 `functionCall` 注入 dummy 值\n  - 使用官方推荐的 `skip_thought_signature_validator` 跳过验证\n  - **默认关闭**：保持原样，符合官方 Gemini API 标准\n  - **用户可开启**：为需要该字段的第三方 API 注入 dummy signature\n  - 前端 UI：在 Gemini 渠道编辑界面添加\"注入 Dummy Thought Signature\"开关\n  - 涉及文件：\n    - `backend-go/internal/config/config.go` - 添加 `InjectDummyThoughtSignature` 配置字段\n    - `backend-go/internal/config/config_gemini.go` - 更新方法支持新字段\n    - `backend-go/internal/config/config_messages.go` - 更新方法支持新字段\n    - `backend-go/internal/handlers/gemini/handler.go` - 根据配置决定是否调用 `ensureThoughtSignatures`\n    - `backend-go/internal/types/gemini.go` - 新增共享常量 `DummyThoughtSignature`\n    - `backend-go/internal/converters/gemini_converter.go` - 使用共享常量\n    - `frontend/src/services/api.ts` - 添加类型定义\n    - `frontend/src/components/AddChannelModal.vue` - 添加配置开关 UI\n    - `frontend/src/plugins/vuetify.ts` - 添加 `mdi-signature` 图标映射\n  - 配置优化：将 `.ccb_config/` 目录加入 `.gitignore`，避免泄露本机路径等敏感信息\n\n- **codex-review 技能 v2.1.0** - 新增自动暂存新增文件功能，避免 codex 审核时报 P1 错误\n  - 新增步骤 2：在审核前自动暂存所有新增文件\n  - 使用安全的 `git ls-files -z | while read` 命令，正确处理特殊文件名（空格、换行、以 `-` 开头）\n  - 修复空列表问题：当没有新增文件时安全跳过，不会报错\n  - 优化元数据：添加 `user-invocable: true` 和 `context: fork` 字段\n  - 优化描述：添加触发关键词，移除 `(user)` 后缀\n  - 更新完整审核协议：增加 `[PREPARE] Stage Untracked Files` 步骤\n  - 创建 Plugin Marketplace 配置：`.claude-plugin/marketplace.json`\n  - 创建详细文档：`.claude/skills/codex-review/README.md`\n  - 涉及文件：`.claude/skills/codex-review/SKILL.md`, `.claude-plugin/marketplace.json`, `.claude/skills/codex-review/README.md`\n\n### 优化\n\n- **渠道活跃度图表颜色优化** - 状态条柱状图颜色改为显示每个 6 秒段的独立成功率\n  - 修改 SVG 渐变定义：为每个柱子单独定义渐变色（`gradient-${channelIndex}-${i}`）\n  - 重构 `getActivityBars` 函数：为每个 6 秒时间段计算独立的成功率并分配颜色\n  - 颜色规则（7 档分级）：\n    - 深红色（0-5%）：极端故障\n    - 红色（5-20%）：严重失败\n    - 深橙色（20-40%）：高失败率\n    - 橙色（40-60%）：中等失败率\n    - 黄色（60-80%）：轻微失败\n    - 黄绿色（80-95%）：良好\n    - 绿色（95-100%）：优秀\n  - 效果：用户可以更清晰地看到每个时间段的健康状况，颜色变化更细腻\n  - 性能优化：新增 `activityBarsCache` 计算属性缓存柱状图数据，避免重复计算\n  - 代码清理：删除未使用的 `activityColorCache` 和 `getActivityColor` 函数\n  - 涉及文件：`frontend/src/components/ChannelOrchestration.vue`\n\n- **修复 Dashboard 切换 Tab 时数据闪烁问题** - 将 Dashboard 数据改为按 API 类型独立缓存\n  - 重构 `channelStore`：将单一全局 `dashboardMetrics`/`dashboardStats`/`dashboardRecentActivity` 改为按 Tab（messages/responses/gemini）独立缓存的 `dashboardCache` 结构\n  - 新增 `currentDashboardMetrics`、`currentDashboardStats`、`currentDashboardRecentActivity` 计算属性，根据当前 Tab 返回对应缓存数据\n  - 切换 Tab 时直接显示该 Tab 的缓存数据，避免显示其他 Tab 的旧数据导致闪烁\n  - 涉及文件：`frontend/src/stores/channel.ts`、`frontend/src/views/ChannelsView.vue`\n\n### 重构\n\n- **前端系统状态管理重构** - 将 App.vue 中的系统级状态迁移到 SystemStore\n  - 新增 `src/stores/system.ts` 系统状态 Store，统一管理系统运行状态、版本信息、Fuzzy 模式加载状态\n  - 重构 `src/App.vue`，移除本地系统状态变量（systemStatus、versionInfo、isCheckingVersion、fuzzyModeLoading、fuzzyModeLoadError），改用 SystemStore 统一管理\n  - 更新 `src/stores/index.ts`，导出 SystemStore\n  - 新增 2 个计算属性：systemStatusText、systemStatusDesc\n  - 新增 8 个状态管理方法：setSystemStatus、setVersionInfo、setCurrentVersion、setCheckingVersion、setFuzzyModeLoading、setFuzzyModeLoadError、resetSystemState\n  - 优势：\n    - 状态集中：所有系统级状态统一管理，避免分散在组件中\n    - 代码简化：App.vue 系统状态逻辑更清晰，减少本地状态管理\n    - 可复用性：其他组件可直接使用 SystemStore 的系统状态\n    - 易维护：系统状态变更集中在 Store 中，便于调试和扩展\n  - 涉及文件：`frontend/src/stores/system.ts`、`frontend/src/stores/index.ts`、`frontend/src/App.vue`\n\n- **前端对话框状态管理重构** - 将 App.vue 中的对话框状态迁移到 DialogStore\n  - 新增 `src/stores/dialog.ts` 对话框状态 Store，统一管理添加/编辑渠道对话框和添加 API 密钥对话框\n  - 重构 `src/App.vue`，移除本地对话框状态变量（showAddChannelModal、showAddKeyModalRef、editingChannel、selectedChannelForKey、newApiKey），改用 DialogStore 统一管理\n  - 更新 `src/stores/index.ts`，导出 DialogStore\n  - 新增 6 个状态管理方法：openAddChannelModal、openEditChannelModal、closeAddChannelModal、openAddKeyModal、closeAddKeyModal、resetDialogState\n  - 优势：\n    - 状态集中：所有对话框相关状态统一管理，避免分散在组件中\n    - 代码简化：App.vue 对话框逻辑更清晰，减少本地状态管理\n    - 可复用性：其他组件可直接使用 DialogStore 的对话框状态\n    - 易维护：对话框状态变更集中在 Store 中，便于调试和扩展\n  - 涉及文件：`frontend/src/stores/dialog.ts`、`frontend/src/stores/index.ts`、`frontend/src/App.vue`\n\n- **前端偏好设置管理重构** - 将 App.vue 中的用户偏好设置迁移到 PreferencesStore\n  - 新增 `src/stores/preferences.ts` 偏好设置 Store，统一管理暗色模式、Fuzzy 模式、全局统计面板状态\n  - 重构 `src/App.vue`，移除本地偏好设置变量（darkModePreference、fuzzyModeEnabled、showGlobalStats），改用 PreferencesStore 统一管理\n  - 更新 `src/stores/index.ts`，导出 PreferencesStore\n  - 支持自动持久化到 localStorage（使用 pinia-plugin-persistedstate）\n  - 优势：\n    - 状态集中：所有用户偏好设置统一管理，避免分散在组件中\n    - 自动持久化：用户设置自动保存到本地存储，刷新页面后保持\n    - 代码简化：App.vue 偏好设置逻辑更清晰，减少本地状态管理\n    - 可复用性：其他组件可直接使用 PreferencesStore 的偏好设置\n  - 涉及文件：`frontend/src/stores/preferences.ts`、`frontend/src/stores/index.ts`、`frontend/src/App.vue`\n\n- **前端认证状态管理重构** - 将 App.vue 中的认证相关状态迁移到 AuthStore\n  - 扩展 `src/stores/auth.ts`，新增认证 UI 状态管理（authError、authAttempts、authLockoutTime、isAutoAuthenticating、isInitialized、authLoading、authKeyInput）\n  - 重构 `src/App.vue`，移除本地认证状态变量，改用 AuthStore 统一管理\n  - 新增 `isAuthLocked` 计算属性，自动判断认证锁定状态\n  - 新增 8 个状态管理方法：setAuthError、incrementAuthAttempts、resetAuthAttempts、setAuthLockout、setAutoAuthenticating、setInitialized、setAuthLoading、setAuthKeyInput\n  - 优势：\n    - 状态集中：所有认证相关状态统一管理，避免分散在组件中\n    - 代码简化：App.vue 认证逻辑更清晰，减少本地状态管理\n    - 可复用性：其他组件可直接使用 AuthStore 的认证状态\n    - 安全性增强：认证失败次数和锁定时间集中管理，便于扩展\n  - 涉及文件：`frontend/src/stores/auth.ts`、`frontend/src/App.vue`\n\n- **前端渠道管理逻辑重构** - 将 App.vue 中的渠道管理逻辑提取到 Pinia Store\n  - 新增 `src/stores/channel.ts` 渠道状态 Store，统一管理三种 API 类型（Messages/Responses/Gemini）的渠道数据\n  - 重构 `src/App.vue`，移除 300+ 行本地状态和业务逻辑，改用 ChannelStore 统一管理\n  - 更新 `src/stores/index.ts`，导出 ChannelStore\n  - 优势：\n    - 代码解耦：App.vue 从 1000+ 行减少到 700+ 行，职责更清晰\n    - 状态集中：渠道数据、指标、自动刷新定时器统一管理\n    - 可复用性：其他组件可直接使用 ChannelStore，无需通过 props 传递\n    - 可测试性：业务逻辑独立于组件，便于单元测试\n  - 涉及文件：`frontend/src/stores/channel.ts`、`frontend/src/stores/index.ts`、`frontend/src/App.vue`\n\n- **前端状态管理架构升级** - 引入 Pinia 状态管理库，替代原有的本地状态管理\n  - 新增 `pinia` 和 `pinia-plugin-persistedstate` 依赖，实现响应式状态管理和自动持久化\n  - 新增 `src/stores/auth.ts` 认证状态 Store，统一管理 API Key 和认证状态\n  - 重构 `src/services/api.ts`，从 AuthStore 获取 API Key，移除本地状态管理逻辑\n  - 重构 `src/App.vue`，使用 AuthStore 替代 `isAuthenticated` 本地状态，简化认证流程\n  - 更新 `src/main.ts`，初始化 Pinia 和持久化插件\n  - 配置 `tsconfig.json` 路径别名 `@/*`，支持模块化导入\n  - 优势：响应式状态管理、自动持久化、更好的类型推断、代码解耦\n  - 涉及文件：`frontend/package.json`、`frontend/src/stores/auth.ts`、`frontend/src/services/api.ts`、`frontend/src/App.vue`、`frontend/src/main.ts`、`frontend/tsconfig.json`\n\n---\n\n## [v2.4.34] - 2026-01-17\n\n### 新增\n\n- **会话管理增强** - 支持 Gemini API 的 `X-Gemini-Api-Privileged-User-Id` 请求头\n  - 在 `ExtractConversationID()` 函数中新增对该请求头的支持，用于会话亲和性管理\n  - 优先级顺序：Conversation_id > Session_id > X-Gemini-Api-Privileged-User-Id > prompt_cache_key > metadata.user_id\n  - 涉及文件：`backend-go/internal/handlers/common/request.go`\n\n### 优化\n\n- **Gemini Dashboard API 性能优化** - 将前端 3 个独立请求合并为 1 个后端统一接口\n  - 新增 `/api/gemini/channels/dashboard` 端点，一次性返回 channels、metrics、stats、recentActivity 数据\n  - 后端新增 `internal/handlers/gemini/dashboard.go` 处理器，减少网络往返次数\n  - 涉及文件：`backend-go/main.go`、`backend-go/internal/handlers/gemini/dashboard.go`\n\n### 重构\n\n- **前端 UI 框架统一** - 移除 Tailwind CSS 和 DaisyUI，完全使用 Vuetify\n  - 从 package.json 移除 tailwindcss、daisyui、autoprefixer、postcss 依赖\n  - 删除 tailwind.config.js 和 postcss.config.js 配置文件\n  - 更新 src/assets/style.css，移除 @tailwind 指令，保留自定义样式\n  - 优势：消除多框架样式冲突、减少打包体积、统一设计语言（Material Design）\n  - 涉及文件：`frontend/package.json`、`frontend/src/assets/style.css`、`frontend/src/main.ts`\n\n---\n\n## [v2.4.33] - 2026-01-17\n\n### 新增\n\n- **渠道实时活跃度可视化** - 在渠道列表中显示最近 15 分钟的活跃度数据\n  - 后端新增 `GetRecentActivityMultiURL()` 方法，按 **6 秒粒度**分段统计请求量、成功/失败数、Token 消耗（共 150 段）\n  - **支持多 URL 和多 Key 聚合**：自动聚合渠道所有故障转移 URL 和所有活跃 API Key 的数据，提供完整的渠道活跃度视图\n  - Dashboard API 返回 `recentActivity` 字段，包含每个渠道的 150 段活跃度数据\n  - 前端渠道行显示 RPM/TPM 指标，**背景波形柱状图**实时反映活跃度变化（整体颜色根据全局失败率着色：绿色=成功率≥80%，橙色=成功率≥50%，红色=成功率<50%）\n  - 柱状图每 2 秒自动更新，用户调用 API 后立即看到柱子\"跳动\"，提供直观的脉冲式活跃度展示\n  - 涉及文件：`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`\n\n---\n\n## [v2.4.32] - 2026-01-14\n\n### ✨ 新增\n\n- **Gemini 渠道支持 thinking 模式函数调用签名传递** - `GeminiFunctionCall` 结构体新增 `ThoughtSignature` 字段\n  - 用于 thinking 模式下的签名，需原样传回上游\n  - 涉及文件：`backend-go/internal/types/gemini.go`\n\n### 🔧 优化\n\n- **Gemini 渠道添加模态框增强** - 扩展服务类型和模型选项\n  - 服务类型新增 OpenAI 和 Claude 选项，支持更多上游协议\n  - 更新 Gemini 模型列表：新增 gemini-2、gemini-2.5-flash-lite、gemini-2.5-flash-image、TTS 预览模型、gemini-3 系列预览模型\n  - 涉及文件：`frontend/src/components/AddChannelModal.vue`\n\n### 🐛 修复\n\n- **修复快速输入解析器冒号分隔导致 URL 被截断的问题** - 增强 `extractTokens()` 函数支持冒号作为分隔符，同时保护 URL 完整性\n  - 新增 URL 占位符机制：先提取完整 URL 并替换为占位符，分割后再恢复\n  - 支持中文标点分隔符：逗号（，）、分号（；）、冒号（：）\n  - 涉及文件：`frontend/src/utils/quickInputParser.ts`\n\n---\n\n## [v2.4.31] - 2026-01-12\n\n### 🐛 修复\n\n- **修复流式工具调用输出稳定性和合并逻辑** - 增强 `stream_synthesizer.go` 的工具调用处理\n  - 工具调用输出按 index 排序，避免 map 遍历顺序不稳定导致日志顺序随机\n  - 修复 ID 生成错误：`string(rune(index))` 改为 `strconv.Itoa(index)`，避免非 ASCII 字符\n  - 合并逻辑增强：仅合并连续 index 的工具调用，防止误合并不相关调用\n  - 新增 ID 匹配检查：合并时验证两个 block 的 ID 一致（或其中一个为空）\n  - 支持 ID 补全：合并时若 curr 无 ID 但 next 有，自动补全\n  - 涉及文件：`backend-go/internal/utils/stream_synthesizer.go`\n\n---\n\n## [v2.4.30] - 2026-01-10\n\n### 🐛 修复\n\n- **修复流式响应工具调用分裂问题** - 当上游返回的工具调用被意外分成两个 content_block 时自动合并\n  - 问题场景：第一个 block 有 name 和 id 但参数为空 \"{}\"，第二个 block 没有 name 但有完整参数\n  - 新增 `mergeSplitToolCalls()` 方法检测并合并分裂的工具调用\n  - 在 `GetSynthesizedContent()` 中调用，确保日志输出正确的工具调用信息\n  - 涉及文件：`backend-go/internal/utils/stream_synthesizer.go`\n\n---\n\n## [v2.4.29] - 2026-01-10\n\n### 🐛 修复\n\n- **修复空 signature 字段导致 Claude API 400 错误** - 客户端可能发送带空 `signature` 字段（空字符串或 null）的请求，Claude API 会拒绝并返回 400 错误\n  - 新增 `RemoveEmptySignatures()` 函数，定向移除 `messages[*].content[*].signature` 路径下的空值\n  - 使用 `json.Decoder` 保留数字精度，`SetEscapeHTML(false)` 保持原始格式\n  - **注意**：当请求体被修改时，JSON 字段顺序可能发生变化（不影响 API 语义）\n  - 在 Messages Handler 入口处调用预处理，确保请求发送前清理无效字段\n  - 涉及文件：`backend-go/internal/handlers/common/request.go`、`backend-go/internal/handlers/messages/handler.go`\n\n### ✨ 改进\n\n- **增强 Trace 亲和性日志记录** - 在关键操作点添加详细日志，方便排查亲和性相关问题\n  - `[Affinity-Set]` 记录新建/变更用户亲和\n  - `[Affinity-Remove]` 记录手动移除用户亲和\n  - `[Affinity-RemoveByChannel]` 记录渠道移除时批量清理\n  - `[Affinity-Cleanup]` 记录定时清理过期记录\n  - 日志在锁外执行，避免高负载下的尾延迟\n  - 用户 ID 分级脱敏：短 ID 也保留部分字符便于关联\n  - 涉及文件：`backend-go/internal/session/trace_affinity.go`\n\n## [v2.4.28] - 2026-01-07\n\n### 🐛 修复\n\n- **修复内容审核错误导致无限重试问题** - 当上游返回 `sensitive_words_detected` 等内容审核错误时，单渠道场景下会无限重试\n  - 根因：`classifyByStatusCode(500)` 触发 failover，但未检查 `error.code` 字段中的不可重试错误码\n  - 新增 `isNonRetryableErrorCode()` 函数，检测内容审核和无效请求错误码\n  - 新增 `isNonRetryableError()` 函数，从响应体提取并检测不可重试错误\n  - 在 `shouldRetryWithNextKeyNormal()` 和 `shouldRetryWithNextKeyFuzzy()` 入口处优先检测\n  - 不可重试错误码：`sensitive_words_detected`、`content_policy_violation`、`content_filter`、`content_blocked`、`moderation_blocked`、`invalid_request`、`invalid_request_error`、`bad_request`\n  - 涉及文件：`backend-go/internal/handlers/common/failover.go`\n\n### 🧪 测试\n\n- **新增不可重试错误码测试** - 覆盖 `sensitive_words_detected` 等错误码在 Normal/Fuzzy 模式下的行为\n  - 涉及文件：`backend-go/internal/handlers/common/failover_test.go`\n\n## [v2.4.27] - 2026-01-05\n\n### 🐛 修复\n\n- **修复多端点 failover 渠道统计丢失问题** - 当渠道配置多个 `baseUrls` 时，请求路由到非主 URL 后指标无法正确聚合到渠道统计\n  - 根因：指标存储使用 `hash(baseURL + apiKey)` 作为键，但查询方法只使用主 BaseURL\n  - 新增 4 个多 URL 聚合方法：`GetHistoricalStatsMultiURL`、`GetChannelKeyUsageInfoMultiURL`、`GetKeyHistoricalStatsMultiURL`、`calculateAggregatedTimeWindowsMultiURL`\n  - `ToResponseMultiURL` 按 API Key 去重聚合，避免同一 Key 在多 URL 场景下产生重复条目\n  - Handler 层全部改用 `upstream.GetAllBaseURLs()` 获取所有 URL 进行聚合\n  - 涉及文件：`backend-go/internal/metrics/channel_metrics.go`、`backend-go/internal/handlers/channel_metrics_handler.go`\n\n## [v2.4.26] - 2026-01-05\n\n### 🐛 修复\n\n- **修复 Key 趋势图切换时间范围后不刷新问题** - 持久化 view/duration 选择到 localStorage，使用 requestId 防止自动刷新旧响应覆盖新选择\n  - 涉及文件：`frontend/src/components/KeyTrendChart.vue`\n\n- **修复 KeyTrendChart SSR 兼容性和健壮性问题**\n  - 添加 `isLocalStorageAvailable()` 检查，防止 SSR 环境下访问 localStorage 崩溃\n  - 为 localStorage 读写操作添加 try/catch 异常捕获（配额超限、隐私模式等场景）\n  - 添加 `channelType` prop 变化监听，切换渠道类型时自动重载偏好设置并刷新数据\n  - 优化 channelType watcher 逻辑，避免与 duration watcher 重复触发刷新\n  - 涉及文件：`frontend/src/components/KeyTrendChart.vue`\n\n- **修复缓存创建统计缺失问题** - 当上游仅返回 TTL 细分字段（5m/1h）时，兜底汇总为 cacheCreationTokens\n  - 涉及文件：`backend-go/internal/metrics/channel_metrics.go`\n\n- **透传缓存 TTL 细分字段到指标层** - Responses 非流式/流式 usage 现在包含 CacheCreation5m/1h + CacheTTL\n  - 涉及文件：`backend-go/internal/handlers/responses/handler.go`\n\n### 🧪 测试\n\n- **新增 TTL 细分字段兜底测试** - 覆盖 cache_creation_input_tokens 为 0 时的汇总场景\n  - 涉及文件：`backend-go/internal/metrics/channel_metrics_cache_stats_test.go`\n\n## [v2.4.25] - 2026-01-04\n\n### 🧪 测试\n\n- **新增 baseUrl/baseUrls 一致性测试套件** - 覆盖 URL 配置的完整场景，防止编辑渠道时数据不一致问题回归\n  - `TestUpdateUpstream_BaseURLConsistency`: 验证 Messages 渠道更新时 baseUrl/baseUrls 的一致性（4 场景）\n  - `TestUpdateResponsesUpstream_BaseURLConsistency`: 验证 Responses 渠道更新一致性\n  - `TestUpdateGeminiUpstream_BaseURLConsistency`: 验证 Gemini 渠道更新一致性\n  - `TestGetAllBaseURLs_Priority`: 验证 URL 获取优先级逻辑（4 场景）\n  - `TestGetEffectiveBaseURL_Priority`: 验证有效 URL 选择逻辑（3 场景）\n  - `TestDeduplicateBaseURLs`: 验证 URL 去重逻辑（7 场景，含末尾斜杠/井号差异）\n  - `TestAddUpstream_BaseURLDeduplication`: 验证添加渠道时的 URL 去重\n  - 涉及文件：`internal/config/config_baseurl_test.go`（新增 414 行）\n\n### 🐛 修复\n\n- **修复历史分桶边界导致边界点漏算** - 历史统计 API 的时间过滤条件从开区间 `(startTime, endTime)` 改为半开区间 `[startTime, endTime)`，避免恰好落在 startTime 的记录被遗漏\n  - 涉及文件：`internal/metrics/channel_metrics.go`\n\n- **修复历史图表时间戳错位** - 将返回的 Timestamp 从\"桶结束时间\"改为\"桶起始时间\"，前端图表不再出现一格偏差\n  - 涉及文件：`internal/metrics/channel_metrics.go`\n\n- **修复成功计数可能重复记录** - 移除多渠道/单渠道成功路径上多余的 `RecordSuccess()` 调用，统一使用 `RecordSuccessWithUsage()` 作为唯一成功计数入口\n  - Messages 路径：移除重复调用，保留流式/非流式末尾的 `RecordSuccessWithUsage`\n  - Responses compact 路径：改用 `RecordSuccessWithUsage(nil)` 替代原 `RecordSuccess`，保持指标一致性\n  - 涉及文件：`internal/handlers/messages/handler.go`、`internal/handlers/responses/compact.go`\n\n- **修复多 BaseURL 故障转移时成功指标归属错误** - 当请求通过 fallback BaseURL 成功时，成功指标错误地记录到主 BaseURL 而非实际成功的 URL\n  - 根本原因：`handleNormalResponse` 和 `HandleStreamResponse` 接收的是原始 `upstream` 而非设置了 `currentBaseURL` 的 `upstreamCopy`\n  - 修复方式：将两处调用点的参数从 `upstream` 改为 `upstreamCopy`\n  - 影响范围：多渠道/单渠道的流式与非流式响应处理\n  - 涉及文件：`internal/handlers/messages/handler.go`\n\n---\n\n## [v2.4.24] - 2026-01-04\n\n### ✨ 新功能\n\n- **缓存命中率统计** - 按 Token 口径展示各渠道缓存读/写与命中率：\n  - 后端：在 `timeWindows` 聚合统计中新增 `inputTokens`/`outputTokens`/`cacheCreationTokens`/`cacheReadTokens`/`cacheHitRate` 字段\n  - 命中率定义：`cacheReadTokens / (cacheReadTokens + inputTokens) * 100`\n  - 前端：渠道编排列表在 15 分钟有请求时额外显示缓存命中率，tooltip 中按 15m/1h/6h/24h 展示缓存统计\n  - 新字段均为 `omitempty`，向后兼容\n\n### 🎨 优化\n\n- **调整渠道指标显示间距** - 优化缓存命中率 chip 与请求数之间的间距，避免布局拥挤\n\n---\n\n## [v2.4.23] - 2026-01-03\n\n### ✨ 新功能\n\n- **lowQuality 模式输出完整的 token 验证过程日志** - 启用低质量渠道时，日志会显示完整的验证过程：\n  - 偏差 > 5% 时显示修补详情\n  - 偏差 ≤ 5% 时显示保留上游值\n  - 上游返回无效值时显示本地估算值\n\n### 🐛 修复\n\n- **修复渠道列表 API 未返回 `lowQuality` 字段** - 在 `GetUpstreams` 和 `GetChannelDashboard` 函数返回的 JSON 中补充 `lowQuality` 字段：\n  - 之前前端编辑渠道时无法正确显示已保存的\"低质量渠道\"开关状态\n  - 涉及文件：`handlers/messages/channels.go`、`handlers/responses/channels.go`、`handlers/gemini/channels.go`、`handlers/channel_metrics_handler.go`\n\n---\n\n## [v2.4.22] - 2026-01-02\n\n### ✨ 新功能\n\n- **低质量渠道处理机制** - 新增 `lowQuality` 渠道配置选项，用于处理返回不完整数据的上游渠道：\n  - Token 偏差检测：启用后对比上游返回值与本地估算值，偏差 > 5% 时使用本地估算值\n  - Model 一致性检查：验证响应中的 model 是否与请求一致，不一致则改写为请求的 model\n  - 空 ID 补全：自动补全上游返回的空 `message.id`（生成 `msg_<uuid>` 格式）\n  - 前端支持：渠道编辑 modal 新增\"低质量渠道\"开关\n\n### 🐛 修复\n\n- **暂停渠道时自动清除促销期** - 当用户暂停一个正在抢优先级的渠道时，自动清除其 `promotionUntil` 字段：\n  - 避免暂停后仍显示促销期标识\n  - 涉及三个渠道类型：Messages、Responses、Gemini\n  - 涉及文件：`config_messages.go`、`config_responses.go`、`config_gemini.go`\n\n- **修复 `lowQuality` 字段更新不持久化的问题** - 在 `UpdateUpstream` 系列函数中补充 `LowQuality` 字段处理：\n  - 之前前端切换\"低质量渠道\"开关后变更不会被保存\n  - 涉及文件：`config_messages.go`、`config_responses.go`、`config_gemini.go`\n\n- **修复渠道列表 API 未返回 `lowQuality` 字段** - 在 `GetUpstreams` 和 `GetChannelDashboard` 函数返回的 JSON 中补充 `lowQuality` 字段：\n  - 之前前端编辑渠道时无法正确显示已保存的\"低质量渠道\"开关状态\n  - 涉及文件：`handlers/messages/channels.go`、`handlers/responses/channels.go`、`handlers/gemini/channels.go`、`handlers/channel_metrics_handler.go`\n\n---\n\n## [v2.4.21] - 2026-01-02\n\n### 🐛 修复\n\n- **修复流式响应 input_tokens 为 nil 时丢失的问题** - 当上游返回的顶层 usage 中 `input_tokens` 为 `nil` 时，之前从 `message.usage` 收集到的有效值无法被修补：\n  - 原因：`patchUsageFieldsWithLog` 和 `checkUsageFieldsWithPatch` 函数中类型断言 `.(float64)` 失败时跳过了修补逻辑\n  - 表现：日志显示 `InputTokens=<nil>` 而非之前收集到的有效值（如 10920）\n  - 修复：在两处函数中新增 `input_tokens == nil` 检测，无论是否有缓存 token 都用收集到的值修补\n  - 涉及文件：`backend-go/internal/handlers/common/stream.go`\n\n---\n\n## [v2.4.18] - 2025-12-31\n\n### 🐛 修复\n\n- **Gemini 日志和 Header 透传改进** - 修复 Gemini 接口的日志显示和请求头处理：\n  - 修复 `contents`/`parts` 字段在日志中不显示的问题\n  - 修复原生 Gemini handler 未透传客户端 Header 的问题\n  - 新增 `compactGeminiContentsArray` 和 `compactGeminiPart` 函数\n  - 涉及文件：`backend-go/internal/utils/json.go`、`backend-go/internal/handlers/gemini/handler.go`\n\n### 🔧 重构\n\n- **Gemini tools 日志简化支持** - 新增 `extractToolNames` 函数支持 Gemini 格式的工具提取：\n  - 支持 Gemini `functionDeclarations` 数组格式\n  - 兼容 Claude 和 OpenAI 格式\n  - 日志中 tools 字段现在统一显示为 `[\"tool1\", \"tool2\", ...]` 格式\n  - 涉及文件：`backend-go/internal/utils/json.go`\n\n- **移除非标准 Gemini API 路由** - 简化 API 端点，仅保留官方格式：\n  - 移除：`POST /v1/models/{model}:generateContent`（非标准简化格式）\n  - 保留：`POST /v1beta/models/{model}:generateContent`（Gemini 官方格式）\n  - 更新前端预览 URL 显示完整路径格式 `/models/{model}:generateContent`\n  - 涉及文件：`backend-go/main.go`、`frontend/src/components/AddChannelModal.vue`\n\n---\n\n## [v2.4.17] - 2025-12-30\n\n### 🐛 修复\n\n- **修复 ModelMapping 导致请求字段丢失** - 解决使用模型重定向时 Claude API 返回 403 的问题：\n  - 原因：`ClaudeRequest` 结构体缺少 `metadata` 字段，JSON 反序列化时该字段被丢弃\n  - 表现：配置 `modelMapping` 后请求被上游拒绝（如 `opus` → `claude-opus-4-5-20251101`）\n  - 修复：在 `ClaudeRequest` 中添加 `Metadata map[string]interface{}` 字段\n  - 涉及文件：`backend-go/internal/types/types.go`\n\n---\n\n## [v2.4.16] - 2025-12-30\n\n### 🐛 修复\n\n- **修复 Gemini 渠道预期请求 URL 预览** - 创建渠道时预览显示正确的 `/v1beta` 路径：\n  - 原问题：Gemini 渠道预览错误显示 `/v1` 而后端实际使用 `/v1beta`\n  - 修复：当 serviceType 为 gemini 时使用 `/v1beta` 作为版本前缀\n  - 涉及文件：`frontend/src/components/AddChannelModal.vue`\n\n---\n\n## [v2.4.15] - 2025-12-30\n\n### 🐛 修复\n\n- **修复 Gemini API 路由注册失败** - 解决 Gin 框架路由 panic 问题：\n  - 原因：Gin 不支持 `:param\\:literal` 格式，即使转义冒号也会被解析为两个通配符\n  - 方案：使用 `*modelAction` 通配符捕获 `model:action` 整体，在 handler 内解析\n  - 涉及文件：`main.go`、`internal/handlers/gemini/handler.go`\n\n### ✨ 新功能\n\n- **Gemini 历史指标 API 完整实现** - 补全 Gemini 模块的历史数据端点：\n  - `GET /api/gemini/channels/metrics/history` - 渠道级别指标历史\n  - `GET /api/gemini/channels/:id/keys/metrics/history` - Key 级别指标历史\n  - `GET /api/gemini/global/stats/history` - 全局统计历史\n  - 涉及文件：`internal/handlers/channel_metrics_handler.go`、`main.go`\n\n- **Gemini 前端管理界面完整实现** - 与 Messages/Responses 功能完全对齐：\n  - 新增 Gemini Tab 切换，支持完整渠道 CRUD、Key 管理、状态/促销设置\n  - KeyTrendChart 和 GlobalStatsChart 组件支持 Gemini 数据展示（移除降级显示）\n  - 涉及文件：`frontend/src/App.vue`、`frontend/src/components/`、`frontend/src/services/api.ts`\n\n---\n\n## [v2.4.14] - 2025-12-29\n\n### ✨ 新功能\n\n- **新增 Gemini API 模块** - 与 `/v1/messages`、`/v1/responses` 同级的完整 Gemini 代理支持：\n  - **代理端点**：`POST /v1/models/{model}:generateContent`（非流式）、`:streamGenerateContent`（流式）\n  - **协议转换**：支持 Gemini 请求转发到 Claude/OpenAI/Gemini 上游，双向转换器自动处理格式差异\n  - **渠道管理 API**：完整 CRUD、API Key 管理、状态/促销设置、指标监控（`/api/gemini/channels/*`）\n  - **多渠道调度**：集成 ChannelScheduler，支持优先级、熔断、Trace 亲和性\n  - **认证方式**：兼容 Gemini 原生格式（`x-goog-api-key` 头、`?key=` 参数）\n  - 涉及文件：`internal/handlers/gemini/`、`internal/converters/gemini_converter.go`、`internal/types/gemini.go`\n\n### 🔧 重构\n\n- **config 包模块化拆分** - 将 1973 行的单文件拆分为 6 个职责清晰的模块：\n  - `config.go`（297 行）：核心类型定义 + 共享方法\n  - `config_loader.go`（384 行）：配置加载、迁移、验证、文件监听\n  - `config_messages.go`（429 行）：Messages 渠道 CRUD\n  - `config_responses.go`（380 行）：Responses 渠道 CRUD\n  - `config_gemini.go`（361 行）：Gemini 渠道 CRUD\n  - `config_utils.go`（183 行）：工具函数（去重、模型重定向、状态辅助）\n  - 遵循单一职责原则，提升代码可维护性\n\n---\n\n## [v2.4.12] - 2025-12-29\n\n### 🐛 修复\n\n- **修复 Responses API 错误消息提取失败的问题** - 解决 upstream_error 字段无法被正确解析：\n  - 扩展 `classifyByErrorMessage` 函数：支持多个消息字段（`message`, `upstream_error`, `detail`）\n  - 支持嵌套对象格式：当 `upstream_error` 为对象时，提取其中的 `message` 字段\n  - 之前仅检查 `error.message` 字段，导致 `{type, upstream_error}` 格式的错误无法被识别\n  - 新增 4 个测试用例覆盖 upstream_error 字符串、嵌套对象、detail 字段等场景\n  - 涉及文件：`internal/handlers/common/failover.go`, `internal/handlers/common/failover_test.go`\n\n---\n\n## [v2.4.11] - 2025-12-29\n\n### 🐛 修复\n\n- **修复 Fuzzy 模式下 403 + 预扣费消息未触发 Key 降级的问题** - 补充 v2.4.10 修复的遗漏场景：\n  - 修改 `shouldRetryWithNextKeyFuzzy` 函数：新增 `bodyBytes` 参数，对非 402/429 状态码检查消息体中的配额关键词\n  - 之前 Fuzzy 模式仅检查状态码（402/429 = quota），不解析消息体，导致 403 + \"预扣费额度失败\" 返回 `isQuotaRelated=false`\n  - 新增 `TestShouldRetryWithNextKey_FuzzyMode_403WithQuotaMessage` 测试用例\n  - 涉及文件：`internal/handlers/common/failover.go`, `internal/handlers/common/failover_test.go`\n\n### 🔧 调试\n\n- **添加 Key 降级调试日志** - 用于追踪 `isQuotaRelated` 值和密钥降级流程：\n  - 在 `ShouldRetryWithNextKey` 调用后记录返回值（statusCode, shouldFailover, isQuotaRelated）\n  - 在密钥标记为配额相关失败时记录日志\n  - 涉及文件：`internal/handlers/messages/handler.go`\n- **改进 .env.example 文档** - 添加日志配置默认值说明（默认启用，需显式设置 false 禁用）\n\n---\n\n## [v2.4.10] - 2025-12-29\n\n### 🐛 修复\n\n- **修复 403 预扣费额度不足的 Key 未被自动降级的问题** - 解决配额不足的密钥始终被优先尝试：\n  - 修改 `shouldRetryWithNextKeyNormal` 逻辑：即使 HTTP 状态码已触发 failover，仍检查消息体确定是否为配额相关错误\n  - 之前 403 状态码直接返回 `isQuotaRelated=false`，跳过消息体解析，导致 `DeprioritizeAPIKey` 未被调用\n  - 新增 \"预扣费\" 关键词到 `quotaKeywords` 列表，确保匹配中文预扣费错误消息\n  - 涉及文件：`internal/handlers/common/failover.go`\n\n---\n\n## [v2.4.9] - 2025-12-27\n\n### 🔧 改进\n\n- **重构 URL 预热机制为非阻塞动态排序** - 解决首次请求延迟 500ms+ 的问题：\n  - 移除阻塞式 ping 预热（`URLWarmupManager`），改用非阻塞的 `URLManager`\n  - 新排序策略：基于实际请求结果动态调整 URL 顺序\n    - 请求成功：重置失败计数，URL 保持/提升位置\n    - 请求失败：增加失败计数，URL 移到末尾\n    - 冷却期机制：失败的 URL 在 30 秒后自动恢复可用\n  - 排序规则：无失败记录优先 > 冷却期已过 > 仍在冷却期\n  - 涉及文件：`warmup/url_manager.go`（新建）、`warmup/url_warmup.go`（删除）、`scheduler/channel_scheduler.go`、`messages/handler.go`、`responses/handler.go`、`main.go`\n\n---\n\n## [v2.4.8] - 2025-12-27\n\n### 🐛 修复\n\n- **修复多端点渠道密钥轮换时的并发竞争问题** - 解决高并发下 BaseURL 被错误修改导致密钥跨渠道混用：\n  - 新增 `UpstreamConfig.Clone()` 深拷贝方法，避免并发修改共享对象\n  - Messages/Responses Handler 改用深拷贝替代临时修改模式\n  - 新增 `MarkWarmupURLFailed()` 方法，请求失败时触发预热缓存失效\n  - HTTP 5xx 和网络超时均会触发预热缓存失效，确保失败端点被重新排序\n  - 涉及文件：`config/config.go`、`messages/handler.go`、`responses/handler.go`、`scheduler/channel_scheduler.go`、`warmup/url_warmup.go`\n\n---\n\n## [v2.4.6] - 2025-12-27\n\n### ✨ 新功能\n\n- **多端点预热排序** - 渠道首次访问前自动 ping 所有端点，按延迟排序：\n  - 新增 `internal/warmup/url_warmup.go` 预热管理器模块\n  - 渠道首次访问时自动并发 ping 所有 BaseURL\n  - 排序策略：成功的端点优先，同类型按延迟从低到高排序\n  - ping 结果缓存 5 分钟，避免频繁测试\n  - 支持并发安全的预热请求去重（多个请求同时触发时只执行一次预热）\n  - Messages 和 Responses API 均支持预热排序\n\n---\n\n## [v2.4.5] - 2025-12-27\n\n### 🔧 改进\n\n- **统一日志前缀规范** - Messages 和 Responses 接口日志标签标准化：\n  - Messages 流式处理日志统一使用 `[Messages-Stream]`、`[Messages-Stream-Token]` 前缀\n  - Responses 流式处理日志保持 `[Responses-Stream]`、`[Responses-Stream-Token]` 前缀\n  - 修复 3 处遗漏前缀的错误日志（`messages/handler.go`、`responses/handler.go`）\n  - 更新 `backend-go/CLAUDE.md` 日志规范文档\n\n---\n\n## [v2.4.4] - 2025-12-27\n\n### ✨ 新功能\n\n- **全局流量和 Token 统计图表** - 新增全局统计可视化功能：\n  - 后端新增 `/api/messages/global/stats/history` 和 `/api/responses/global/stats/history` API\n  - 支持请求数量（成功/失败/总量）和 Token 总量（输入/输出）统计\n  - 前端新增 `GlobalStatsChart.vue` 组件，支持流量/Token 双视图切换\n  - 时间范围支持 1h / 6h / 24h / 今日 多档位切换\n  - 用户偏好（时间范围、视图模式）按 Messages/Responses 分别保存到 localStorage\n  - 以顶部可折叠卡片形式展示，随当前 Tab 自动切换对应 API 类型的统计\n\n- **渠道 Key 趋势图表支持\"今日\"** - KeyTrendChart 新增今日时间范围选项：\n  - 后端 `GetChannelKeyMetricsHistory` 支持 `duration=today` 参数\n  - 前端添加\"今日\"按钮，动态计算从今日 0 点到当前的时长\n\n---\n\n## [v2.4.3] - 2025-12-27\n\n### 🐛 修复\n\n- **Responses API Token 统计修复** - 解决上游无 usage 时本地统计无数据的问题：\n  - 修复 SSE 事件解析格式兼容性：支持 `data:` 和 `data: ` 两种格式（某些上游不带空格）\n  - 修复 `handleSuccess` / `handleStreamSuccess` 不返回 usage 数据的问题\n  - 修复调用点使用 `RecordSuccess` 而非 `RecordSuccessWithUsage` 导致 token 统计未入库\n  - 涉及函数：`checkResponsesEventUsage`、`injectResponsesUsageToCompletedEvent`、`patchResponsesCompletedEventUsage`、`tryChannelWithAllKeys`\n\n---\n\n## [v2.4.2] - 2025-12-26\n\n### 🐛 修复\n\n- **原始请求日志修复** - 修复多渠道模式下原始请求头/请求体日志不显示的问题：\n  - 将 `LogOriginalRequest` 调用移至 Handler 入口处，确保无论单/多渠道模式都只记录一次\n  - 移除单渠道处理函数中重复的日志调用和未使用变量\n  - 同时修复 Messages 和 Responses 两个处理器\n\n### 🧹 清理\n\n- **移除废弃环境变量 `LOAD_BALANCE_STRATEGY`** - 负载均衡策略已迁移至 config.json 热重载配置：\n  - 删除 `env.go` 中 `LoadBalanceStrategy` 字段\n  - 更新 `.env.example`、`docker-compose.yml`、`README.md` 移除相关配置\n  - 更新 `CLAUDE.md` 添加配置方式说明\n\n---\n\n## [v2.4.0] - 2025-12-26\n\n### ✨ 改进\n\n- **渠道编辑表单优化** - 改进 AddChannelModal 用户体验：\n  - 预期请求支持显示所有 BaseURL 端点，而非仅显示首个\n  - 修复 Gemini 类型渠道预期请求显示错误端点的问题（应为 `/generateContent`）\n  - 修复从快速模式切换到详细模式时 BaseURL 输入框为空的问题\n  - 表单字段重排：TLS 验证开关和描述字段移至表单末尾\n  - BaseURL 输入框不再自动修改用户输入，仅在提交时进行去重处理\n  - 调整预期请求区域下方间距，改善视觉效果\n\n- **API Key/BaseURL 策略简化** - 移除过度设计，采用纯 failover 模式：\n  - 删除 `ResourceAffinityManager` 及相关代码（资源亲和性）\n  - 移除 API Key 策略选择（round-robin/random/failover），始终使用优先级顺序\n  - 移除 BaseURL 策略选择，始终使用优先级顺序并在失败时切换\n  - 前端删除策略选择器，简化渠道配置界面\n  - 保留渠道级 Trace 亲和性（TraceAffinityManager）用于会话一致性\n  - 清理遗留无用代码：`requestCount`/`responsesRequestCount` 字段、`EnableStreamEventDedup` 环境变量\n\n### 🐛 修复\n\n- **多 BaseURL failover 失效** - 修复当所有 API Key 在首个 BaseURL 失败后不会切换到下一个 BaseURL 的问题：\n  - 重构 `tryChannelWithAllKeys` 函数，采用嵌套循环遍历所有 BaseURL\n  - 重构 `handleSingleChannel` 函数，单渠道模式也支持多 BaseURL failover\n  - 每个 BaseURL 尝试所有 Key 后，若全部失败则自动切换下一个\n  - 每次切换 BaseURL 时重置失败 Key 列表\n  - 同时修复 Messages 和 Responses 两个处理器\n  - 修复 `GetEffectiveBaseURL()` 优先级：临时设置的 `BaseURL` 字段优先于 `BaseURLs` 数组\n  - 移除废弃代码：`MarkBaseURLFailed()`、`baseURLIndex` 字段\n\n- **SSE 流式事件完整性** - 修复 Claude Provider 流式响应可能在事件边界处截断的问题：\n  - 改用事件缓冲机制，按空行分隔完整 SSE 事件后再转发\n  - 确保 `event:`/`data:`/`id:`/`retry:` 等字段作为整体发送\n  - 处理上游未以空行结尾的边界情况\n\n- **前端延迟测试结果被覆盖** - 修复 ping 延迟值显示几秒后消失的问题：\n  - 新增 `mergeChannelsWithLocalData()` 函数保留本地延迟测试结果\n  - 应用于自动刷新、Tab 切换、手动刷新三处数据更新点\n  - 添加 5 分钟有效期检查，确保过期数据自动清除\n\n---\n\n## [v2.3.11] - 2025-12-26\n\n### 🐛 修复\n\n- **Responses API usage 字段缺失** - 修复当上游服务（OpenAI/Gemini）不返回 usage 信息时，`response.completed` 事件完全不包含 `usage` 字段的问题：\n  - 转换器现在始终生成基础 `usage` 字段（`input_tokens`、`output_tokens`、`total_tokens`），即使值为 0\n  - Handler 检测到 usage 存在后，会用本地 token 估算值替换 0 值\n  - 确保下游客户端始终能获得合理的 token 使用估算\n\n### ✨ 新功能\n\n- **API Key/Base URL 去重** - 前后端全链路自动去重：\n  - 前端详细表单模式输入时自动过滤重复 URL（忽略末尾 `/` 和 `#` 差异）\n  - 后端 AddUpstream/UpdateUpstream 接口添加去重逻辑\n  - 同时覆盖 Messages 和 Responses 渠道\n\n### 🔧 改进\n\n- **API Key 策略推荐调整** - 将默认推荐策略从\"轮询\"改为\"故障转移\"，更符合实际使用场景\n- **延迟测试结果持久显示** - 优化渠道延迟测试体验：\n  - 测试结果直接显示在故障转移序列列表中，不再使用短暂 Toast 通知\n  - 延迟结果保持显示 5 分钟后自动清除\n  - 支持单个渠道测试和批量测试统一行为\n\n---\n\n## [v2.3.10] - 2025-12-25\n\n### ✨ 新功能\n\n- **快速添加支持等号分割** - 输入 `KEY=value` 格式时自动按等号分割，识别 `value` 为 API Key\n- **快速添加支持多 Base URL** - 自动识别输入中所有 HTTP 链接作为 Base URL（最多 10 个）\n- **多 URL 预期请求展示** - 快速添加模式下逐一展示每个 URL 的预期请求地址\n\n---\n\n## [v2.3.9] - 2025-12-25\n\n### ✨ 新功能\n\n- **渠道级 API Key 策略** - 每个渠道可独立配置 API Key 分配策略：\n  - `round-robin`（默认）：轮询分发请求到不同 Key\n  - `random`：随机选择 Key\n  - `failover`：故障转移，优先使用第一个 Key\n  - 单 Key 时自动强制使用 `failover`，UI 显示禁用状态\n- **多 BaseURL 支持** - 单个渠道可配置多个 BaseURL，支持三种策略：\n  - `round-robin`（默认）：轮询分发请求，自动分散负载\n  - `random`：随机选择 URL\n  - `failover`：手动故障转移（需配合外部监控切换）\n- **促销期状态展示** - 渠道列表显示正在\"抢优先级\"的渠道，带火箭图标和剩余时间\n- **延迟测试优化** - 批量测试时直接在列表显示每个渠道的延迟值，颜色根据延迟等级变化（绿/黄/红）\n- **多 URL 延迟测试** - 当渠道配置多个 BaseURL 时，并发测试所有 URL 并显示最快的延迟\n- **资源亲和性** - 记录用户成功使用的 BaseURL 和 API Key 索引，后续请求优先使用相同资源组合，减少不必要的资源切换\n\n---\n\n## [v2.3.8] - 2025-12-24\n\n### 🔨 重构\n\n- **日志输出规范化** - 移除所有 emoji 符号，统一使用 `[Component-Action]` 标签格式，确保跨平台兼容性\n\n---\n\n## [v2.3.7] - 2025-12-24\n\n### 🐛 修复\n\n- **滑动窗口重建逻辑优化** - 服务重启时只从最近 15 分钟的历史记录重建滑动窗口，避免历史失败记录导致渠道长期处于不健康状态\n\n---\n\n## [v2.3.6] - 2025-12-24\n\n### ✨ 新功能\n\n- **快速添加渠道 - API Key 识别增强** - 大幅改进 `quickInputParser` 的密钥识别能力\n  - 新增各平台特定格式支持：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)\n  - 新增宽松兜底规则：常见前缀 (sk/api/key/ut/hf/gsk/cr/ms/r8/pplx) + 任意后缀，支持识别短密钥如 `sk-111`\n  - 新增配置键名排除：全大写下划线分隔格式 (如 `API_TIMEOUT_MS`) 不再被误识别为密钥\n\n### 🐛 修复\n\n- **Claude Code settings.json 解析修复** - 粘贴 Claude Code 配置时，不再将键名 (`ANTHROPIC_AUTH_TOKEN` 等) 误识别为 API 密钥\n\n---\n\n## [v2.3.5] - 2025-12-24\n\n### ✨ 新功能\n\n- **Responses API Token 统计补全** - 为 Responses 接口添加完整的输入输出 Token 统计功能\n  - 非流式响应：自动检测上游是否返回 usage，无 usage 时本地估算，修补虚假值（`input_tokens/output_tokens <= 1`）\n  - 流式响应：累积收集流事件中的文本内容，在 `response.completed` 事件中检测并修补 Token 统计\n  - 新增 `EstimateResponsesRequestTokens`、`EstimateResponsesOutputTokens` 专用估算函数\n  - 支持缓存 Token 细分统计（5m/1h TTL）\n  - 与 Messages API 保持一致的处理逻辑\n\n### 🐛 修复\n\n- **缓存 Token 5m/1h 字段检测完善** - 修复缓存 Token 检测逻辑，同时检测 `cache_creation_5m_input_tokens` 和 `cache_creation_1h_input_tokens` 字段\n- **类型化 ResponsesItem 处理** - `EstimateResponsesOutputTokens` 现支持直接处理 `[]types.ResponsesItem` 类型\n- **total_tokens 零值补全** - 修复当上游返回有效 `input_tokens/output_tokens` 但 `total_tokens` 为 0 时未自动补全的问题（非流式和流式均已修复）\n- **特殊类型 Token 估算回退** - 当 `ResponsesItem` 的 `Type` 为 `function_call`、`reasoning` 等特殊类型时，自动序列化整个结构进行估算\n- **流式 delta 类型扩展** - `extractResponsesTextFromEvent` 现支持更多 delta 事件类型：`output_json.delta`、`content_part.delta`、`audio.delta`、`audio_transcript.delta`\n- **流式缓冲区内存保护** - `outputTextBuffer` 添加 1MB 大小上限，防止长流式响应导致内存溢出\n- **Claude/OpenAI 缓存格式区分** - 新增 `HasClaudeCache` 标志，正确区分 Claude 原生缓存字段（`cache_creation/read_input_tokens`）和 OpenAI 格式（`input_tokens_details.cached_tokens`），避免 OpenAI 格式错误阻止 `input_tokens` 补全\n- **流式缓存标志传播** - 修复 `updateResponsesStreamUsage` 未传播 `HasClaudeCache` 标志的问题，确保流式响应正确识别 Claude 缓存\n\n---\n\n## [v2.3.4] - 2025-12-23\n\n### ✨ 新功能\n\n- **Models API 增强** - `/v1/models` 端点重大改进\n  - 使用调度器按故障转移顺序选择渠道（与 Messages/Responses API 一致）\n  - 同时从 Messages 和 Responses 两种渠道获取模型列表并合并去重\n  - 添加详细日志：渠道名称、脱敏 Key、选择原因\n  - 移除对 Claude 原生渠道的跳过限制（第三方 Claude 代理通常支持 /models）\n  - 移除不常用的 `DELETE /v1/models/:model` 端点\n\n---\n\n## [v2.3.3] - 2025-12-23\n\n### ✨ 新功能\n\n- **Models API 端点支持** - 新增 `/v1/models` 系列端点，转发到上游 OpenAI 兼容服务\n  - `GET /v1/models` - 获取模型列表\n  - `GET /v1/models/:model` - 获取单个模型详情\n  - `DELETE /v1/models/:model` - 删除微调模型\n  - 自动跳过不支持的 Claude 原生渠道，遍历所有上游直到成功或返回 404\n\n---\n\n## [v2.3.2] - 2025-12-23\n\n### ✨ 新功能\n\n- **快速添加渠道自动检测协议类型** - 根据 URL 路径自动选择正确的服务类型\n  - `/messages` → Claude 协议\n  - `/chat/completions` → OpenAI 协议\n  - `/responses` → Responses 协议\n  - `/generateContent` → Gemini 协议\n- **快速添加支持 `%20` 分隔符** - 解析输入时自动将 URL 编码的空格转换为实际空格\n\n---\n\n## [v2.3.1] - 2025-12-22\n\n### ✨ 新功能\n\n- **HTTP 响应头超时可配置** - 新增 `RESPONSE_HEADER_TIMEOUT` 环境变量（默认 60 秒，范围 30-120 秒），解决上游响应慢导致的 `http2: timeout awaiting response headers` 错误\n\n---\n\n## [v2.3.0] - 2025-12-22\n\n### ✨ 新功能\n\n- **快速添加渠道支持引号内容提取** - 支持从双引号/单引号中提取 URL 和 API Key，可直接粘贴 Claude Code 环境变量 JSON 配置格式\n- **SQLite 指标持久化存储** - 服务重启后不再丢失历史指标数据，启动时自动加载最近 24 小时数据\n  - 新增 `METRICS_PERSISTENCE_ENABLED`（默认 true）和 `METRICS_RETENTION_DAYS`（默认 7）配置\n  - 异步批量写入（100 条/批或每 30 秒），WAL 模式高并发，自动清理过期数据\n- **完整的 Responses API Token Usage 统计** - 支持多格式自动检测（Claude/Gemini/OpenAI）、缓存 TTL 细分统计（5m/1h）\n- **Messages API 缓存 TTL 细分统计** - 区分 5 分钟和 1 小时 TTL 的缓存创建统计\n\n### 🔨 重构\n\n- **SQLite 驱动切换为纯 Go 实现** - 从 `go-sqlite3`（CGO）切换为 `modernc.org/sqlite`，简化交叉编译\n\n### 🐛 修复\n\n- **Usage 解析数值类型健壮性** - 支持 `float64`/`int`/`int64`/`int32` 四种数值类型\n- **CachedTokens 重复计算** - `CachedTokens` 仅包含 `cache_read`，不再包含 `cache_creation`\n- **流式响应纯缓存场景 Usage 丢失** - 有任何 usage 字段时都记录\n\n---\n\n## [v2.2.0] - 2025-12-21\n\n### 🔨 重构\n\n- **Handlers 模块重构为同级子包结构** - 将 Messages/Responses API 处理器重构为同级模块，新增 `handlers/common/` 公共包，代码量减少约 180 行\n\n### 🐛 修复\n\n- **Stream 错误处理完善** - 流式传输错误时发送 SSE 错误事件并记录失败指标\n- **CountTokens 端点安全加固** - 应用请求体大小限制\n- **非 failover 错误指标记录** - 400/401/403 等错误正确记录失败指标\n\n---\n\n## [v2.1.35] - 2025-12-21\n\n- **流量图表失败率可视化** - 失败率超过 10% 显示红色背景，Tooltip 显示详情\n\n---\n\n## [v2.1.34] - 2025-12-20\n\n- **Key 级别使用趋势图表** - 支持流量/Token I/O/缓存三种视图，智能 Key 筛选\n- **合并 Dashboard API** - 3 个并行请求优化为 1 个\n\n---\n\n## [v2.1.33] - 2025-12-20\n\n- **Fuzzy Mode 错误处理开关** - 所有非 2xx 错误自动触发 failover\n- **渠道指标历史数据 API** - 支持时间序列图表\n\n---\n\n## [v2.1.25] - 2025-12-18\n\n### ✨ 新功能\n\n- **TransformerMetadata 和 CacheControl 支持** - 转换器元数据保留原始格式信息，实现特性透传\n- **FinishReason 统一映射函数** - OpenAI/Anthropic/Responses 三种协议间双向映射\n- **原始日志输出开关** - `RAW_LOG_OUTPUT` 环境变量，开启后不进行格式化或截断\n\n---\n\n## [v2.1.23] - 2025-12-13\n\n- 修复编辑渠道弹窗中基础 URL 布局和验证问题\n\n---\n\n## [v2.1.31] - 2025-12-19\n\n- **前端显示版本号和更新检查** - 自动检查 GitHub 最新版本\n\n---\n\n## [v2.1.30] - 2025-12-19\n\n- **强制探测模式** - 所有 Key 熔断时自动启用强制探测\n\n---\n\n## [v2.1.28] - 2025-12-19\n\n- **BaseURL 支持 `#` 结尾跳过自动添加 `/v1`**\n\n---\n\n## [v2.1.27] - 2025-12-19\n\n- 移除 Claude Provider 畸形 tool_call 修复逻辑\n\n---\n\n## [v2.1.26] - 2025-12-19\n\n- Responses 渠道新增 `gpt-5.2-codex` 模型选项\n\n---\n\n## [v2.1.24] - 2025-12-17\n\n- Responses 渠道新增 `gpt-5.2`、`gpt-5` 模型选项\n- 移除 openaiold 服务类型支持\n\n---\n\n## [v2.1.23] - 2025-12-13\n\n- 修复 402 状态码未触发 failover 的问题\n- 重构 HTTP 状态码 failover 判断逻辑（两层分类策略）\n\n---\n\n## [v2.1.22] - 2025-12-13\n\n### 🐛 修复\n\n- **流式日志合成器类型修复** - 所有 Provider 的 HandleStreamResponse 都将响应转换为 Claude SSE 格式，日志合成器使用 \"claude\" 类型解析\n- **insecureSkipVerify 字段提交修复** - 修复前端 insecureSkipVerify 为 false 时不提交的问题\n\n---\n\n## [v2.1.21] - 2025-12-13\n\n### 🐛 修复\n\n- **促销渠道绕过健康检查** - 促销渠道现在绕过健康检查直接尝试使用，只有本次请求实际失败后才跳过\n\n---\n\n## [v2.1.20] - 2025-12-12\n\n- 渠道名称支持点击打开编辑弹窗\n\n---\n\n## [v2.1.19] - 2025-12-12\n\n- 修复添加渠道弹窗密钥重复错误状态残留\n- 新增 `/v1/responses/compact` 端点\n\n---\n\n## [v2.1.15] - 2025-12-12\n\n### 🔒 安全加固\n\n- **请求体大小限制** - 新增 `MAX_REQUEST_BODY_SIZE_MB` 环境变量（默认 50MB），超限返回 413\n- **Goroutine 泄漏修复** - ConfigManager 添加 `stopChan` 和 `Close()` 方法释放资源\n- **数据竞争修复** - 负载均衡计数器改用 `sync/atomic` 原子操作\n- **优雅关闭** - 监听 SIGINT/SIGTERM，10 秒超时优雅关闭\n\n---\n\n## [v2.1.14] - 2025-12-12\n\n- 修复流式响应 Token 计数中间更新被覆盖\n\n---\n\n## [v2.1.12] - 2025-12-11\n\n- 支持 Claude 缓存 Token 计数\n\n---\n\n## [v2.1.10] - 2025-12-11\n\n- 修复流式响应 Token 计数补全逻辑\n\n---\n\n## [v2.1.8] - 2025-12-11\n\n- 重构过长方法，提升代码可读性\n\n---\n\n## [v2.1.7] - 2025-12-11\n\n### 🐛 修复\n\n- 修复前端 MDI 图标无法显示\n- **Token 计数补全虚假值处理** - 当 `input_tokens <= 1` 或 `output_tokens == 0` 时用本地估算值覆盖\n\n---\n\n## [v2.1.6] - 2025-12-11\n\n### ✨ 新功能\n\n- **Messages API Token 计数补全** - 当上游不返回 usage 时，本地估算 token 数量并附加到响应中\n\n---\n\n## [v2.1.4] - 2025-12-11\n\n- 修复前端渠道健康度统计不显示数据\n\n---\n\n## [v2.1.1] - 2025-12-11\n\n- 新增 `QUIET_POLLING_LOGS` 环境变量（默认 true），过滤前端轮询日志噪音\n\n---\n\n## [v2.1.0] - 2025-12-11\n\n### 🔨 重构\n\n- **指标系统重构：Key 级别绑定** - 指标键改为 `hash(baseURL + apiKey)`，每个 Key 独立追踪\n- **熔断器生效修复** - 在 `tryChannelWithAllKeys` 中调用 `ShouldSuspendKey()` 跳过熔断的 Key\n- **单渠道路径指标记录** - 转换失败、发送失败、failover、成功时正确记录指标\n\n---\n\n## [v2.0.20-go] - 2025-12-08\n\n- 修复单渠道模式渠道选择逻辑\n\n---\n\n## [v2.0.11-go] - 2025-12-06\n\n### 🚀 多渠道智能调度器\n\n- **ChannelScheduler** - 基于优先级的渠道选择、Trace 亲和性、失败率检测和自动熔断\n- **MetricsManager** - 滑动窗口算法计算实时成功率\n- **TraceAffinityManager** - 用户会话与渠道绑定\n\n### 🎨 渠道编排面板\n\n- 拖拽排序、实时指标、状态切换、备用池管理\n\n---\n\n## [v2.0.10-go] - 2025-12-06\n\n### 🎨 复古像素主题\n\n- Neo-Brutalism 设计语言：无圆角、等宽字体、粗实体边框、硬阴影\n\n---\n\n## [v2.0.5-go] - 2025-11-15\n\n### 🚀 Responses API 转换器架构重构\n\n- 策略模式 + 工厂模式实现多上游转换器\n- 完整支持 Responses API 标准格式\n\n---\n\n## [v2.0.4-go] - 2025-11-14\n\n### ✨ Responses API 透明转发\n\n- Codex Responses API 端点 (`/v1/responses`)\n- 会话管理系统（多轮对话跟踪）\n- Messages API 多上游协议支持（Claude/OpenAI/Gemini）\n\n---\n\n## [v2.0.0-go] - 2025-10-15\n\n### 🎉 Go 语言重写版本\n\n- **性能提升**: 启动速度 20x，内存占用 -70%\n- **单文件部署**: 前端资源嵌入二进制\n- **完整功能移植**: 所有上游适配器、协议转换、流式响应、配置热重载\n\n---\n\n## 历史版本\n\n<details>\n<summary>v1.x TypeScript 版本</summary>\n\n### v1.2.0 - 2025-09-19\n- Web 管理界面、模型映射、渠道置顶、API 密钥故障转移\n\n### v1.1.0 - 2025-09-17\n- SSE 数据解析优化、Bearer Token 处理简化、代码重构\n\n### v1.0.0 - 2025-09-13\n- 初始版本：多上游支持、负载均衡、配置管理\n\n</details>\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## 项目概述\n\nClaude / Codex / Gemini API Proxy - 支持多上游 AI 服务的协议转换代理，提供 Web 管理界面和统一 API 入口。\n\n**技术栈**: Go 1.22 (后端) + Vue 3 + Vuetify (前端) + Docker\n\n## 常用命令\n\n```bash\n# 根目录（推荐）\nmake dev              # Go 后端热重载开发（不含前端）\nmake run              # 构建前端并运行 Go 后端\nmake frontend-dev     # 前端开发服务器\nmake build            # 构建前端并编译 Go 后端\nmake clean            # 清理构建文件\ndocker-compose up -d  # Docker 部署\n\n# Go 后端开发 (backend-go/)\nmake dev              # 热重载开发模式\nmake test             # 运行所有测试\nmake test-cover       # 测试 + 覆盖率报告（生成 coverage.html）\nmake build            # 构建生产版本\nmake lint             # 代码检查（需要 golangci-lint）\nmake fmt              # 格式化代码\nmake deps             # 更新依赖\n\n# 运行特定测试\ngo test -v ./internal/converters/...       # 运行单个包测试\ngo test -v -run TestName ./internal/...    # 运行单个测试\n\n# 前端开发 (frontend/)\nbun install && bun run dev    # 开发服务器\nbun run build                 # 生产构建\n```\n\n## 架构概览\n\n```\nclaude-proxy/\n├── backend-go/                 # Go 后端（主程序）\n│   ├── main.go                # 入口、路由配置\n│   └── internal/\n│       ├── handlers/          # HTTP 处理器 (proxy.go, responses.go, config.go)\n│       ├── providers/         # 上游适配器 (openai.go, gemini.go, claude.go)\n│       ├── converters/        # 协议转换器（工厂模式）\n│       ├── scheduler/         # 多渠道调度器（优先级、熔断）\n│       ├── session/           # 会话管理 + Trace 亲和性\n│       ├── metrics/           # 渠道指标（滑动窗口算法）\n│       ├── config/            # 配置管理（fsnotify 热重载）\n│       └── middleware/        # 认证、CORS、日志过滤\n├── frontend/                   # Vue 3 + Vuetify 前端\n│   └── src/\n│       ├── components/        # Vue 组件\n│       └── services/          # API 服务封装\n└── .config/                    # 运行时配置（热重载）\n```\n\n## 核心设计模式\n\n1. **Provider Pattern** - `internal/providers/`: 所有上游实现统一 `Provider` 接口\n2. **Converter Pattern** - `internal/converters/`: 协议转换，工厂模式创建转换器\n3. **Session Manager** - `internal/session/`: 基于 `previous_response_id` 的多轮对话跟踪\n4. **Scheduler Pattern** - `internal/scheduler/`: 优先级调度、Trace 亲和性、自动熔断\n\n## API 端点\n\n**代理端点**:\n- `POST /v1/messages` - Claude Messages API（支持 OpenAI/Gemini 协议转换）\n- `POST /v1/messages/count_tokens` - Token 计数\n- `POST /v1/responses` - Codex Responses API（支持会话管理）\n- `POST /v1/responses/compact` - 精简版 Responses API\n- `GET /health` - 健康检查（无需认证）\n\n**管理 API** (`/api/`):\n- `/api/messages/channels` - Messages 渠道 CRUD\n- `/api/responses/channels` - Responses 渠道 CRUD\n- `/api/messages/channels/metrics` - 渠道指标\n- `/api/messages/channels/scheduler/stats` - 调度器统计\n- `/api/messages/ping/:id` - 渠道连通性测试\n\n## 关键配置\n\n| 环境变量 | 默认值 | 说明 |\n|---------|--------|------|\n| `PORT` | 3000 | 服务器端口 |\n| `ENV` | production | 运行环境 |\n| `PROXY_ACCESS_KEY` | - | **必须设置** 访问密钥 |\n| `QUIET_POLLING_LOGS` | true | 静默轮询日志 |\n| `MAX_REQUEST_BODY_SIZE_MB` | 50 | 请求体最大大小 |\n\n完整配置参考 `backend-go/.env.example`\n\n## 常见任务\n\n1. **添加新的上游服务**: 在 `internal/providers/` 实现 `Provider` 接口，在 `GetProvider()` 注册\n2. **修改协议转换**: 编辑 `internal/converters/` 中的转换器\n3. **调整调度策略**: 修改 `internal/scheduler/channel_scheduler.go`\n4. **前端界面调整**: 编辑 `frontend/src/components/` 中的 Vue 组件\n\n## 重要提示\n\n- **Git 操作**: 未经用户明确要求，不要执行 git commit/push/branch 操作\n- **配置热重载**: `backend-go/.config/config.json` 修改后自动生效，无需重启\n- **环境变量变更**: 修改 `.env` 后需要重启服务\n- **认证**: 所有端点（除 `/health`）需要 `x-api-key` 头或 `PROXY_ACCESS_KEY`\n\n## Git 命令注意事项\n\n- 执行 `git add`/`git commit` 前确保在项目根目录\n- `git diff` 查看特定文件时使用 `--` 分隔符避免歧义：`git diff -- path/to/file`\n- 错误示例：`git diff frontend/src/file.vue`（可能报 `unknown revision` 错误）\n- 正确示例：`git diff -- frontend/src/file.vue`\n\n## 模块文档\n\n- [backend-go/CLAUDE.md](backend-go/CLAUDE.md) - Go 后端详细文档\n- [frontend/CLAUDE.md](frontend/CLAUDE.md) - Vue 前端详细文档\n- [ARCHITECTURE.md](ARCHITECTURE.md) - 详细架构设计\n- [ENVIRONMENT.md](ENVIRONMENT.md) - 完整环境变量配置\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# 贡献指南\n\n本文档为项目贡献者提供了一套标准化的指导，以确保代码库的一致性和高质量。\n\n## 如何贡献\n\n欢迎通过提交 Issue 和 Pull Request 为本项目贡献力量！\n\n1.  Fork 本项目。\n2.  创建特性分支 (`git checkout -b feature/AmazingFeature`)。\n3.  提交改动 (`git commit -m 'feat: Add some AmazingFeature'`)。\n4.  推送到分支 (`git push origin feature/AmazingFeature`)。\n5.  开启 Pull Request。\n\n## 版本规范\n\n项目遵循 **语义化版本 2.0.0 (Semantic Versioning)** 规范。版本格式为 `主版本号.次版本号.修订号` (MAJOR.MINOR.PATCH)，版本号递增规则如下：\n\n-   **主版本号 (MAJOR)**: 当你做了不兼容的 API 修改。\n-   **次版本号 (MINOR)**: 当你做了向下兼容的功能性新增。\n-   **修订号 (PATCH)**: 当你做了向下兼容的问题修正。\n\n## 发布流程\n\n本节为项目维护者提供了版本发布流程概述。贡献者通常不需要直接执行这些步骤。\n\n1.  **准备工作**:\n    *   确保本地 `main` 分支是最新的。\n    *   确认所有计划内的功能和修复已合并。\n    *   运行类型检查 (`bun run type-check`) 和构建验证 (`bun run build`)。\n2.  **更新日志**: 更新 `CHANGELOG.md`，新增版本标题，并分类记录变更内容。\n3.  **更新版本号**: 更新 `package.json` 中的 `version` 字段。\n4.  **提交**: 提交 `CHANGELOG.md` 和 `package.json` 的修改，提交信息格式为 `chore(release): prepare for vX.Y.Z`。\n5.  **创建标签**: 为此次提交创建附注标签 `git tag -a vX.Y.Z -m \"Release vX.Y.Z\"` 并推送到远程。\n6.  **GitHub Release**: 在 GitHub 上创建 Release，将 `CHANGELOG.md` 中的对应版本内容复制到发布说明中。\n\n## 编码规范\n\n### 设计原则\n\n项目严格遵循以下软件工程原则：\n\n1.  **KISS 原则 (Keep It Simple, Stupid)**: 追求代码和设计的极致简洁，优先选择最直观的解决方案。\n2.  **DRY 原则 (Don't Repeat Yourself)**: 消除重复代码，提取共享函数，统一相似功能的实现方式。\n3.  **YAGNI 原则 (You Aren't Gonna Need It)**: 仅实现当前明确所需的功能，删除未使用的代码和依赖，避免过度设计。\n4.  **函数式编程优先**: 优先使用 `map`、`reduce`、`filter` 等函数式方法和不可变数据操作。\n\n### 代码质量标准\n\n-   使用 TypeScript 严格模式，避免 `any` 类型。\n-   所有函数都有明确的类型声明。\n-   实现优雅的错误处理和日志记录。\n-   遵循 Prettier 格式化（2空格、单引号、无分号、宽度120、LF EOL）。\n\n### 文件命名规范\n\n-   **文件名**: `kebab-case` (例: `config-manager.ts`)\n-   **类名**: `PascalCase` (例: `ConfigManager`)\n-   **Vue 组件名**: `PascalCase` (例: `ChannelCard.vue`)\n-   **类型/接口名**: `PascalCase`\n-   **函数名**: `camelCase` (例: `getNextApiKey`)\n-   **常量名**: `SCREAMING_SNAKE_CASE` (例: `DEFAULT_CONFIG`)\n\n### TypeScript 规范\n\n-   使用严格的 TypeScript 配置。\n-   所有函数和变量都有明确的类型声明。\n-   使用接口定义数据结构。\n-   避免使用 `any` 类型。\n\n## 测试指南\n\n### 开发测试\n\n在提交代码前，请确保：\n-   运行 TypeScript 类型检查：`bun run type-check`\n-   运行构建验证：`bun run build`\n-   通过健康检查端点 (`GET http://localhost:3000/health`) 进行冒烟测试。\n-   对于 UI 变更，在 Pull Request 中包含简短的测试计划和截图/GIF。\n\n### 提交与 Pull Request 指南\n\n-   **Conventional Commits**: 提交信息遵循 `conventional-commits` 规范，例如 `feat:`, `fix:`, `refactor:`, `chore:`。\n    -   示例: `feat(frontend): add ESC to close modal`, `fix(backend): redact Authorization header`。\n-   **PR 内容**: Pull Request 必须包含：\n    -   目的说明\n    -   关联的 Issue (如果有)\n    -   详细的测试步骤\n    -   配置/环境变量变更说明\n    -   UI 变更的截图/GIF\n\n## 安全与配置提示\n\n-   **切勿提交敏感信息**: 永远不要将密钥或敏感配置提交到版本控制中。使用 `.env` 文件和 `backend/config.json` 进行管理。\n-   **访问密钥**: `PROXY_ACCESS_KEY` 是代理访问的必需密钥。避免在日志中记录完整的 API 密钥。\n\n## Agent-Specific Notes\n\n-   保持代码差异最小化，与现有代码风格保持一致。\n-   当行为发生变化时，及时更新相关文档。\n-   除非必要，否则避免进行重命名或大规模重构。\n"
  },
  {
    "path": "DEVELOPMENT.md",
    "content": "# 开发指南\n\n本文档为开发者提供开发环境配置、工作流程、调试技巧和最佳实践。\n\n> 📚 **相关文档**\n> - 架构设计和技术选型: [ARCHITECTURE.md](ARCHITECTURE.md)\n> - 环境变量配置: [ENVIRONMENT.md](ENVIRONMENT.md)\n> - 贡献规范: [CONTRIBUTING.md](CONTRIBUTING.md)\n\n---\n\n## 🎯 推荐开发方式\n\n| 开发方式 | 启动速度 | 热重载 | 适用场景 |\n|---------|---------|-------|---------|\n| **🚀 根目录 Make 命令** | ⚡ 极快 | ✅ 支持 | **推荐：日常开发** |\n| **🔧 backend-go Make** | ⚡ 极快 | ✅ 支持 | Go 后端专项开发 |\n| **🐳 Docker** | 🔄 中等 | ❌ 需重启 | 生产环境测试 |\n\n---\n\n## 方式一：🚀 根目录开发（推荐）\n\n**适合日常开发，自动处理前端构建和后端启动**\n\n### 快速开始\n\n```bash\n# 在项目根目录执行\n\n# 查看所有可用命令\nmake help\n\n# 开发模式（后端热重载）\nmake dev\n\n# 构建前端并运行后端\nmake run\n\n# 前端独立开发服务器\nmake frontend-dev\n\n# 完整构建（前端 + 后端）\nmake build\n\n# 清理构建产物\nmake clean\n```\n\n### 开发环境要求\n\n- Go 1.22+\n- Make（构建工具）\n- Bun（前端构建）\n\n---\n\n## 方式二：🔧 backend-go 目录开发\n\n**适合专注 Go 后端开发和调试**\n\n```bash\ncd backend-go\n\n# 查看所有可用命令\nmake help\n\n# 开发模式（支持热重载）\nmake dev\n\n# 运行测试\nmake test\n\n# 测试 + 覆盖率\nmake test-cover\n\n# 构建当前平台二进制\nmake build-current\n\n# 构建并运行\nmake build-run\n```\n\n---\n\n## 🪟 Windows 环境配置\n\nWindows 用户在开发本项目时可能遇到一些工具缺失的问题，以下是常见问题的解决方案。\n\n### 问题 1: 没有 `make` 命令\n\nWindows 默认不包含 `make` 工具，有以下几种解决方案：\n\n#### 方案 A: 安装 Make (推荐)\n\n```powershell\n# 使用 Chocolatey (推荐)\nchoco install make\n\n# 或使用 Scoop\nscoop install make\n\n# 或使用 winget\nwinget install GnuWin32.Make\n```\n\n#### 方案 B: 直接使用 Go 命令 (无需安装 make)\n\n```powershell\ncd backend-go\n\n# 替代 make dev (需要先安装 air: go install github.com/air-verse/air@latest)\nair\n\n# 替代 make build\ngo build -o claude-proxy.exe .\n\n# 替代 make run\ngo run main.go\n\n# 替代 make test\ngo test ./...\n\n# 替代 make fmt\ngo fmt ./...\n```\n\n### 问题 2: 没有 `vite` 命令\n\n这是因为前端依赖未安装，`vite` 是项目的开发依赖。\n\n#### 解决步骤\n\n```powershell\ncd frontend\n\n# 使用 bun 安装依赖 (推荐)\nbun install\n\n# 或使用 npm\nnpm install\n\n# 安装完成后运行开发服务器\nbun run dev    # 或 npm run dev\n```\n\n### Windows 完整开发环境配置\n\n#### 1. 安装包管理器 (可选但推荐)\n\n```powershell\n# 安装 Scoop (无需管理员权限)\nSet-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser\nirm get.scoop.sh | iex\n\n# 或安装 Chocolatey (需要管理员权限)\nSet-ExecutionPolicy Bypass -Scope Process -Force\n[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072\niex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))\n```\n\n#### 2. 安装开发工具\n\n```powershell\n# 使用 Scoop\nscoop install git go bun make\n\n# 或使用 Chocolatey\nchoco install git golang bun make -y\n```\n\n#### 3. 验证安装\n\n```powershell\ngo version      # 应显示 go1.22+\nbun --version   # 应显示版本号\nmake --version  # 应显示 GNU Make 版本\ngit --version   # 应显示 git 版本\n```\n\n### Windows 快速启动流程\n\n```powershell\n# 1. 克隆项目\ngit clone https://github.com/BenedictKing/claude-proxy\ncd claude-proxy\n\n# 2. 安装前端依赖\ncd frontend\nbun install    # 或 npm install\n\n# 3. 配置环境变量\ncd ../backend-go\ncopy .env.example .env\n# 编辑 .env 文件设置 PROXY_ACCESS_KEY\n\n# 4. 启动后端 (选择以下方式之一)\n\n# 方式 A: 使用 make (如果已安装)\nmake dev\n\n# 方式 B: 直接使用 Go\ngo run main.go\n\n# 5. 另开终端，启动前端开发服务器 (如需单独开发前端)\ncd frontend\nbun run dev\n```\n\n### Windows 常见问题\n\n#### PowerShell 执行策略限制\n\n```powershell\n# 如果遇到脚本执行限制，以管理员身份运行\nSet-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser\n```\n\n#### 端口被占用\n\n```powershell\n# 查看端口占用\nnetstat -ano | findstr :3000\n\n# 终止占用进程 (替换 PID 为实际进程 ID)\ntaskkill /PID <PID> /F\n```\n\n#### 路径包含空格\n\n确保项目路径不包含空格和中文字符，推荐使用如 `C:\\projects\\claude-proxy` 这样的路径。\n\n---\n\n## 方式三：🐳 Docker 开发\n\n**适合测试生产环境或隔离开发**\n\n```bash\n# 使用 docker-compose 启动\ndocker-compose up -d\n\n# 查看日志\ndocker-compose logs -f\n\n# 重新构建并启动\ndocker-compose up -d --build\n\n# 停止服务\ndocker-compose down\n```\n\n---\n\n## 前端独立开发\n\n前端使用 Vue 3 + Vuetify + Vite，可独立开发：\n\n```bash\ncd frontend\n\n# 安装依赖\nbun install\n\n# 启动开发服务器（端口 5173）\nbun run dev\n\n# 构建生产版本\nbun run build\n\n# 预览构建结果\nbun run preview\n```\n\n**开发服务器代理配置**:\n\nVite 开发服务器会自动将 `/api` 和 `/v1` 请求代理到后端（默认 `http://localhost:3000`）：\n\n```typescript\n// frontend/vite.config.ts\nserver: {\n  port: 5173,\n  proxy: {\n    '/api': { target: backendUrl, changeOrigin: true },\n    '/v1': { target: backendUrl, changeOrigin: true }\n  }\n}\n```\n\n**环境变量**:\n- `VITE_PROXY_TARGET` - 后端代理目标（默认 `http://localhost:3000`）\n- `VITE_FRONTEND_PORT` - 前端开发服务器端口（默认 `5173`）\n\n---\n\n## 文件监听策略\n\n### 配置文件（无需重启）\n\n- `backend-go/.config/config.json` - 主配置文件\n\n**变化时**: 自动重载配置，服务保持运行\n\n### 环境变量文件（需要重启）\n\n- `backend-go/.env` - 环境变量配置\n\n**变化时**: 需要重启服务以加载新的环境变量\n\n## 开发模式特性\n\n### 1. 热重载开发 (`make dev`)\n\n- ✅ Go 源码变化自动重新编译\n- ✅ 配置文件变化自动重载（不重启）\n- ✅ 优雅关闭处理\n- ✅ 详细的开发日志\n\n### 2. 配置热重载\n\n- ✅ 配置文件变化自动重载\n- ✅ 无需重启服务器\n- ✅ 自动备份配置（最多 10 个）\n\n---\n\n## 🎯 代码质量标准\n\n> 📚 完整的编码规范和设计模式请参考 [ARCHITECTURE.md](ARCHITECTURE.md)\n\n### 编程原则\n\n项目严格遵循以下软件工程原则：\n\n#### 1. KISS 原则 (Keep It Simple, Stupid)\n- 追求代码和设计的极致简洁\n- 优先选择最直观的解决方案\n- 使用正则表达式替代复杂的字符串处理逻辑\n\n#### 2. DRY 原则 (Don't Repeat Yourself)  \n- 消除重复代码，提取共享函数\n- 统一相似功能的实现方式\n- 例：`normalizeClaudeRole` 函数的提取和共享\n\n#### 3. YAGNI 原则 (You Aren't Gonna Need It)\n- 仅实现当前明确所需的功能\n- 删除未使用的代码和依赖\n- 避免过度设计和未来特性预留\n\n#### 4. 函数式编程优先\n- 使用 `map`、`reduce`、`filter` 等函数式方法\n- 优先使用不可变数据操作\n- 例：命令行参数解析使用 `reduce()` 替代传统循环\n\n### 代码优化检查清单\n\n在提交代码前，请确保：\n\n- [ ] Go 代码通过 `make lint` 检查\n- [ ] 通过 `make test` 测试\n- [ ] 前端代码通过 `bun run build` 构建验证\n\n### Go 代码规范\n\n- 使用 `gofmt` 格式化代码\n- 遵循 Go 官方代码规范\n- 错误处理要完整\n- 适当添加注释\n\n### 命名规范\n\n- **文件名**: snake_case (例: `config_manager.go`)\n- **函数名**: PascalCase 导出 / camelCase 私有 (例: `GetProvider` / `parseRequest`)\n- **常量名**: PascalCase 或 SCREAMING_SNAKE_CASE\n- **接口名**: PascalCase，通常以 -er 结尾 (例: `Provider`)\n\n### 错误处理\n\n```go\nresult, err := riskyOperation()\nif err != nil {\n    log.Printf(\"Operation failed: %v\", err)\n    return fmt.Errorf(\"specific error: %w\", err)\n}\n```\n\n### 日志规范\n\n使用 Go 标准日志或结构化日志：\n\n```go\nlog.Printf(\"🎯 使用上游: %s\", upstream.Name)\nlog.Printf(\"⚠️ 警告: %s\", message)\nlog.Printf(\"💥 错误: %v\", err)\n```\n\n## 🧪 测试策略\n\n### 手动测试\n\n#### 1. 基础功能测试\n\n```bash\n# 测试健康检查\ncurl http://localhost:3000/health\n\n# 测试基础对话\ncurl -X POST http://localhost:3000/v1/messages \\\n  -H \"x-api-key: test-key\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"model\":\"claude-3-5-sonnet-20241022\",\"max_tokens\":100,\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n\n# 测试流式响应\ncurl -X POST http://localhost:3000/v1/messages \\\n  -H \"x-api-key: test-key\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"model\":\"claude-3-5-sonnet-20241022\",\"stream\":true,\"max_tokens\":100,\"messages\":[{\"role\":\"user\",\"content\":\"Count to 10\"}]}'\n```\n\n#### 2. 负载均衡测试\n\n```bash\n# 添加多个 API 密钥\nbun run config key test-upstream add key1 key2 key3\n\n# 设置轮询策略\nbun run config balance round-robin\n\n# 发送多个请求观察密钥轮换\nfor i in {1..5}; do\n  curl -X POST http://localhost:3000/v1/messages \\\n    -H \"x-api-key: test-key\" \\\n    -H \"Content-Type: application/json\" \\\n    -d '{\"model\":\"claude-3-5-sonnet-20241022\",\"max_tokens\":10,\"messages\":[{\"role\":\"user\",\"content\":\"Test '$i'\"}]}'\ndone\n```\n\n### 集成测试\n\n#### Claude Code 集成测试\n\n1. 配置 Claude Code 使用本地代理\n2. 测试基础对话功能\n3. 测试工具调用功能\n4. 测试流式响应\n5. 验证错误处理\n\n#### 压力测试\n\n```bash\n# 使用 ab (Apache Bench) 进行压力测试\nab -n 100 -c 10 -p request.json -T application/json \\\n  -H \"x-api-key: test-key\" \\\n  http://localhost:3000/v1/messages\n```\n\n## 🔧 调试技巧\n\n### 1. 日志分析\n\n```bash\n# 实时查看日志\ntail -f server.log\n\n# 过滤错误日志\ngrep -i \"error\" server.log\n\n# 分析请求模式\ngrep -o \"POST /v1/messages\" server.log | wc -l\n```\n\n### 2. 配置调试\n\n```bash\n# 验证配置文件\ncat config.json | jq .\n\n# 检查环境变量\nenv | grep -E \"(PORT|LOG_LEVEL)\"\n```\n\n### 3. 网络调试\n\n```bash\n# 测试上游连接\ncurl -I https://api.openai.com\n\n# 检查 DNS 解析\nnslookup api.openai.com\n\n# 测试端口连通性\ntelnet localhost 3000\n```\n\n## 🚀 部署指南\n\n### 开发环境部署\n\n```bash\n# 1. 克隆项目\ngit clone https://github.com/BenedictKing/claude-proxy\ncd claude-proxy\n\n# 2. 配置环境变量\ncp backend-go/.env.example backend-go/.env\nvim backend-go/.env\n\n# 3. 启动开发服务器\nmake dev\n```\n\n### 生产环境部署\n\n```bash\n# 1. 构建生产版本\nmake build\n\n# 2. 配置环境变量\ncp backend-go/.env.example backend-go/.env\n# 修改 ENV=production 和 PROXY_ACCESS_KEY\n\n# 3. 运行服务\n./backend-go/dist/claude-proxy\n```\n\n### Docker 部署\n\n```bash\n# 使用预构建镜像\ndocker-compose up -d\n\n# 或本地构建\ndocker-compose build\ndocker-compose up -d\n```\n\n## 🤝 贡献与发布\n\n### 贡献指南\n\n欢迎提交 Issue 和 Pull Request！\n\n> 📚 详细的贡献规范和提交指南请参考 [CONTRIBUTING.md](CONTRIBUTING.md)\n\n### 版本发布\n\n> 📚 维护者版本发布流程请参考 [RELEASE.md](RELEASE.md)\n"
  },
  {
    "path": "Dockerfile",
    "content": "# --- 阶段 1: 准备 Bun 运行时 ---\nFROM oven/bun:alpine AS bun-runtime\n\n# --- 阶段 2: 构建阶段 (Go + Bun) ---\nFROM golang:1.22-alpine AS builder\n\n# 声明 VERSION 构建参数（用于 CI 传入版本号，留空则从 VERSION 文件读取）\nARG VERSION\n\nWORKDIR /src\n\n# 安装必要的构建工具和 bun 依赖（libstdc++ libgcc 是 bun:alpine 运行所需）\nRUN apk add --no-cache git make libstdc++ libgcc\n\n# 从 bun-runtime 复制 bun 和 bunx 到 Go 镜像\nCOPY --from=bun-runtime /usr/local/bin/bun /usr/local/bin/bun\nCOPY --from=bun-runtime /usr/local/bin/bunx /usr/local/bin/bunx\n\n# 将 bun 添加到 PATH\nENV PATH=\"/usr/local/bin:${PATH}\"\n\n# 复制项目必要文件（.dockerignore 会排除不需要的文件）\nCOPY Makefile VERSION ./\nCOPY frontend/ ./frontend/\nCOPY backend-go/ ./backend-go/\n\n# 使用 bun 安装前端依赖（比 npm 快 10-100 倍）\nRUN cd frontend && bun install\n\n# 安装 Go 后端依赖（go mod tidy 确保 go.sum 完整）\nRUN cd backend-go && go mod tidy && go mod download\n\n# 使用 Makefile 构建整个项目（前端 + 后端）\n# 如果 CI 传入了 VERSION 则使用，否则 Makefile 会从 VERSION 文件读取\nRUN if [ -n \"${VERSION}\" ]; then VERSION=${VERSION} make build; else make build; fi\n\n# --- 阶段 3: 运行时 ---\nFROM alpine:latest AS runtime\n\nWORKDIR /app\n\n# 安装运行时依赖\nRUN apk --no-cache add ca-certificates tzdata\n\n# 从构建阶段复制 Go 二进制文件（已内嵌前端资源）\nCOPY --from=builder /src/dist/claude-proxy-go /app/claude-proxy\n\n# 创建配置目录和日志目录\nRUN mkdir -p /app/.config/backups /app/logs\n\n# 设置时区（可选）\nENV TZ=Asia/Shanghai\n\n# 暴露端口\nEXPOSE 3000\n\n# 健康检查\nHEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\\n    CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1\n\n# 启动命令\nCMD [\"/app/claude-proxy\"]\n"
  },
  {
    "path": "Dockerfile_China",
    "content": "# --- 阶段 1: 准备 Bun 运行时 ---\nFROM docker.1ms.run/oven/bun:alpine AS bun-runtime\n\n# --- 阶段 2: 构建阶段 (Go + Bun) ---\nFROM docker.1ms.run/library/golang:1.22-alpine AS builder\n\n# 声明 VERSION 构建参数（用于 CI 传入版本号，留空则从 VERSION 文件读取）\nARG VERSION\n\nWORKDIR /src\n\n# 配置 Go 代理（使用国内镜像）\nENV GOPROXY=https://goproxy.cn,direct\nENV GO111MODULE=on\n\n# 配置国内镜像源并安装构建工具和 bun 依赖（libstdc++ libgcc 是 bun:alpine 运行所需）\nRUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \\\n    apk add --no-cache git make libstdc++ libgcc\n\n# 从 bun-runtime 复制 bun 和 bunx 到 Go 镜像\nCOPY --from=bun-runtime /usr/local/bin/bun /usr/local/bin/bun\nCOPY --from=bun-runtime /usr/local/bin/bunx /usr/local/bin/bunx\n\n# 将 bun 添加到 PATH\nENV PATH=\"/usr/local/bin:${PATH}\"\n\n# 复制项目必要文件（.dockerignore 会排除不需要的文件）\nCOPY Makefile VERSION ./\nCOPY frontend/ ./frontend/\nCOPY backend-go/ ./backend-go/\n\n# 使用 bun 安装前端依赖（使用国内镜像，比 npm 快 10-100 倍）\nRUN cd frontend && bun install --registry https://registry.npmmirror.com\n\n# 安装 Go 后端依赖（go mod tidy 确保 go.sum 完整）\nRUN cd backend-go && go mod tidy && go mod download\n\n# 使用 Makefile 构建整个项目（前端 + 后端）\n# 如果 CI 传入了 VERSION 则使用，否则 Makefile 会从 VERSION 文件读取\nRUN if [ -n \"${VERSION}\" ]; then VERSION=${VERSION} make build; else make build; fi\n\n# --- 阶段 3: 运行时 ---\nFROM docker.1ms.run/library/alpine:latest AS runtime\n\nWORKDIR /app\n\n# 配置国内镜像源并安装运行时依赖\nRUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \\\n    apk --no-cache add ca-certificates tzdata\n\n# 从构建阶段复制 Go 二进制文件（已内嵌前端资源）\nCOPY --from=builder /src/dist/claude-proxy-go /app/claude-proxy\n\n# 创建配置目录和日志目录\nRUN mkdir -p /app/.config/backups /app/logs\n\n# 设置时区\nENV TZ=Asia/Shanghai\n\n# 暴露端口\nEXPOSE 3000\n\n# 健康检查\nHEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\\n    CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1\n\n# 启动命令\nCMD [\"/app/claude-proxy\"]\n"
  },
  {
    "path": "ENVIRONMENT.md",
    "content": "# 环境变量配置指南\n\n## 概述\n\n本项目使用分层的环境变量配置系统，支持开发、生产等不同环境的端口和API配置。前端通过 Vite 的环境变量系统动态连接后端服务。\n\n## 配置文件结构\n\n```\nclaude-proxy/\n├── frontend/\n│   ├── .env                    # 前端默认配置\n│   ├── .env.development        # 开发环境配置\n│   ├── .env.production         # 生产环境配置\n│   └── vite.config.ts          # Vite 构建配置\n└── backend-go/\n    └── .env                    # Go 后端环境配置\n```\n\n## 环境变量详解\n\n### 前端配置变量\n\n#### 开发环境变量\n\n前端使用 Vite，环境变量需以 `VITE_` 前缀：\n\n- `VITE_PROXY_TARGET` - 后端代理目标地址（默认 `http://localhost:3000`）\n- `VITE_FRONTEND_PORT` - 前端开发服务器端口（默认 `5173`）\n- `VITE_BACKEND_URL` - 开发环境后端 URL（用于 API 服务）\n- `VITE_API_BASE_PATH` - API 基础路径（默认 `/api`）\n- `VITE_PROXY_API_PATH` - 代理 API 路径（默认 `/v1`）\n- `VITE_APP_ENV` - 应用环境标识\n\n### 后端配置 (Go)\n\n后端支持以下环境变量：\n\n```bash\n# 服务器配置\nPORT=3000                              # 服务器端口\n\n# 运行环境\nENV=production                         # 运行环境: development | production\n# NODE_ENV=production                  # 向后兼容 (已弃用，请使用 ENV)\n\n# 访问控制\nPROXY_ACCESS_KEY=your-secret-key       # 访问密钥 (必须设置!)\n\n# Web UI\nENABLE_WEB_UI=true                     # 是否启用 Web 管理界面\n\n# 日志配置\nLOG_LEVEL=info                         # 日志级别: debug | info | warn | error\nENABLE_REQUEST_LOGS=true               # 是否记录请求日志\nENABLE_RESPONSE_LOGS=false             # 是否记录响应日志\nQUIET_POLLING_LOGS=true                # 静默前端轮询端点日志（/api/channels 等）\n\n# 性能配置\nREQUEST_TIMEOUT=300000                 # 请求超时时间（毫秒）\nMAX_REQUEST_BODY_SIZE_MB=50            # 请求体最大大小（MB，默认 50）\n\n# CORS 配置\nENABLE_CORS=false                      # 是否启用 CORS\nCORS_ORIGIN=*                          # CORS 允许的源\n\n# 熔断指标配置\nMETRICS_WINDOW_SIZE=10                 # 滑动窗口大小（最小 3，默认 10）\nMETRICS_FAILURE_THRESHOLD=0.5          # 失败率阈值（0-1，默认 0.5 即 50%）\n```\n\n#### 日志等级说明\n\n项目采用标准的四级日志系统，等级从高到低：\n\n| 等级 | 值 | 说明 | 典型场景 |\n|------|----|----|---------|\n| `error` | 0 | 错误日志（最高优先级） | 致命错误、异常情况 |\n| `warn` | 1 | 警告日志 | 非致命问题、降级操作 |\n| `info` | 2 | 信息日志（默认） | 常规操作、状态变化 |\n| `debug` | 3 | 调试日志（最低优先级） | 详细调试信息、敏感数据 |\n\n**等级控制规则**：设置 `LOG_LEVEL=info` 时，会输出 `error`、`warn`、`info` 级别的日志，但不输出 `debug` 级别。\n\n#### 日志控制机制\n\n项目使用三种机制来控制日志输出：\n\n##### 1. 显式等级控制（推荐）\n```go\n// 代码示例\nif envCfg.ShouldLog(\"info\") {\n    log.Printf(\"🎯 使用上游: %s\", upstream.Name)\n}\n```\n- **适用场景**：通用信息输出\n- **控制变量**：`LOG_LEVEL`\n\n##### 2. 开关控制（分类日志）\n```go\n// 代码示例\nif envCfg.EnableRequestLogs {\n    log.Printf(\"📥 收到请求: %s\", c.Request.URL.Path)\n}\n```\n- **适用场景**：请求/响应类日志\n- **控制变量**：`ENABLE_REQUEST_LOGS`、`ENABLE_RESPONSE_LOGS`\n\n##### 3. 环境门控（开发专用）\n```go\n// 代码示例\nif envCfg.EnableRequestLogs && envCfg.IsDevelopment() {\n    log.Printf(\"📄 原始请求体:\\n%s\", formattedBody)\n}\n```\n- **适用场景**：敏感/详细信息（请求体、请求头等）\n- **控制变量**：`ENV=development`\n\n#### 日志输出对照表\n\n| 日志内容 | 控制条件 | 等效等级 | 生产环境 | 开发环境 |\n|---------|---------|---------|---------|---------|\n| `📄 原始请求体` | `EnableRequestLogs && IsDevelopment()` | debug | ❌ 不输出 | ✅ 输出 |\n| `📋 实际请求头` | `EnableRequestLogs && IsDevelopment()` | debug | ❌ 不输出 | ✅ 输出 |\n| `📦 响应体` | `EnableResponseLogs && IsDevelopment()` | debug | ❌ 不输出 | ✅ 输出 |\n| `📥 收到请求` | `EnableRequestLogs` | info | ⚙️ 可配置 | ✅ 输出 |\n| `⏱️ 响应完成` | `EnableResponseLogs` | info | ⚙️ 可配置 | ✅ 输出 |\n| `🎯 使用上游` | `ShouldLog(\"info\")` | info | ⚙️ 可配置 | ✅ 输出 |\n| `ℹ️ 客户端中断` | `ShouldLog(\"info\")` | info | ⚙️ 可配置 | ✅ 输出 |\n| `⚠️ API密钥失败` | 无条件 | warn | ✅ 输出 | ✅ 输出 |\n| `💥 所有密钥失败` | 无条件 | error | ✅ 输出 | ✅ 输出 |\n\n#### 配置组合效果\n\n**开发环境 + 完整日志**：\n```env\nENV=development\nLOG_LEVEL=debug\nENABLE_REQUEST_LOGS=true\nENABLE_RESPONSE_LOGS=true\n```\n- ✅ 输出所有日志，包括完整请求体、请求头、响应体\n- ✅ 适合本地开发调试\n- ⚠️ 可能包含敏感信息，不要在生产环境使用\n\n**生产环境 + 最小日志**：\n```env\nENV=production\nLOG_LEVEL=warn\nENABLE_REQUEST_LOGS=false\nENABLE_RESPONSE_LOGS=false\n```\n- ✅ 只输出警告和错误\n- ✅ 最小性能影响\n- ✅ 不输出敏感信息\n- ⚠️ 排查问题时信息较少\n\n**生产环境 + 适度日志**（推荐）：\n```env\nENV=production\nLOG_LEVEL=info\nENABLE_REQUEST_LOGS=true\nENABLE_RESPONSE_LOGS=false\n```\n- ✅ 输出基本请求信息（如 `📥 收到请求`）\n- ✅ 不输出详细内容（请求体、响应体等）\n- ✅ 平衡了可观测性和性能\n- ✅ 不泄露敏感信息\n\n**调试模式**：\n```env\nENV=development\nLOG_LEVEL=debug\nENABLE_REQUEST_LOGS=true\nENABLE_RESPONSE_LOGS=true\n```\n- ✅ 最详细的日志输出\n- ✅ 查看完整的请求/响应数据流\n- ⚠️ 仅用于故障排查，排查完成后应恢复正常配置\n\n#### 性能影响说明\n\n| 配置 | CPU 影响 | 内存影响 | 磁盘 I/O |\n|-----|---------|---------|----------|\n| `LOG_LEVEL=error` | 极低 | 极低 | 极低 |\n| `LOG_LEVEL=warn` | 极低 | 极低 | 低 |\n| `LOG_LEVEL=info` | 低 | 低 | 中 |\n| `LOG_LEVEL=debug` | 中 | 中 | 高 |\n| `ENABLE_REQUEST_LOGS=true` | 低 | 低 | 中 |\n| `ENABLE_RESPONSE_LOGS=true` | 低-中 | 中-高 | 高 |\n\n**生产环境建议**：\n- 日常运行：`LOG_LEVEL=info`，`ENABLE_RESPONSE_LOGS=false`\n- 故障排查：临时开启 `ENABLE_RESPONSE_LOGS=true`\n- 高负载场景：使用 `LOG_LEVEL=warn` 减少开销\n\n### ENV 变量影响\n\n| 配置项 | `development` | `production` |\n|--------|---------------|--------------|\n| Gin 模式 | DebugMode | ReleaseMode |\n| `/admin/dev/info` | ✅ 开启 | ❌ 关闭 |\n| CORS | 宽松（localhost自动允许）| 严格 |\n| 日志 | 详细 | 最小 |\n\n## 配置文件内容\n\n### frontend/.env\n```env\n# 前端环境配置\n\n# 后端API服务器配置\nVITE_BACKEND_URL=http://localhost:3000\n\n# 前端开发服务器配置\nVITE_FRONTEND_PORT=5173\n\n# API路径配置\nVITE_API_BASE_PATH=/api\nVITE_PROXY_API_PATH=/v1\n```\n\n### frontend/.env.development\n```env\n# 开发环境配置\n\n# 后端API服务器配置\nVITE_BACKEND_URL=http://localhost:3000\n\n# 前端开发服务器配置\nVITE_FRONTEND_PORT=5173\n\n# API路径配置\nVITE_API_BASE_PATH=/api\nVITE_PROXY_API_PATH=/v1\n\n# 开发模式标识\nVITE_APP_ENV=development\n```\n\n### frontend/.env.production\n```env\n# 生产环境配置\nVITE_API_BASE_PATH=/api\nVITE_PROXY_API_PATH=/v1\nVITE_APP_ENV=production\n```\n\n### backend-go/.env.example\n```env\n# 服务器配置\nPORT=3000\n\n# 运行环境\nENV=production\n\n# 访问控制 (必须修改!)\nPROXY_ACCESS_KEY=your-super-strong-secret-key\n\n# Web UI\nENABLE_WEB_UI=true\n\n# 日志配置\nLOG_LEVEL=info\nENABLE_REQUEST_LOGS=false\nENABLE_RESPONSE_LOGS=false\n```\n\n## API 基础URL 生成逻辑\n\n前端通过以下逻辑动态确定API基础URL：\n\n```typescript\nconst getApiBase = () => {\n  // 生产环境：直接使用当前域名\n  if (import.meta.env.PROD) {\n    return '/api'\n  }\n\n  // 开发环境：使用配置的后端URL\n  const backendUrl = import.meta.env.VITE_BACKEND_URL\n  const apiBasePath = import.meta.env.VITE_API_BASE_PATH || '/api'\n\n  if (backendUrl) {\n    return `${backendUrl}${apiBasePath}`\n  }\n\n  // 回退到默认配置\n  return '/api'\n}\n```\n\n## 开发服务器代理配置\n\nVite 开发服务器自动配置代理，将前端请求转发到后端：\n\n```typescript\n// vite.config.ts\nserver: {\n  port: Number(env.VITE_FRONTEND_PORT) || 5173,\n  proxy: {\n    '/api': {\n      target: backendUrl,\n      changeOrigin: true,\n      secure: false\n    }\n  }\n}\n```\n\n## 环境切换\n\n### 开发环境启动\n```bash\n# 方式 1: 根目录启动（推荐）\nmake dev\n\n# 方式 2: 分别启动\n# 启动后端 (端口 3000)\ncd backend-go && make dev\n\n# 启动前端 (端口 5173)\ncd frontend && bun run dev\n```\n\n### 生产环境构建\n```bash\n# 完整构建\nmake build\n\n# Docker 部署\ndocker-compose up -d\n```\n\n## 端口配置优先级\n\n1. **环境变量** - 从 `.env.*` 文件读取\n2. **默认值** - 代码中定义的回退值\n3. **系统环境变量** - `PORT` （后端）\n\n## 常见配置场景\n\n### 场景1：更改后端端口到 8080\n```env\n# backend-go/.env\nPORT=8080\n\n# frontend/.env.development\nVITE_BACKEND_URL=http://localhost:8080\n```\n\n### 场景2：使用远程后端服务\n```env\n# frontend/.env.development\nVITE_BACKEND_URL=https://api.example.com\n```\n\n### 场景3：自定义前端开发端口\n```env\n# frontend/.env.development\nVITE_FRONTEND_PORT=3000\n```\n\n### 场景4：生产环境配置\n\n#### 4.1 高性能模式（最小日志）\n```env\n# backend-go/.env\nENV=production\nPORT=3000\nPROXY_ACCESS_KEY=$(openssl rand -base64 32)\n\n# 最小日志输出\nLOG_LEVEL=warn\nENABLE_REQUEST_LOGS=false\nENABLE_RESPONSE_LOGS=false\n\nENABLE_WEB_UI=true\n```\n- ✅ 适合：高并发场景、性能敏感应用\n- ✅ 特点：最低资源消耗，只记录警告和错误\n- ⚠️ 注意：排查问题时信息较少\n\n#### 4.2 标准模式（推荐）\n```env\n# backend-go/.env\nENV=production\nPORT=3000\nPROXY_ACCESS_KEY=$(openssl rand -base64 32)\n\n# 适度日志输出\nLOG_LEVEL=info\nENABLE_REQUEST_LOGS=true\nENABLE_RESPONSE_LOGS=false\n\nENABLE_WEB_UI=true\n```\n- ✅ 适合：大多数生产环境\n- ✅ 特点：平衡可观测性和性能，不泄露敏感信息\n- ✅ 优势：足够的信息用于监控和问题排查\n\n#### 4.3 调试模式（临时排查）\n```env\n# backend-go/.env\nENV=production\nPORT=3000\nPROXY_ACCESS_KEY=$(openssl rand -base64 32)\n\n# 详细日志输出（临时使用）\nLOG_LEVEL=info\nENABLE_REQUEST_LOGS=true\nENABLE_RESPONSE_LOGS=true\n\nENABLE_WEB_UI=true\n```\n- ⚠️ 适合：故障排查时临时启用\n- ⚠️ 注意：会输出完整响应内容，增加日志量\n- 🔄 建议：问题解决后立即恢复标准配置\n\n#### 4.4 开发环境配置\n```env\n# backend-go/.env\nENV=development\nPORT=3000\nPROXY_ACCESS_KEY=dev-test-key\n\n# 完整日志输出\nLOG_LEVEL=debug\nENABLE_REQUEST_LOGS=true\nENABLE_RESPONSE_LOGS=true\n\nENABLE_WEB_UI=true\n```\n- ✅ 适合：本地开发和调试\n- ✅ 特点：输出所有详细信息，包括请求体、响应体\n- ⚠️ 警告：包含敏感信息，仅限开发环境使用\n\n## 调试配置\n\n开发环境下，前端会在控制台输出当前API配置：\n\n```javascript\nconsole.log('🔗 API Configuration:', {\n  API_BASE: '/api',\n  BACKEND_URL: 'http://localhost:3000',\n  IS_DEV: true,\n  IS_PROD: false\n})\n```\n\n## 注意事项\n\n1. **变量前缀**：前端环境变量必须以 `VITE_` 开头才能在浏览器中访问\n2. **构建时解析**：Vite 在构建时静态替换环境变量，运行时无法修改\n3. **生产环境**：生产环境不需要指定后端URL，通过反向代理或一体化部署处理\n4. **类型安全**：使用 `Number()` 转换端口号确保类型正确\n5. **密钥安全**：切勿在版本控制中提交 `.env` 文件，使用 `.env.example` 作为模板\n\n## 安全最佳实践\n\n### 生成强密钥\n```bash\n# 生成随机密钥\nPROXY_ACCESS_KEY=$(openssl rand -base64 32)\necho \"生成的密钥: $PROXY_ACCESS_KEY\"\n```\n\n### 生产环境配置清单\n```bash\n# 1. 强密钥 (必须!)\nPROXY_ACCESS_KEY=<strong-random-key>\n\n# 2. 生产模式\nENV=production\n\n# 3. 适度日志（推荐）\nLOG_LEVEL=info\nENABLE_REQUEST_LOGS=true\nENABLE_RESPONSE_LOGS=false\n\n# 4. 启用 Web UI (可选)\nENABLE_WEB_UI=true\n```\n\n### 日志安全建议\n\n#### 敏感信息保护\n项目已自动对以下信息进行脱敏处理：\n- ✅ API密钥：只显示前4位和后4位（如 `sk-a***b`）\n- ✅ Authorization 请求头：完全隐藏\n- ✅ x-api-key 请求头：完全隐藏\n\n#### 推荐配置\n```bash\n# 生产环境：不输出详细内容\nENV=production\nENABLE_REQUEST_LOGS=true    # ✅ 基本请求信息\nENABLE_RESPONSE_LOGS=false  # ❌ 不输出响应体\n\n# 开发环境：可以输出详细内容\nENV=development\nENABLE_REQUEST_LOGS=true\nENABLE_RESPONSE_LOGS=true\n```\n\n#### 日志存储注意事项\n1. **日志轮转**：定期清理旧日志，避免磁盘空间耗尽\n2. **访问控制**：限制日志文件的访问权限\n   ```bash\n   chmod 600 /var/log/claude-proxy/*.log\n   ```\n3. **敏感数据**：即使有脱敏，也应定期审查日志内容\n4. **合规要求**：根据数据保护法规（GDPR、CCPA等）管理日志\n\n#### 故障排查时的安全做法\n```bash\n# ✅ 推荐：临时开启详细日志，排查完成后恢复\nENABLE_RESPONSE_LOGS=true  # 临时启用\n\n# 🔄 排查完成后立即恢复\nENABLE_RESPONSE_LOGS=false\n\n# ❌ 不推荐：在生产环境长期开启 debug 级别\nLOG_LEVEL=debug  # 可能泄露敏感信息\n```\n\n## 故障排除\n\n### 问题：前端无法连接后端\n1. 检查后端是否在正确端口启动\n   ```bash\n   curl http://localhost:3000/health\n   ```\n2. 确认 `VITE_BACKEND_URL` 配置正确\n3. 查看浏览器控制台的API配置输出\n\n### 问题：构建后API请求失败\n1. 确认生产环境配置了正确的反向代理或使用一体化部署\n2. 检查 `VITE_API_BASE_PATH` 设置\n3. 验证后端API路径匹配\n\n### 问题：环境变量不生效\n1. 确认变量名以 `VITE_` 开头 (前端) 或在后端代码中正确读取\n2. 重启开发服务器\n3. 检查 `.env` 文件语法正确 (无多余空格、引号等)\n\n### 问题：认证失败\n```bash\n# 检查密钥设置\necho $PROXY_ACCESS_KEY\n\n# 测试认证\ncurl -H \"x-api-key: $PROXY_ACCESS_KEY\" http://localhost:3000/health\n```\n\n### 问题：日志输出过多或过少\n\n#### 日志过多（影响性能）\n**症状**：日志文件快速增长，磁盘空间不足，或系统性能下降\n\n**解决方案**：\n1. 降低日志等级\n   ```env\n   LOG_LEVEL=warn  # 从 info 或 debug 降级\n   ```\n\n2. 关闭详细日志\n   ```env\n   ENABLE_REQUEST_LOGS=false\n   ENABLE_RESPONSE_LOGS=false\n   ```\n\n3. 使用日志轮转（推荐）\n   ```bash\n   # 使用 systemd 日志轮转\n   journalctl --vacuum-time=7d\n\n   # 或使用 logrotate\n   # /etc/logrotate.d/claude-proxy\n   /var/log/claude-proxy/*.log {\n       daily\n       rotate 7\n       compress\n       delaycompress\n       missingok\n       notifempty\n   }\n   ```\n\n#### 日志过少（排查困难）\n**症状**：出现问题时没有足够的日志信息\n\n**解决方案**：\n1. 提高日志等级\n   ```env\n   LOG_LEVEL=info  # 从 warn 提升\n   ```\n\n2. 临时开启详细日志\n   ```env\n   ENABLE_REQUEST_LOGS=true\n   ENABLE_RESPONSE_LOGS=true\n   ```\n\n3. 使用开发模式（仅限测试环境）\n   ```env\n   ENV=development\n   LOG_LEVEL=debug\n   ```\n\n#### 看不到请求体/响应体\n**症状**：日志中没有详细的请求/响应内容\n\n**原因**：详细内容只在开发环境 (`ENV=development`) 输出\n\n**解决方案**：\n```env\n# 方案1：临时切换到开发模式（不推荐生产环境）\nENV=development\nENABLE_REQUEST_LOGS=true\nENABLE_RESPONSE_LOGS=true\n\n# 方案2：查看是否开启了日志开关\nENABLE_REQUEST_LOGS=true   # 必须为 true\nENABLE_RESPONSE_LOGS=true  # 必须为 true\n\n# 方案3：检查当前环境\necho $ENV  # 必须是 development\n```\n\n**安全提醒**：\n- ⚠️ 请求体和响应体可能包含敏感信息（API密钥、用户数据等）\n- ⚠️ 生产环境建议关闭 `ENABLE_RESPONSE_LOGS`\n- ⚠️ 排查完成后立即恢复安全配置\n\n### 问题：日志格式混乱\n**症状**：日志输出格式不统一或难以阅读\n\n**检查项**：\n1. 确认是否混用了多个日志系统\n2. 检查是否有第三方库输出了额外日志\n3. 验证环境变量是否正确加载\n   ```bash\n   # 打印当前日志配置\n   curl -H \"x-api-key: $PROXY_ACCESS_KEY\" http://localhost:3000/health\n   ```\n\n## 文档资源\n\n- **项目架构**: 参见 [ARCHITECTURE.md](ARCHITECTURE.md)\n- **快速开始**: 参见 [README.md](README.md)\n- **贡献指南**: 参见 [CONTRIBUTING.md](CONTRIBUTING.md)\n"
  },
  {
    "path": "LICENSE",
    "content": "Copyright (c) 2025 wangyusong\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE. "
  },
  {
    "path": "Makefile",
    "content": "# Claude Proxy Makefile\n\nGREEN=\\033[0;32m\nYELLOW=\\033[0;33m\nNC=\\033[0m\n\n.PHONY: help dev run build clean frontend-dev frontend-build embed-frontend\n\nhelp:\n\t@echo \"$(GREEN)Claude Proxy - 可用命令:$(NC)\"\n\t@echo \"\"\n\t@echo \"$(YELLOW)开发:$(NC)\"\n\t@echo \"  make dev            - Go 后端热重载开发(不含前端)\"\n\t@echo \"  make run            - 构建前端并运行 Go 后端\"\n\t@echo \"  make frontend-dev   - 前端开发服务器\"\n\t@echo \"\"\n\t@echo \"$(YELLOW)构建:$(NC)\"\n\t@echo \"  make build          - 构建前端并编译 Go 后端\"\n\t@echo \"  make frontend-build - 仅构建前端\"\n\t@echo \"  make clean          - 清理构建文件\"\n\ndev:\n\t@echo \"$(GREEN)🚀 启动前后端开发模式...$(NC)\"\n\t@cd frontend && bun run dev &\n\t@cd backend-go && $(MAKE) dev\n\nrun: embed-frontend\n\t@cd backend-go && $(MAKE) run\n\nbuild: embed-frontend\n\t@cd backend-go && $(MAKE) build\n\nembed-frontend:\n\t@echo \"$(GREEN)📦 构建前端...$(NC)\"\n\t@cd frontend && bun run build\n\t@echo \"$(GREEN)📋 嵌入前端到 Go 后端...$(NC)\"\n\t@rm -rf backend-go/frontend/dist\n\t@mkdir -p backend-go/frontend/dist\n\t@cp -r frontend/dist/* backend-go/frontend/dist/\n\nclean:\n\t@cd backend-go && $(MAKE) clean\n\t@rm -rf frontend/dist\n\nfrontend-dev:\n\t@cd frontend && bun run dev\n\nfrontend-build:\n\t@cd frontend && bun run build\n"
  },
  {
    "path": "README.md",
    "content": "> ⚠️ **项目已重命名**: 本项目已重命名为 **[CCX](https://github.com/BenedictKing/ccx)**，请访问新仓库获取最新版本和更新。本仓库已归档，不再维护。\n\n---\n\n# Claude / Codex / Gemini API Proxy\n\n[![GitHub release](https://img.shields.io/github/v/release/BenedictKing/claude-proxy)](https://github.com/BenedictKing/claude-proxy/releases/latest)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n\n一个高性能的 Claude API 代理服务器，支持多种上游 AI 服务提供商（Claude、Codex、Gemini），提供故障转移、多 API 密钥管理和统一入口访问。\n\n## 🚀 功能特性\n\n- **🖥️ 一体化架构**: 后端集成前端，单容器部署，完全替代 Nginx\n- **🔐 统一认证**: 一个密钥保护所有入口（前端界面、管理 API、代理 API）\n- **📱 Web 管理面板**: 现代化可视化界面，支持渠道管理、实时监控和配置\n- **三 API 支持**: 同时支持 Claude Messages API (`/v1/messages`)、Codex Responses API (`/v1/responses`) 和 Gemini API\n- **统一入口**: 通过统一端点访问不同的 AI 服务\n- **多上游支持**: 支持 Claude、Codex 和 Gemini 等多种上游服务\n- **🔌 协议转换**: Messages API 支持协议自动转换，统一接入不同上游服务\n- **🎯 智能调度**: 多渠道智能调度器，支持优先级排序、健康检查和自动熔断\n- **📊 渠道编排**: 可视化渠道管理，拖拽调整优先级，实时查看健康状态\n- **🔄 Trace 亲和**: 同一用户会话自动绑定到同一渠道，提升一致性体验\n- **故障转移**: 自动切换到可用渠道，确保服务高可用\n- **多 API 密钥**: 每个上游可配置多个 API 密钥，自动轮换使用（推荐 failover 策略以最大化利用 Prompt Caching）\n- **🧠 缓存统计**: 按 Token 口径展示各渠道缓存读/写与命中率（命中率 = `cache_read_tokens / (cache_read_tokens + input_tokens)`）\n- **增强的稳定性**: 内置上游请求超时与重试机制，确保服务在网络波动时依然可靠\n- **自动重试与密钥降级**: 检测到额度/余额不足等错误时自动切换下一个可用密钥；若后续请求成功，再将失败密钥移动到末尾（降级）；所有密钥均失败时按上游原始错误返回\n- **⚡ 自动熔断**: 基于滑动窗口算法检测渠道健康度，失败率过高自动熔断，15 分钟后自动恢复\n- **双重配置**: 支持命令行工具和 Web 界面管理上游配置\n- **环境变量**: 通过 `.env` 文件灵活配置服务器参数\n- **健康检查**: 内置健康检查端点和实时状态监控\n- **日志系统**: 完整的请求/响应日志记录\n- **📡 支持流式和非流式响应**\n- **🛠️ 支持工具调用**\n- **💬 会话管理**: Responses API 支持多轮对话的会话跟踪和上下文保持\n\n## 📄 许可证\n\n本项目基于 MIT 许可证开源 - 查看 [LICENSE](LICENSE) 文件了解详情。\n"
  },
  {
    "path": "RELEASE.md",
    "content": "# 发布指南\n\n本文档为项目维护者提供了一套标准的版本发布流程，以确保版本迭代的一致性和清晰度。\n\n## 版本规范\n\n项目遵循**语义化版本 2.0.0 (Semantic Versioning)** 规范。版本格式为 `主版本号.次版本号.修订号` (MAJOR.MINOR.PATCH)，版本号递增规则如下：\n\n-   **主版本号 (MAJOR)**: 当你做了不兼容的 API 修改。\n-   **次版本号 (MINOR)**: 当你做了向下兼容的功能性新增。\n-   **修订号 (PATCH)**: 当你做了向下兼容的问题修正。\n\n## 发布流程\n\n### 步骤 1: 准备工作\n\n1.  确保本地的 `main` 分支是最新且稳定的。\n    ```bash\n    git checkout main\n    git pull origin main\n    ```\n\n2.  确认所有计划内的功能和修复都已合并到 `main` 分支。\n\n3.  验证代码质量和构建状态。\n    ```bash\n    # TypeScript 类型检查\n    bun run type-check\n    \n    # 构建验证\n    bun run build\n    ```\n\n### 步骤 2: 更新版本日志 (`CHANGELOG.md`)\n\n1.  打开 `CHANGELOG.md` 文件。\n2.  在文件顶部新增一个版本标题，格式为 `## vX.Y.Z - YYYY-MM-DD`。\n3.  在标题下，根据本次版本的变更内容，添加相应的分类，如：\n    -   `### ✨ 新功能`\n    -   `### 🐛 Bug 修复`\n    -   `### ♻️ 重构`\n    -   `### 📚 文档`\n    -   `### ⚙️ 其他`\n\n4.  运行以下命令，查看自上一个版本以来的所有提交记录，以帮助你整理更新日志。\n    ```bash\n    # 将 v1.0.0 替换为上一个版本的标签\n    git log v1.0.0...HEAD --oneline\n    ```\n\n### 步骤 3: 更新 `package.json` 中的版本号\n\n1.  打开 `package.json` 文件。\n2.  将 `version` 字段的值更新为新的版本号。\n\n    例如，从 `1.0.0` 更新到 `1.0.1`:\n    ```diff\n    - \"version\": \"1.0.0\",\n    + \"version\": \"1.0.1\",\n    ```\n\n### 步骤 4: 提交版本更新\n\n1.  将 `CHANGELOG.md` 和 `package.json` 的修改提交到暂存区。\n    ```bash\n    git add CHANGELOG.md package.json\n    ```\n\n2.  使用标准化的提交信息进行提交。\n    ```bash\n    # 将 vX.Y.Z 替换为新版本号\n    git commit -m \"chore(release): prepare for vX.Y.Z\"\n    ```\n\n3.  将提交推送到远程 `main` 分支。\n    ```bash\n    git push origin main\n    ```\n\n### 步骤 5: 创建并推送 Git 标签\n\n1.  为此次提交创建一个附注标签（annotated tag）。\n    ```bash\n    # 将 vX.Y.Z 替换为新版本号\n    git tag -a vX.Y.Z -m \"Release vX.Y.Z\"\n    ```\n\n2.  将新创建的标签推送到远程仓库。\n    ```bash\n    # 将 vX.Y.Z 替换为新版本号\n    git push origin vX.Y.Z\n    ```\n\n3.  推送 tag 后，GitHub Actions 会自动触发以下构建任务（**三平台并行执行**）：\n    -   `release-linux.yml` - 构建 Linux amd64/arm64 版本\n    -   `release-macos.yml` - 构建 macOS amd64/arm64 版本\n    -   `release-windows.yml` - 构建 Windows amd64/arm64 版本\n    -   `docker-build.yml` - 构建并推送 Docker 镜像\n\n    > **注意**: 各平台使用独立的 concurrency group (`${{ github.workflow }}-${{ github.ref }}`)，确保并行构建互不阻塞。\n\n### 步骤 6: 在 GitHub 上创建 Release (可选但推荐)\n\n1.  访问项目的 GitHub 页面，进入 \"Releases\" 部分。\n2.  点击 \"Draft a new release\"。\n3.  从 \"Choose a tag\" 下拉菜单中选择你刚刚推送的标签（如 `vX.Y.Z`）。\n4.  将 `CHANGELOG.md` 中对应版本的更新内容复制到发布说明中。\n5.  点击 \"Publish release\"。\n\n至此，新版本的发布流程已全部完成。\n"
  },
  {
    "path": "VERSION",
    "content": "v2.5.13\n"
  },
  {
    "path": "backend-go/.air.toml",
    "content": "# Air 配置文件 - Go热重载工具\n# 文档: https://github.com/air-verse/air\n\n# 工作目录\nroot = \".\"\ntestdata_dir = \"testdata\"\ntmp_dir = \"tmp\"\n\n# 构建配置\n[build]\n  # 编译命令 (可以添加自定义参数)\n  cmd = \"go build -o ./tmp/main .\"\n  # 运行的二进制文件\n  bin = \"tmp/main\"\n  # 自定义运行时参数 (比如: [\"run\", \"test\", \"-v\"])\n  full_bin = \"\"\n  # 监听的文件扩展名\n  include_ext = [\"go\", \"tpl\", \"tmpl\", \"html\", \"yaml\", \"yml\", \"toml\", \"env\"]\n  # 忽略的文件/文件夹\n  exclude_dir = [\"assets\", \"tmp\", \"vendor\", \"testdata\", \"frontend\", \"dist\", \".git\", \".github\", \".vscode\", \".idea\"]\n  # 监听指定的目录\n  include_dir = []\n  # 监听指定的文件（包括 .env）\n  include_file = [\".env\"]\n  # 忽略的文件\n  exclude_file = []\n  # 忽略未修改的文件\n  exclude_unchanged = false\n  # 监听符号链接的目录\n  follow_symlink = false\n  # 文件变更延迟触发编译 (毫秒)\n  delay = 1000\n  # 编译错误时是否停止旧的二进制文件\n  stop_on_error = true\n  # 运行二进制文件时的日志文件\n  log = \"build-errors.log\"\n  # air启动时是否发送中断信号\n  send_interrupt = true\n  # 中断信号延迟 (毫秒)\n  kill_delay = 500\n  # 在发送中断信号时添加额外的参数\n  rerun = false\n  # 运行后的延迟 (毫秒)\n  rerun_delay = 500\n  # 添加额外的参数\n  args_bin = []\n\n# 自定义shell命令\n[build.pre_cmd]\n  # 数组形式的命令会按顺序执行\n  # enable = true\n  # cmds = [\n  #   \"echo 'Building...'\"\n  # ]\n\n[build.post_cmd]\n  # enable = true\n  # cmds = [\n  #   \"echo 'Build complete!'\"\n  # ]\n\n# 日志配置\n[log]\n  # 是否开启主进程日志\n  main_only = false\n  # 时间格式\n  time = true\n\n# 颜色配置\n[color]\n  # 自定义颜色\n  main = \"magenta\"\n  watcher = \"cyan\"\n  build = \"yellow\"\n  runner = \"green\"\n  # app日志颜色\n  app = \"\"\n\n# 其他配置\n[misc]\n  # 是否在退出时清理tmp目录\n  clean_on_exit = true\n\n# 屏幕清理\n[screen]\n  # 重新编译时清屏\n  clear_on_rebuild = true\n  # 保持滚动\n  keep_scroll = true"
  },
  {
    "path": "backend-go/.env.example",
    "content": "# 环境变量示例配置\n# 复制此文件为 .env 并修改配置\n\n# ============ 服务器配置 ============\nPORT=3000\n\n# 运行环境: development | production\n# 影响:\n#   - production: Gin ReleaseMode(高性能)、关闭/admin/dev/info、严格CORS\n#   - development: Gin DebugMode(详细日志)、开启/admin/dev/info、宽松CORS\n# 生产环境务必设置为 production！\nENV=production\n\n# ============ Web UI 配置 ============\n# 是否启用 Web 管理界面\nENABLE_WEB_UI=true\n\n# ============ 访问控制 ============\n# 代理访问密钥（必须修改！）\nPROXY_ACCESS_KEY=your-secure-access-key-here\n\n# ============ 日志配置 ============\n# 日志级别: error | warn | info | debug\nLOG_LEVEL=info\n\n# 是否启用请求/响应日志\n# 注意：默认值为 true，注释掉此项等于启用日志\n# 要禁用日志必须显式设置为 false，不能通过注释来禁用\nENABLE_REQUEST_LOGS=false\nENABLE_RESPONSE_LOGS=false\n\n# 静默前端轮询端点日志（调试时避免刷屏）\nQUIET_POLLING_LOGS=true\n\n# 原始日志输出（不缩进、不截断、不重排序，直接输出完整请求/响应内容）\nRAW_LOG_OUTPUT=false\n\n# SSE 调试级别: off | summary | full\n# full: 记录每个 SSE 事件的类型、长度、content_block 详情\n# summary: 仅在流结束时记录事件统计摘要\n# off: 关闭 SSE 调试日志（默认）\nSSE_DEBUG_LEVEL=off\n\n# 是否改写响应中的 model 字段为请求的 model（默认 false）\n# 启用后，当上游返回的 model 与请求的 model 不一致时，会自动改写为请求的 model\n# 注意：仅影响 Messages API 的流式响应，不影响 Responses API 和 Gemini API\nREWRITE_RESPONSE_MODEL=false\n\n# ============ 性能配置 ============\n# 请求超时时间（毫秒）\nREQUEST_TIMEOUT=300000\n\n# 请求体最大大小（MB），默认 50\nMAX_REQUEST_BODY_SIZE_MB=50\n\n# 等待上游响应头超时时间（秒），默认 60，范围 30-120\n# 如果遇到 \"http2: timeout awaiting response headers\" 错误，可以适当调高\nRESPONSE_HEADER_TIMEOUT=60\n\n# ============ CORS 配置 ============\nENABLE_CORS=false\nCORS_ORIGIN=*\n\n# ============ 熔断指标配置 ============\n# 滑动窗口大小（最小 3，默认 10）\nMETRICS_WINDOW_SIZE=10\n# 失败率阈值（0-1，默认 0.5 即 50%）\nMETRICS_FAILURE_THRESHOLD=0.5\n\n# ============ 指标持久化配置 ============\n# 是否启用 SQLite 持久化（默认 true）\n# 启用后重启服务不会丢失历史指标数据\nMETRICS_PERSISTENCE_ENABLED=true\n# 数据保留天数（3-30，默认 7）\nMETRICS_RETENTION_DAYS=7\n"
  },
  {
    "path": "backend-go/.gitignore",
    "content": "# 忽略编译产物\ndist/\n*.exe\nclaude-proxy-*\nclaude-proxy\nstream_verify\n\n# 忽略前端资源（会在构建时复制）\nfrontend/\n\n# 忽略配置文件\n.env\n.config/\n\n# Go 相关\nvendor/\n\n# 操作系统文件\n.DS_Store\nThumbs.db\n\n# IDE\n.vscode/\n.idea/\n*.swp\n*.swo\n*~\ntmp/\n\n!docs/"
  },
  {
    "path": "backend-go/CLAUDE.md",
    "content": "# backend-go 模块文档\n\n[← 根目录](../CLAUDE.md)\n\n## 模块职责\n\nGo 后端核心服务：HTTP API、多上游适配、协议转换、智能调度、会话管理、配置热重载。\n\n## 启动命令\n\n```bash\nmake dev          # 热重载开发\nmake test         # 运行测试\nmake test-cover   # 测试 + 覆盖率\nmake build        # 构建二进制\n```\n\n## API 端点\n\n| 端点 | 方法 | 功能 |\n|------|------|------|\n| `/health` | GET | 健康检查（无需认证） |\n| `/v1/messages` | POST | Claude Messages API |\n| `/v1/messages/count_tokens` | POST | Token 计数 |\n| `/v1/responses` | POST | Codex Responses API |\n| `/v1/responses/compact` | POST | 精简版 Responses API |\n| `/api/messages/channels` | CRUD | Messages 渠道管理 |\n| `/api/responses/channels` | CRUD | Responses 渠道管理 |\n| `/api/messages/ping/:id` | GET | 渠道连通性测试 |\n| `/api/messages/channels/metrics` | GET | 渠道指标 |\n| `/api/messages/channels/scheduler/stats` | GET | 调度器统计 |\n\n## 指标历史数据聚合粒度\n\n`/api/messages/channels/:id/keys/metrics/history` 端点根据查询时间范围自动选择聚合间隔：\n\n| 时间范围 | 聚合间隔 | 数据点数 |\n|----------|----------|----------|\n| 1h       | 1 分钟   | ~60 点   |\n| 6h       | 5 分钟   | ~72 点   |\n| 24h      | 15 分钟  | ~96 点   |\n\n可通过 `interval` 参数手动指定（最小 1 分钟）。\n\n## Provider 接口\n\n所有上游服务实现 `internal/providers/Provider` 接口：\n\n```go\ntype Provider interface {\n    ConvertToProviderRequest(c *gin.Context, upstream *config.UpstreamConfig, apiKey string) (*http.Request, []byte, error)\n    ConvertToClaudeResponse(providerResp *types.ProviderResponse) (*types.ClaudeResponse, error)\n    HandleStreamResponse(body io.ReadCloser) (<-chan string, <-chan error, error)\n}\n```\n\n**实现**: `ClaudeProvider`, `OpenAIProvider`, `GeminiProvider`\n\n## 核心模块\n\n| 模块 | 职责 |\n|------|------|\n| `handlers/` | HTTP 处理器（proxy.go, responses.go） |\n| `providers/` | 上游适配器 |\n| `converters/` | 协议转换器（工厂模式） |\n| `scheduler/` | 多渠道调度（优先级、熔断） |\n| `session/` | 会话管理（Trace 亲和性） |\n| `config/` | 配置管理（热重载） |\n\n## 日志规范\n\n所有日志输出使用 `[Component-Action]` 标签格式，禁止使用 emoji 符号（确保跨平台兼容性）。\n\n**格式规范**:\n```go\n// 标准格式\nlog.Printf(\"[Component-Action] 消息内容: %v\", value)\n\n// 警告信息\nlog.Printf(\"[Component] 警告: 消息内容\")\n```\n\n**标签命名示例**:\n\n| 组件 | 标签 | 用途 |\n|------|------|------|\n| 调度器 | `[Scheduler-Channel]` | 渠道选择 |\n| 调度器 | `[Scheduler-Promotion]` | 促销渠道 |\n| 调度器 | `[Scheduler-Affinity]` | Trace 亲和性 |\n| 调度器 | `[Scheduler-Fallback]` | 降级选择 |\n| 认证 | `[Auth-Failed]` | 认证失败 |\n| 认证 | `[Auth-Success]` | 认证成功 |\n| 指标 | `[Metrics-Store]` | 指标存储 |\n| 会话 | `[Session-Manager]` | 会话管理 |\n| 配置 | `[Config-Watcher]` | 配置热重载 |\n| 压缩 | `[Gzip]` | Gzip 解压缩 |\n| Messages | `[Messages-Stream]` | Messages 流式处理 |\n| Messages | `[Messages-Stream-Token]` | Messages Token 统计 |\n| Messages | `[Messages-Models]` | Messages Models API 操作 |\n| Responses | `[Responses-Stream]` | Responses 流式处理 |\n| Responses | `[Responses-Stream-Token]` | Responses Token 统计 |\n| Responses | `[Responses-Models]` | Responses Models API 操作 |\n| Models | `[Models]` | 跨接口的模型列表合并操作 |\n\n## 扩展指南\n\n**添加新上游服务**:\n1. 在 `internal/providers/` 创建新文件\n2. 实现 `Provider` 接口\n3. 在 `GetProvider()` 注册\n\n**调度优先级规则**:\n1. 促销期渠道优先\n2. Priority 字段排序\n3. Trace 亲和性绑定\n4. 熔断状态过滤\n\n## 工具使用注意事项\n\n**Edit 工具与 Tab 缩进**:\n- Go 文件使用 tab 缩进，`Edit` 工具匹配时可能因空白字符差异失败\n- 失败时可用 `sed -i '' 's/old/new/g' file.go` 替代\n"
  },
  {
    "path": "backend-go/DEV_GUIDE.md",
    "content": "# Go 后端开发指南 - 热重载模式\n\n## 🚀 快速开始\n\n### 1. 安装 Air 热重载工具\n\nAir 项目已迁移至新仓库 `github.com/air-verse/air`（原 `cosmtrek/air`）\n\n```bash\n# 推荐方式\nmake install-air\n\n# 或手动安装\ngo install github.com/air-verse/air@latest\n```\n\n### 2. 启动热重载开发模式\n\n```bash\n# 启动开发模式\nmake dev\n\n# 输出示例：\n# 🚀 启动开发模式 (热重载开启)\n# 📝 监听文件变化: *.go, *.yaml, *.toml, *.env\n# 🔄 修改代码后将自动重启...\n```\n\n## 📁 热重载配置\n\n### 监听的文件类型\n- `*.go` - Go 源代码\n- `*.yaml`, `*.yml` - YAML 配置文件\n- `*.toml` - TOML 配置文件\n- `*.env` - 环境变量文件\n- `*.html`, `*.tpl`, `*.tmpl` - 模板文件\n\n### 忽略的目录\n- `tmp/` - Air 临时编译目录\n- `vendor/` - Go 依赖目录\n- `frontend/` - 前端源码（不影响后端）\n- `dist/` - 构建输出目录\n- `.git/`, `.github/` - Git 相关\n- `.vscode/`, `.idea/` - IDE 配置\n\n### 性能优化设置\n- **编译延迟**: 1000ms（避免保存过程中频繁编译）\n- **错误处理**: 编译错误时保持旧版本运行\n- **信号处理**: 优雅关闭，500ms 延迟\n- **清屏设置**: 每次重编译时自动清屏\n\n## 🎯 开发流程\n\n### 典型开发场景\n\n1. **修改业务逻辑**\n   ```bash\n   # 编辑 handlers/proxy.go\n   # 保存文件 → Air 检测到变化 → 1秒后自动重编译 → 重启服务\n   ```\n\n2. **更新配置文件**\n   ```bash\n   # 编辑 .env\n   # 保存文件 → Air 重新加载配置 → 服务自动重启\n   ```\n\n3. **处理编译错误**\n   ```bash\n   # 代码有语法错误\n   # Air 显示错误信息 → 保持旧版本运行\n   # 修复错误并保存 → 自动重新编译 → 恢复正常\n   ```\n\n## 🛠️ Make 命令参考\n\n| 命令 | 说明 | 使用场景 |\n|------|------|---------|\n| `make dev` | 启动热重载开发模式 | 日常开发主要命令 |\n| `make install-air` | 安装 Air 工具 | 首次设置或更新 Air |\n| `make run` | 直接运行（无热重载） | 快速测试 |\n| `make build` | 构建生产版本 | 部署准备 |\n| `make build-local` | 构建本地版本 | 本地测试 |\n| `make test` | 运行测试 | 功能验证 |\n| `make fmt` | 格式化代码 | 代码规范化 |\n| `make clean` | 清理临时文件 | 清理环境 |\n\n## 🔧 Air 高级配置\n\n### 自定义 .air.toml\n\n```toml\n# 添加预编译命令\n[build.pre_cmd]\n  enable = true\n  cmds = [\n    \"echo '开始编译...'\",\n    \"go mod tidy\"\n  ]\n\n# 添加后编译命令\n[build.post_cmd]\n  enable = true\n  cmds = [\n    \"echo '编译完成！'\"\n  ]\n\n# 自定义运行参数\n[build]\n  # 添加构建标签\n  cmd = \"go build -tags dev -o ./tmp/main .\"\n\n  # 传递运行时参数\n  args_bin = [\"--debug\", \"--verbose\"]\n```\n\n### 环境变量\n\n```bash\n# 开发模式专用环境变量\nENV=development\nLOG_LEVEL=debug\nENABLE_REQUEST_LOGS=true\nENABLE_RESPONSE_LOGS=true\n```\n\n## 🐛 问题排查\n\n### Air 命令未找到\n```bash\n# 检查安装\nwhich air\n\n# 添加到 PATH\nexport PATH=$PATH:$(go env GOPATH)/bin\n\n# 或添加到 shell 配置\necho 'export PATH=$PATH:$(go env GOPATH)/bin' >> ~/.zshrc\nsource ~/.zshrc\n```\n\n### 热重载不触发\n```bash\n# 检查 Air 进程\nps aux | grep air\n\n# 查看 Air 日志\ntail -f build-errors.log\n\n# 清理并重启\nmake clean && make dev\n```\n\n### 端口占用\n```bash\n# 查找占用端口的进程\nlsof -i :3000\n\n# 终止进程\nkill -9 <PID>\n```\n\n### 文件权限问题\n```bash\n# 修复权限\nchmod -R 755 .\nchmod 644 .air.toml\n```\n\n## 📊 性能对比\n\n| 操作 | 无热重载 | 有热重载 | 提升 |\n|------|---------|---------|------|\n| 修改代码后重启 | 手动 10-15秒 | 自动 1-2秒 | **10倍** |\n| 处理编译错误 | 中断→修复→重启 | 保持运行→修复→自动恢复 | **无中断** |\n| 配置更新 | 停止→修改→启动 | 修改→自动重启 | **3倍** |\n| 开发效率 | 低 | 高 | **显著提升** |\n\n## 💡 最佳实践\n\n1. **保持 Air 运行**: 开发期间始终使用 `make dev`\n2. **合理设置延迟**: 1秒延迟平衡了响应速度和性能\n3. **利用彩色输出**: 不同颜色快速区分日志类型\n4. **定期清理**: 使用 `make clean` 清理临时文件\n5. **版本控制**: `.air.toml` 应该加入版本控制\n\n## 🔗 相关资源\n\n- [Air 官方文档](https://github.com/air-verse/air)\n- [Air 配置示例](https://github.com/air-verse/air/blob/master/air_example.toml)\n- [Gin 开发模式文档](https://gin-gonic.com/docs/quickstart/)\n\n---\n\n**提示**: 如果遇到任何问题，请先运行 `make clean && make install-air` 重置环境。"
  },
  {
    "path": "backend-go/Makefile",
    "content": "# Makefile for Claude Proxy Go Backend\n\n# 变量定义\nBINARY_NAME=claude-proxy-go\nMAIN_PATH=.\nBUILD_DIR=../dist\nAIR_VERSION=v1.52.0\n# 支持环境变量覆盖 VERSION（用于 Docker 构建时传入）\nVERSION?=$(shell cat ../VERSION 2>/dev/null || echo \"v0.0.0-dev\")\nBUILD_TIME=$(shell date '+%Y-%m-%d_%H:%M:%S_%Z')\nGIT_COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo \"unknown\")\nLDFLAGS=-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) -X main.GitCommit=$(GIT_COMMIT)\n\n# 颜色输出\nGREEN=\\033[0;32m\nYELLOW=\\033[0;33m\nNC=\\033[0m # No Color\n\n.PHONY: help dev build run clean install-air test copy-frontend\n\n# 默认目标 - 显示帮助\nhelp:\n\t@echo \"$(GREEN)Claude Proxy Go Backend - 可用命令:$(NC)\"\n\t@echo \"\"\n\t@echo \"$(YELLOW)开发命令:$(NC)\"\n\t@echo \"  make dev          - 启动开发模式 (热重载)\"\n\t@echo \"  make install-air  - 安装 Air 热重载工具\"\n\t@echo \"\"\n\t@echo \"$(YELLOW)构建命令:$(NC)\"\n\t@echo \"  make build        - 构建生产版本\"\n\t@echo \"  make run          - 直接运行 (自动复制前端)\"\n\t@echo \"  make copy-frontend- 复制前端资源\"\n\t@echo \"  make clean        - 清理构建文件\"\n\t@echo \"\"\n\t@echo \"$(YELLOW)测试命令:$(NC)\"\n\t@echo \"  make test         - 运行测试\"\n\t@echo \"  make test-cover   - 运行测试并生成覆盖率\"\n\n# 安装 Air 工具\ninstall-air:\n\t@echo \"$(GREEN)正在安装 Air 热重载工具...$(NC)\"\n\t@go install github.com/air-verse/air@latest\n\t@echo \"$(GREEN)✅ Air 安装完成!$(NC)\"\n\t@echo \"使用方法: make dev\"\n\n# 开发模式 - 使用 Air 热重载\ndev:\n\t@if ! command -v air &> /dev/null; then \\\n\t\techo \"$(YELLOW)⚠️  Air 未安装，正在自动安装...$(NC)\"; \\\n\t\t$(MAKE) install-air; \\\n\tfi\n\t@echo \"$(GREEN)🚀 启动开发模式 (热重载开启)$(NC)\"\n\t@echo \"$(YELLOW)📝 监听文件变化: *.go, *.yaml, *.toml, *.env$(NC)\"\n\t@echo \"$(YELLOW)🔄 修改代码后将自动重启...$(NC)\"\n\t@echo \"\"\n\t@air\n\n# 开发模式 - 使用系统环境变量\ndev-with-env:\n\t@echo \"$(GREEN)🚀 启动开发模式 (使用系统环境变量)$(NC)\"\n\t@ENV_MODE=development air\n\n# 构建生产版本\nbuild:\n\t@echo \"$(GREEN)📦 构建生产版本...$(NC)\"\n\t@CGO_ENABLED=0 go build -ldflags=\"$(LDFLAGS) -s -w\" -o $(BUILD_DIR)/$(BINARY_NAME) $(MAIN_PATH)\n\t@echo \"$(GREEN)✅ 构建完成: $(BUILD_DIR)/$(BINARY_NAME)$(NC)\"\n\n# 构建当前平台版本\nbuild-local:\n\t@echo \"$(GREEN)📦 构建本地版本...$(NC)\"\n\t@go build -o $(BINARY_NAME) $(MAIN_PATH)\n\t@echo \"$(GREEN)✅ 构建完成: ./$(BINARY_NAME)$(NC)\"\n\n# 直接运行 (带版本信息)\nrun: copy-frontend\n\t@echo \"$(GREEN)▶️  直接运行...$(NC)\"\n\t@go run -ldflags=\"$(LDFLAGS)\" $(MAIN_PATH)\n\n# 运行测试\ntest:\n\t@echo \"$(GREEN)🧪 运行测试...$(NC)\"\n\t@go test -v ./...\n\n# 运行测试并生成覆盖率报告\ntest-cover:\n\t@echo \"$(GREEN)🧪 运行测试并生成覆盖率...$(NC)\"\n\t@go test -v -cover -coverprofile=coverage.out ./...\n\t@go tool cover -html=coverage.out -o coverage.html\n\t@echo \"$(GREEN)✅ 覆盖率报告已生成: coverage.html$(NC)\"\n\n# 清理构建文件\nclean:\n\t@echo \"$(YELLOW)🧹 清理构建文件...$(NC)\"\n\t@rm -rf tmp/\n\t@rm -rf frontend/dist/\n\t@rm -f $(BINARY_NAME)\n\t@rm -f $(BUILD_DIR)/$(BINARY_NAME)\n\t@rm -f coverage.out coverage.html\n\t@rm -f build-errors.log\n\t@echo \"$(GREEN)✅ 清理完成$(NC)\"\n\n# 复制前端资源\ncopy-frontend:\n\t@echo \"$(GREEN)📦 复制前端资源...$(NC)\"\n\t@rm -rf frontend/dist\n\t@mkdir -p frontend/dist\n\t@if [ -d \"../frontend/dist\" ]; then \\\n\t\tcp -r ../frontend/dist/* frontend/dist/; \\\n\t\techo \"$(GREEN)✅ 前端资源复制完成$(NC)\"; \\\n\telse \\\n\t\techo \"$(YELLOW)⚠️  前端构建产物不存在，请先构建前端: cd ../frontend && bun run build$(NC)\"; \\\n\tfi\n\n# 代码格式化\nfmt:\n\t@echo \"$(GREEN)🎨 格式化代码...$(NC)\"\n\t@go fmt ./...\n\t@echo \"$(GREEN)✅ 格式化完成$(NC)\"\n\n# 代码检查\nlint:\n\t@echo \"$(GREEN)🔍 检查代码...$(NC)\"\n\t@if ! command -v golangci-lint &> /dev/null; then \\\n\t\techo \"$(YELLOW)安装 golangci-lint...$(NC)\"; \\\n\t\tgo install github.com/golangci/golangci-lint/cmd/golangci-lint@latest; \\\n\tfi\n\t@golangci-lint run\n\n# 依赖管理\ndeps:\n\t@echo \"$(GREEN)📚 更新依赖...$(NC)\"\n\t@go mod tidy\n\t@go mod download\n\t@echo \"$(GREEN)✅ 依赖更新完成$(NC)\"\n\n# 查看依赖树\ndeps-tree:\n\t@echo \"$(GREEN)🌳 依赖树:$(NC)\"\n\t@go mod graph\n\n# 安装开发工具\ninstall-tools: install-air\n\t@echo \"$(GREEN)🔧 安装开发工具...$(NC)\"\n\t@go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest\n\t@echo \"$(GREEN)✅ 所有工具安装完成$(NC)\""
  },
  {
    "path": "backend-go/README.md",
    "content": "# Claude / Codex / Gemini API Proxy - Go 版本\n\n> 🚀 高性能的 Claude / Codex / Gemini API Proxy - Go 语言实现，支持多种上游AI服务提供商，内置前端管理界面\n\n## 特性\n\n- ✅ **完整的 TypeScript 后端功能移植**：所有原 TS 后端功能完整实现\n- 🚀 **高性能**：Go 语言实现，性能优于 Node.js 版本\n- 📦 **单文件部署**：前端资源嵌入二进制文件，无需额外配置\n- 🔄 **协议转换**：自动转换 Claude 格式请求到不同上游服务商格式\n- ⚖️ **故障转移**：支持多 API 密钥的智能分配和自动切换\n- 🖥️ **Web 管理界面**：内置的前端管理界面（嵌入式）\n- 🛡️ **高可用性**：健康检查、错误处理和优雅降级\n\n## 支持的上游服务\n\n- ✅ OpenAI (GPT-4, GPT-3.5 等)\n- ✅ Gemini (Google AI)\n- ✅ Claude (Anthropic)\n- ✅ OpenAI Old (旧版兼容)\n\n## 最新更新 (v2.0.1)\n\n### 🐛 重要修复\n- ✅ 修复前端资源加载问题（Vite base 路径配置）\n- ✅ 修复静态文件 MIME 类型错误（favicon.ico 等）\n- ✅ 修复 API 路由与前端不匹配问题\n- ✅ 修复版本信息未注入问题\n\n### ⚡ 性能优化\n- ✅ 智能前端构建缓存（无变更时 0.07秒启动，提升 142 倍）\n- ✅ 优化代码分割（vue-vendor 独立打包）\n\n### 📝 改进\n- ✅ ENV 环境变量标准化（替代 NODE_ENV，向后兼容）\n- ✅ 添加 favicon 支持（SVG 格式）\n- ✅ 完善文档和开发指南\n\n---\n\n## 快速开始\n\n### 方式1：下载预编译二进制文件（推荐）\n\n1. 从 [Releases](https://github.com/yourusername/claude-proxy/releases) 下载对应平台的二进制文件\n2. 创建 `.env` 文件：\n\n```bash\n# 复制示例配置\ncp .env.example .env\n\n# 编辑配置\nnano .env\n```\n\n3. 运行服务器：\n\n```bash\n# Linux / macOS\n./claude-proxy-linux-amd64\n\n# Windows\nclaude-proxy-windows-amd64.exe\n```\n\n### 方式2：从源码构建\n\n#### 前置要求\n\n- Go 1.22 或更高版本\n- Node.js 18+ (用于构建前端)\n\n#### 构建步骤\n\n```bash\n# 1. 克隆项目\ngit clone https://github.com/yourusername/claude-proxy.git\ncd claude-proxy\n\n# 2. 构建前端\ncd frontend\nnpm install\nnpm run build\ncd ..\n\n# 3. 构建 Go 后端（包含前端资源）\ncd backend-go\n./build.sh\n\n# 构建产物位于 dist/ 目录\n```\n\n## 配置说明\n\n### 环境变量配置 (.env)\n\n```env\n# ============ 服务器配置 ============\nPORT=3000\n\n# 运行环境: development | production\n# 影响:\n#   - production: Gin ReleaseMode(高性能)、关闭/admin/dev/info、严格CORS\n#   - development: Gin DebugMode(详细日志)、开启/admin/dev/info、宽松CORS\nENV=production\n\n# ============ Web UI 配置 ============\nENABLE_WEB_UI=true\n\n# ============ 访问控制 ============\n# 代理访问密钥（必须修改！）\nPROXY_ACCESS_KEY=your-secure-access-key\n\n# ============ 日志配置 ============\n# 日志级别: error | warn | info | debug\nLOG_LEVEL=info\n\n# 是否启用请求/响应日志\nENABLE_REQUEST_LOGS=true\nENABLE_RESPONSE_LOGS=true\n\n# ============ 性能配置 ============\n# 请求超时时间（毫秒）\nREQUEST_TIMEOUT=30000\n\n# ============ CORS 配置 ============\nENABLE_CORS=true\nCORS_ORIGIN=*\n```\n\n### 环境模式详解\n\n| 配置项 | development | production |\n|--------|-------------|------------|\n| **Gin 模式** | DebugMode (详细日志) | ReleaseMode (高性能) |\n| **开发端点** | `/admin/dev/info` 开启 | `/admin/dev/info` 关闭 |\n| **CORS 策略** | 自动允许所有 localhost 源 | 严格使用 CORS_ORIGIN 配置 |\n| **日志输出** | 路由注册、请求详情 | 仅错误和警告 |\n| **安全性** | 低（暴露调试信息） | 高（最小信息暴露） |\n\n**建议**：\n- 开发测试时使用 `ENV=development`\n- 生产部署时务必使用 `ENV=production`\n\n### 渠道配置\n\n服务启动后，通过 Web 管理界面 (http://localhost:3000) 配置上游渠道和 API 密钥。\n\n或者直接编辑配置文件 `.config/config.json`：\n\n```json\n{\n  \"upstream\": [\n    {\n      \"name\": \"OpenAI\",\n      \"baseUrl\": \"https://api.openai.com/v1\",\n      \"apiKeys\": [\"sk-your-api-key\"],\n      \"serviceType\": \"openai\",\n      \"status\": \"active\"\n    }\n  ],\n  \"loadBalance\": \"failover\"\n}\n```\n\n### 渠道状态自动变化\n\n以下场景会触发渠道状态的自动变化：\n\n| 场景 | 触发条件 | 自动行为 |\n|------|----------|----------|\n| **单 Key 更换自动激活** | 渠道只有 1 个 Key，且更新为不同的 Key | 1. 状态从 `suspended` 变为 `active`<br>2. 重置熔断状态（清除错误计数） |\n| **熔断自动恢复** | 渠道熔断后超过恢复时间（默认 15 分钟） | 自动清除熔断标记，渠道恢复可用 |\n| **无 Key 自动暂停** | 渠道配置为 `active` 但没有 API Key | 状态自动设为 `suspended` |\n\n**设计说明：**\n- 单 Key 更换时自动激活，因为用户明显想要使用新 Key\n- 多 Key 场景不会自动激活，避免误操作（用户可能只是添加/删除部分 Key）\n- `disabled` 状态不受影响，用户主动禁用的渠道不会被自动激活\n\n### 渠道促销期（Promotion）\n\n促销期机制用于临时提升某个渠道的优先级，让新渠道能够快速获得流量进行测试。\n\n**促销期特性：**\n- 处于促销期的渠道会被**优先选择**，忽略 trace 亲和性\n- 同一时间**只能有一个渠道**处于促销期（设置新渠道会自动清除旧渠道的促销期）\n- 促销期有**时间限制**，到期后自动失效\n- 促销渠道如果**不健康**（熔断/无可用密钥），会自动跳过\n\n**自动触发场景：**\n\n| 场景 | 触发条件 | 自动行为 |\n|------|----------|----------|\n| **快速添加渠道** | 通过 Web UI 快速添加新渠道 | 1. 新渠道排序到第一位<br>2. 设置 5 分钟促销期 |\n\n**API 使用：**\n```bash\n# 设置渠道促销期（600秒 = 10分钟）\ncurl -X POST http://localhost:3000/api/channels/0/promotion \\\n  -H \"x-api-key: your-proxy-access-key\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"duration\": 600}'\n\n# 清除渠道促销期\ncurl -X POST http://localhost:3000/api/channels/0/promotion \\\n  -H \"x-api-key: your-proxy-access-key\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"duration\": 0}'\n```\n\n**适用场景：**\n- 新增渠道后，临时提升优先级进行测试\n- 更换 Key 后，验证新 Key 是否正常工作\n- 临时将流量切换到特定渠道\n\n## 使用方法\n\n### 访问 Web 管理界面\n\n打开浏览器访问: http://localhost:3000\n\n首次访问需要输入 `PROXY_ACCESS_KEY`\n\n### API 调用\n\n```bash\ncurl -X POST http://localhost:3000/v1/messages \\\n  -H \"x-api-key: your-proxy-access-key\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"model\": \"claude-3-5-sonnet-20241022\",\n    \"max_tokens\": 1024,\n    \"messages\": [\n      {\"role\": \"user\", \"content\": \"Hello, Claude!\"}\n    ]\n  }'\n```\n\n## 架构对比\n\n| 特性 | TypeScript 版本 | Go 版本 |\n|------|----------------|---------|\n| 运行时 | Node.js/Bun | Go (编译型) |\n| 性能 | 中等 | 高 |\n| 内存占用 | 较高 | 较低 |\n| 部署 | 需要 Node.js 环境 | 单文件可执行 |\n| 启动速度 | 较慢 | 快速 |\n| 并发处理 | 事件循环 | Goroutine（原生并发）|\n\n## 目录结构\n\n```\nbackend-go/\n├── main.go                 # 主程序入口\n├── go.mod                  # Go 模块定义\n├── build.sh                # 构建脚本\n├── internal/\n│   ├── config/             # 配置管理\n│   │   ├── env.go          # 环境变量配置\n│   │   └── config.go       # 配置文件管理\n│   ├── providers/          # 上游服务适配器\n│   │   ├── provider.go     # Provider 接口\n│   │   ├── openai.go       # OpenAI 适配器\n│   │   ├── gemini.go       # Gemini 适配器\n│   │   └── claude.go       # Claude 适配器\n│   ├── middleware/         # HTTP 中间件\n│   │   ├── cors.go         # CORS 中间件\n│   │   └── auth.go         # 认证中间件\n│   ├── handlers/           # HTTP 处理器\n│   │   ├── health.go       # 健康检查\n│   │   ├── config.go       # 配置管理 API\n│   │   ├── proxy.go        # 代理处理逻辑\n│   │   └── frontend.go     # 前端资源服务\n│   └── types/              # 类型定义\n│       └── types.go        # 请求/响应类型\n└── frontend/dist/          # 嵌入的前端资源（构建时生成）\n```\n\n## 性能优化\n\nGo 版本相比 TypeScript 版本的性能优势：\n\n1. **更低的内存占用**：Go 的垃圾回收机制更高效\n2. **更快的启动速度**：编译型语言，无需运行时解析\n3. **更好的并发性能**：原生 Goroutine 支持\n4. **更小的部署包**：单文件可执行，无需 node_modules\n\n## 常见问题\n\n### 1. 如何更新前端资源？\n\n重新构建前端后，运行 `./build.sh` 重新打包。\n\n### 2. 如何禁用 Web UI？\n\n在 `.env` 文件中设置 `ENABLE_WEB_UI=false`\n\n### 3. 支持热重载配置吗？\n\n支持！配置文件（`.config/config.json`）变更会自动重载，无需重启服务器。\n\n### 4. 如何添加自定义上游服务？\n\n实现 `providers.Provider` 接口并在 `providers.GetProvider` 中注册即可。\n\n## 开发\n\n### 🔥 热重载开发模式（新增）\n\nGo 版本现在支持代码热重载，修改代码后自动重新编译和重启！\n\n#### 安装热重载工具\n\n```bash\n# 方式一：使用 make（推荐）\nmake install-air\n\n# 方式二：使用 npm/bun\nnpm run dev:go:install\n\n# 方式三：直接安装\ngo install github.com/air-verse/air@latest\n```\n\n#### 启动热重载开发模式\n\n```bash\n# 方式一：使用 make（推荐）\nmake dev              # 自动检测并安装 Air，启动热重载\n\n# 方式二：使用 npm/bun\nnpm run dev:go        # 或 bun run dev:go\n\n# 方式三：直接使用 air\ncd backend-go && air\n```\n\n**热重载特性：**\n- ✅ **自动重启** - 修改 `.go` 文件后自动重新编译和重启\n- ✅ **配置监听** - 修改 `.yaml`, `.toml`, `.env` 文件也会触发重启\n- ✅ **错误恢复** - 编译错误时保持运行，修复后自动恢复\n- ✅ **彩色日志** - 不同类型日志使用不同颜色，便于调试\n- ✅ **性能优化** - 1秒延迟编译，避免频繁重启\n\n### 推荐开发流程（智能缓存）\n\n```bash\n# 使用 Makefile - 自动管理前端构建缓存\nmake dev              # 🔥 热重载开发模式（推荐）\nmake run              # 首次构建前端，后续仅在源文件变更时重新编译\nmake build            # 构建生产版本\nmake clean            # 清除所有构建缓存和临时文件\n\n# 手动控制\nmake build-local      # 构建本地版本\nmake test             # 运行测试\nmake test-cover       # 生成测试覆盖率报告\nmake fmt              # 格式化代码\nmake lint             # 代码检查\nmake deps             # 更新依赖\n```\n\n**智能缓存机制：**\n- ✅ `make run` 自动检测 `frontend/src` 目录文件变更\n- ✅ 未变更时跳过编译，**秒级启动**服务器\n- ✅ 首次运行或源文件修改后自动重新编译\n- ✅ 使用标记文件 `.build-marker` 追踪构建状态\n\n### Air 配置说明\n\n`.air.toml` 文件定义了热重载行为：\n\n```toml\n# 监听的文件类型\ninclude_ext = [\"go\", \"tpl\", \"tmpl\", \"html\", \"yaml\", \"yml\", \"toml\", \"env\"]\n\n# 忽略的目录\nexclude_dir = [\"assets\", \"tmp\", \"vendor\", \"frontend\", \"dist\"]\n\n# 编译延迟（毫秒）\ndelay = 1000\n\n# 编译错误时是否停止\nstop_on_error = true\n```\n\n### 传统开发方式\n\n```bash\n# 直接运行（不推荐 - 无版本信息）\ngo run main.go\n\n# 运行测试\ngo test ./...\n\n# 格式化代码\ngo fmt ./...\n\n# 静态检查\ngo vet ./...\n```\n\n### 开发技巧\n\n1. **使用热重载**：`make dev` 启动后，专注于代码编写，无需手动重启\n2. **查看日志**：热重载模式下日志有颜色区分，更易阅读\n3. **错误处理**：编译错误会显示在控制台，修复后自动重新编译\n4. **配置更新**：修改 `.env` 或配置文件也会触发重启\n\n## 版本管理\n\n### 升级版本\n\n只需修改根目录的 `VERSION` 文件：\n\n```bash\n# 编辑 VERSION 文件\necho \"v1.1.0\" > ../VERSION\n\n# 重新构建即可\nmake build\n```\n\n所有构建产物会自动包含新版本号，无需修改代码！\n\n### 查看版本信息\n\n```bash\n# 查看项目版本信息\nmake info\n\n# 启动服务器后查看版本\ncurl http://localhost:3000/health | jq '.version'\n\n# 输出示例：\n# {\n#   \"version\": \"v1.0.0\",\n#   \"buildTime\": \"2025-01-15_10:30:45_UTC\",\n#   \"gitCommit\": \"abc1234\"\n# }\n```\n\n## 许可证\n\nMIT License\n\n## 贡献\n\n欢迎提交 Issue 和 Pull Request！\n\n---\n\n**注意**: 这是 Claude Proxy 的 Go 语言重写版本，完整实现了原 TypeScript 版本的所有功能，并提供了更好的性能和部署体验。\n"
  },
  {
    "path": "backend-go/build.sh",
    "content": "#!/bin/bash\n\n# Claude Proxy Go 版本构建脚本\n\nset -e\n\n# 版本信息 - 从根目录 VERSION 文件读取\nVERSION=$(cat ../VERSION 2>/dev/null || echo \"v0.0.0-dev\")\nBUILD_TIME=$(date '+%Y-%m-%d_%H:%M:%S_%Z')\nGIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo \"unknown\")\n\n# 构建标志\nLDFLAGS=\"-X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.GitCommit=${GIT_COMMIT}\"\n\necho \"🚀 开始构建 Claude Proxy Go 版本...\"\necho \"📌 版本: ${VERSION}\"\necho \"🕐 构建时间: ${BUILD_TIME}\"\necho \"🔖 Git提交: ${GIT_COMMIT}\"\necho \"\"\n\n# 检查前端构建产物是否存在\nif [ ! -d \"../frontend/dist\" ]; then\n    echo \"❌ 前端构建产物不存在，请先构建前端：\"\n    echo \"   cd ../frontend && npm run build\"\n    exit 1\nfi\n\n# 创建 frontend/dist 目录并复制前端资源\necho \"📦 复制前端资源...\"\nrm -rf frontend/dist\nmkdir -p frontend/dist\ncp -r ../frontend/dist/* frontend/dist/\n\n# 下载依赖\necho \"📥 下载 Go 依赖...\"\ngo mod download\ngo mod tidy\n\n# 创建输出目录\nmkdir -p dist\n\n# 构建二进制文件\necho \"🔨 构建二进制文件...\"\n\n# Linux\necho \"  - 构建 Linux (amd64)...\"\nCGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags \"${LDFLAGS}\" -o dist/claude-proxy-linux-amd64 .\n\n# Linux ARM64\necho \"  - 构建 Linux (arm64)...\"\nCGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags \"${LDFLAGS}\" -o dist/claude-proxy-linux-arm64 .\n\n# macOS\necho \"  - 构建 macOS (amd64)...\"\nCGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags \"${LDFLAGS}\" -o dist/claude-proxy-darwin-amd64 .\n\n# macOS ARM64 (M1/M2)\necho \"  - 构建 macOS (arm64)...\"\nCGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags \"${LDFLAGS}\" -o dist/claude-proxy-darwin-arm64 .\n\n# Windows\necho \"  - 构建 Windows (amd64)...\"\nCGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags \"${LDFLAGS}\" -o dist/claude-proxy-windows-amd64.exe .\n\necho \"\"\necho \"✅ 构建完成！\"\necho \"\"\necho \"📁 构建产物位于 dist/ 目录：\"\nls -lh dist/\n\necho \"\"\necho \"💡 使用方法：\"\necho \"  1. 复制对应平台的二进制文件到目标机器\"\necho \"  2. 创建 .env 文件配置环境变量\"\necho \"  3. 运行: ./claude-proxy-linux-amd64\"\necho \"\"\necho \"📌 版本信息已注入到二进制文件中\"\n\n"
  },
  {
    "path": "backend-go/docs/MALFORMED_TOOLCALL_MEMO.md",
    "content": "# 畸形 tool_call 问题备忘录\n\n> 创建时间: 2025-12-19\n> 状态: 待观察，暂不修复\n\n## 问题描述\n\n上游 Claude API 在流式返回时，偶尔会在同一个 `content_block` 中错误地发送多个工具调用的参数。\n\n### 表现形式\n\n1. **参数拼接**：两个工具的 JSON 参数被拼接成无效格式\n   ```json\n   {\"command\": \"git diff --stat\", \"description\": \"...\"}{\"command\": \"git diff xxx\", \"description\": \"...\"}\n   ```\n\n2. **元数据缺失**：第二个工具缺少必要的 `name` 和 `id`\n\n3. **下游解析失败**：客户端（如 Claude Code）收到畸形数据后可能无法正确解析\n\n### 日志示例\n\n```\n2025/12/19 11:00:51.203101 🛰️  上游流式响应合成内容:\n...\nTool Call: Bash({\"command\": \"git diff --stat\", \"description\": \"获取变更统计摘要\"}{\"command\": \"git diff backend-go/internal/providers/claude.go\", \"description\": \"获取 claude.go 的详细变更内容\"}) [ID: toolu_01S6L3ngcGA9XKQrT1o2PLQa]\n```\n\n## 曾尝试的修复方案\n\n### 方案 1: 实时流处理修复（已放弃）\n\n在 SSE 流传输过程中实时检测并修复畸形数据。\n\n**实现内容**：\n- `toolCallFixer` 结构体跟踪状态\n- `findJSONObjectBoundary()` 使用状态机检测 JSON 边界\n- `inferToolName()` 根据参数推断工具名称\n- `shouldFilterStop()` 过滤重复的 stop 事件\n- `cleanupOnStop()` 清理内存\n\n**放弃原因**：\n- 逻辑复杂，经过多轮 Codex Review 仍有边缘问题\n- 合成 block 的 index 可能与后续上游 index 冲突\n- 需要处理重复 `content_block_stop` 事件\n- 内存管理复杂\n\n### 方案 2: 流结束后修复（未实现）\n\n在流式响应完全结束后，检测并修复拼接的 tool_call。\n\n**优势**：\n- 逻辑简单：只在流结束时处理一次\n- 无状态冲突：不需要实时跟踪 block index\n\n**未实现原因**：\n- 问题发生频率较低\n- 等待上游修复\n\n## 工具名称推断规则\n\n如果将来需要实现修复，可根据参数 key 组合推断工具类型：\n\n| 参数组合 | 工具名称 |\n|---------|---------|\n| `file_path` + `content` | Write |\n| `file_path` + `old_string` + `new_string` | Edit |\n| `file_path` (仅) | Read |\n| `command` | Bash |\n| `pattern` + `output_mode`/`glob`/`type` | Grep |\n| `pattern` (仅) | Glob |\n| `url` | WebFetch |\n| `query` | WebSearch |\n| `todos` | TodoWrite |\n| `prompt` + `subagent_type` | Task |\n\n## 相关文件\n\n- `internal/providers/claude.go` - Claude Provider 流式处理\n- `internal/utils/stream_synthesizer.go` - 日志合成器\n\n## 后续行动\n\n- [ ] 持续观察问题发生频率\n- [ ] 如频繁触发，考虑实现\"流结束后修复\"方案\n- [ ] 关注上游 Claude API 是否修复此问题\n"
  },
  {
    "path": "backend-go/go.mod",
    "content": "module github.com/BenedictKing/claude-proxy\n\ngo 1.22\n\nrequire (\n\tgithub.com/fsnotify/fsnotify v1.7.0\n\tgithub.com/gin-gonic/gin v1.10.0\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/joho/godotenv v1.5.1\n\tgithub.com/stretchr/testify v1.9.0\n\tgithub.com/tidwall/gjson v1.18.0\n\tgithub.com/tidwall/sjson v1.2.5\n\tgopkg.in/natefinch/lumberjack.v2 v2.2.1\n\tmodernc.org/sqlite v1.34.4\n)\n\nrequire (\n\tgithub.com/bytedance/sonic v1.11.6 // indirect\n\tgithub.com/bytedance/sonic/loader v0.1.1 // indirect\n\tgithub.com/cloudwego/base64x v0.1.4 // indirect\n\tgithub.com/cloudwego/iasm v0.2.0 // indirect\n\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n\tgithub.com/dustin/go-humanize v1.0.1 // indirect\n\tgithub.com/gabriel-vasile/mimetype v1.4.3 // indirect\n\tgithub.com/gin-contrib/sse v0.1.0 // indirect\n\tgithub.com/go-playground/locales v0.14.1 // indirect\n\tgithub.com/go-playground/universal-translator v0.18.1 // indirect\n\tgithub.com/go-playground/validator/v10 v10.20.0 // indirect\n\tgithub.com/goccy/go-json v0.10.2 // indirect\n\tgithub.com/hashicorp/golang-lru/v2 v2.0.7 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/klauspost/cpuid/v2 v2.2.7 // indirect\n\tgithub.com/leodido/go-urn v1.4.0 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.2 // indirect\n\tgithub.com/ncruces/go-strftime v0.1.9 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.2.2 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.0 // indirect\n\tgithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect\n\tgithub.com/tidwall/match v1.1.1 // indirect\n\tgithub.com/tidwall/pretty v1.2.0 // indirect\n\tgithub.com/twitchyliquid64/golang-asm v0.15.1 // indirect\n\tgithub.com/ugorji/go/codec v1.2.12 // indirect\n\tgolang.org/x/arch v0.8.0 // indirect\n\tgolang.org/x/crypto v0.23.0 // indirect\n\tgolang.org/x/net v0.25.0 // indirect\n\tgolang.org/x/sys v0.22.0 // indirect\n\tgolang.org/x/text v0.15.0 // indirect\n\tgoogle.golang.org/protobuf v1.34.1 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n\tmodernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect\n\tmodernc.org/libc v1.55.3 // indirect\n\tmodernc.org/mathutil v1.6.0 // indirect\n\tmodernc.org/memory v1.8.0 // indirect\n\tmodernc.org/strutil v1.2.0 // indirect\n\tmodernc.org/token v1.1.0 // indirect\n)\n"
  },
  {
    "path": "backend-go/go.sum",
    "content": "github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=\ngithub.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=\ngithub.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=\ngithub.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=\ngithub.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=\ngithub.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=\ngithub.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=\ngithub.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=\ngithub.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=\ngithub.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=\ngithub.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=\ngithub.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=\ngithub.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=\ngithub.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=\ngithub.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=\ngithub.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=\ngithub.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=\ngithub.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=\ngithub.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=\ngithub.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=\ngithub.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=\ngithub.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=\ngithub.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=\ngithub.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=\ngithub.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=\ngithub.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=\ngithub.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=\ngithub.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=\ngithub.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=\ngithub.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=\ngithub.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=\ngithub.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=\ngithub.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=\ngithub.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=\ngithub.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=\ngithub.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=\ngithub.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=\ngithub.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=\ngithub.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=\ngithub.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=\ngithub.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=\ngithub.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=\ngithub.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=\ngithub.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=\ngithub.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=\ngithub.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=\ngithub.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=\ngithub.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=\ngithub.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=\ngithub.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=\ngithub.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=\ngithub.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=\ngithub.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=\ngolang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=\ngolang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=\ngolang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=\ngolang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=\ngolang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=\ngolang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=\ngolang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=\ngolang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=\ngolang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=\ngolang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=\ngolang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=\ngolang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=\ngolang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=\ngoogle.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=\ngopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\nmodernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=\nmodernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=\nmodernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=\nmodernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=\nmodernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=\nmodernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=\nmodernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=\nmodernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=\nmodernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=\nmodernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=\nmodernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=\nmodernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=\nmodernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=\nmodernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=\nmodernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=\nmodernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=\nmodernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=\nmodernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=\nmodernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=\nmodernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=\nmodernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=\nmodernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=\nmodernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=\nmodernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=\nmodernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=\nmodernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=\nnullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=\nrsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=\n"
  },
  {
    "path": "backend-go/internal/config/config.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/utils\"\n\n\t\"github.com/fsnotify/fsnotify\"\n)\n\n// ============== 核心类型定义 ==============\n\n// UpstreamConfig 上游配置\ntype UpstreamConfig struct {\n\tBaseURL            string            `json:\"baseUrl\"`\n\tBaseURLs           []string          `json:\"baseUrls,omitempty\"` // 多 BaseURL 支持（failover 模式）\n\tAPIKeys            []string          `json:\"apiKeys\"`\n\tHistoricalAPIKeys  []string          `json:\"historicalApiKeys,omitempty\"` // 历史 API Key（用于统计聚合，换 Key 后保留旧 Key 的统计数据）\n\tServiceType        string            `json:\"serviceType\"`                 // gemini, openai, claude\n\tName               string            `json:\"name,omitempty\"`\n\tDescription        string            `json:\"description,omitempty\"`\n\tWebsite            string            `json:\"website,omitempty\"`\n\tInsecureSkipVerify bool              `json:\"insecureSkipVerify,omitempty\"`\n\tModelMapping       map[string]string `json:\"modelMapping,omitempty\"`\n\t// 多渠道调度相关字段\n\tPriority       int        `json:\"priority\"`                 // 渠道优先级（数字越小优先级越高，默认按索引）\n\tStatus         string     `json:\"status\"`                   // 渠道状态：active（正常）, suspended（暂停）, disabled（备用池）\n\tPromotionUntil *time.Time `json:\"promotionUntil,omitempty\"` // 促销期截止时间，在此期间内优先使用此渠道（忽略trace亲和）\n\tLowQuality     bool       `json:\"lowQuality,omitempty\"`     // 低质量渠道标记：启用后强制本地估算 token，偏差>5%时使用本地值\n\t// Gemini 特定配置\n\tInjectDummyThoughtSignature bool `json:\"injectDummyThoughtSignature,omitempty\"` // 给空 thought_signature 注入 dummy 值（兼容 x666.me 等要求必须有该字段的 API）\n\tStripThoughtSignature       bool `json:\"stripThoughtSignature,omitempty\"`       // 移除 thought_signature 字段（兼容旧版 Gemini API）\n}\n\n// UpstreamUpdate 用于部分更新 UpstreamConfig\ntype UpstreamUpdate struct {\n\tName               *string           `json:\"name\"`\n\tServiceType        *string           `json:\"serviceType\"`\n\tBaseURL            *string           `json:\"baseUrl\"`\n\tBaseURLs           []string          `json:\"baseUrls\"`\n\tAPIKeys            []string          `json:\"apiKeys\"`\n\tDescription        *string           `json:\"description\"`\n\tWebsite            *string           `json:\"website\"`\n\tInsecureSkipVerify *bool             `json:\"insecureSkipVerify\"`\n\tModelMapping       map[string]string `json:\"modelMapping\"`\n\t// 多渠道调度相关字段\n\tPriority       *int       `json:\"priority\"`\n\tStatus         *string    `json:\"status\"`\n\tPromotionUntil *time.Time `json:\"promotionUntil\"`\n\tLowQuality     *bool      `json:\"lowQuality\"`\n\t// Gemini 特定配置\n\tInjectDummyThoughtSignature *bool `json:\"injectDummyThoughtSignature\"`\n\tStripThoughtSignature       *bool `json:\"stripThoughtSignature\"`\n}\n\n// Config 配置结构\ntype Config struct {\n\tUpstream        []UpstreamConfig `json:\"upstream\"`\n\tCurrentUpstream int              `json:\"currentUpstream,omitempty\"` // 已废弃：旧格式兼容用\n\tLoadBalance     string           `json:\"loadBalance\"`               // round-robin, random, failover\n\n\t// Responses 接口专用配置（独立于 /v1/messages）\n\tResponsesUpstream        []UpstreamConfig `json:\"responsesUpstream\"`\n\tCurrentResponsesUpstream int              `json:\"currentResponsesUpstream,omitempty\"` // 已废弃：旧格式兼容用\n\tResponsesLoadBalance     string           `json:\"responsesLoadBalance\"`\n\n\t// Gemini 接口专用配置（独立于 /v1/messages 和 /v1/responses）\n\tGeminiUpstream    []UpstreamConfig `json:\"geminiUpstream\"`\n\tGeminiLoadBalance string           `json:\"geminiLoadBalance\"`\n\n\t// Fuzzy 模式：启用时模糊处理错误，所有非 2xx 错误都尝试 failover\n\tFuzzyModeEnabled bool `json:\"fuzzyModeEnabled\"`\n}\n\n// FailedKey 失败密钥记录\ntype FailedKey struct {\n\tTimestamp    time.Time\n\tFailureCount int\n}\n\n// ConfigManager 配置管理器\ntype ConfigManager struct {\n\tmu              sync.RWMutex\n\tconfig          Config\n\tconfigFile      string\n\twatcher         *fsnotify.Watcher\n\tfailedKeysCache map[string]*FailedKey\n\tkeyRecoveryTime time.Duration\n\tmaxFailureCount int\n\tstopChan        chan struct{} // 用于通知 goroutine 停止\n\tcloseOnce       sync.Once     // 确保 Close 只执行一次\n}\n\n// ============== 核心共享方法 ==============\n\n// GetConfig 获取配置（返回深拷贝，确保并发安全）\nfunc (cm *ConfigManager) GetConfig() Config {\n\tcm.mu.RLock()\n\tdefer cm.mu.RUnlock()\n\n\t// 深拷贝整个 Config 结构体\n\tcloned := cm.config\n\n\t// 深拷贝 Upstream slice\n\tif cm.config.Upstream != nil {\n\t\tcloned.Upstream = make([]UpstreamConfig, len(cm.config.Upstream))\n\t\tfor i := range cm.config.Upstream {\n\t\t\tcloned.Upstream[i] = *cm.config.Upstream[i].Clone()\n\t\t}\n\t}\n\n\t// 深拷贝 ResponsesUpstream slice\n\tif cm.config.ResponsesUpstream != nil {\n\t\tcloned.ResponsesUpstream = make([]UpstreamConfig, len(cm.config.ResponsesUpstream))\n\t\tfor i := range cm.config.ResponsesUpstream {\n\t\t\tcloned.ResponsesUpstream[i] = *cm.config.ResponsesUpstream[i].Clone()\n\t\t}\n\t}\n\n\t// 深拷贝 GeminiUpstream slice\n\tif cm.config.GeminiUpstream != nil {\n\t\tcloned.GeminiUpstream = make([]UpstreamConfig, len(cm.config.GeminiUpstream))\n\t\tfor i := range cm.config.GeminiUpstream {\n\t\t\tcloned.GeminiUpstream[i] = *cm.config.GeminiUpstream[i].Clone()\n\t\t}\n\t}\n\n\treturn cloned\n}\n\n// GetNextAPIKey 获取下一个 API 密钥（纯 failover 模式）\n// apiType: 接口类型（Messages/Responses/Gemini），用于日志标签前缀\nfunc (cm *ConfigManager) GetNextAPIKey(upstream *UpstreamConfig, failedKeys map[string]bool, apiType string) (string, error) {\n\tif len(upstream.APIKeys) == 0 {\n\t\treturn \"\", fmt.Errorf(\"上游 %s 没有可用的API密钥\", upstream.Name)\n\t}\n\n\t// 单 Key 直接返回\n\tif len(upstream.APIKeys) == 1 {\n\t\treturn upstream.APIKeys[0], nil\n\t}\n\n\t// 筛选可用密钥：排除临时失败密钥和内存中的失败密钥\n\tavailableKeys := []string{}\n\tfor _, key := range upstream.APIKeys {\n\t\tif !failedKeys[key] && !cm.isKeyFailed(key) {\n\t\t\tavailableKeys = append(availableKeys, key)\n\t\t}\n\t}\n\n\tif len(availableKeys) == 0 {\n\t\t// 如果所有密钥都失效，尝试选择失败时间最早的密钥（恢复尝试）\n\t\tvar oldestFailedKey string\n\t\toldestTime := time.Now()\n\n\t\tcm.mu.RLock()\n\t\tfor _, key := range upstream.APIKeys {\n\t\t\tif !failedKeys[key] { // 排除本次请求已经尝试过的密钥\n\t\t\t\tif failure, exists := cm.failedKeysCache[key]; exists {\n\t\t\t\t\tif failure.Timestamp.Before(oldestTime) {\n\t\t\t\t\t\toldestTime = failure.Timestamp\n\t\t\t\t\t\toldestFailedKey = key\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tcm.mu.RUnlock()\n\n\t\tif oldestFailedKey != \"\" {\n\t\t\tlog.Printf(\"[%s-Key] 警告: 所有密钥都失效，尝试最早失败的密钥: %s\", apiType, utils.MaskAPIKey(oldestFailedKey))\n\t\t\treturn oldestFailedKey, nil\n\t\t}\n\n\t\treturn \"\", fmt.Errorf(\"上游 %s 的所有API密钥都暂时不可用\", upstream.Name)\n\t}\n\n\t// 纯 failover：按优先级顺序选择第一个可用密钥\n\tselectedKey := availableKeys[0]\n\t// 获取该密钥在原始列表中的索引\n\tkeyIndex := 0\n\tfor i, key := range upstream.APIKeys {\n\t\tif key == selectedKey {\n\t\t\tkeyIndex = i + 1\n\t\t\tbreak\n\t\t}\n\t}\n\tlog.Printf(\"[%s-Key] 故障转移选择密钥 %s (%d/%d)\", apiType, utils.MaskAPIKey(selectedKey), keyIndex, len(upstream.APIKeys))\n\treturn selectedKey, nil\n}\n\n// MarkKeyAsFailed 标记密钥失败\n// apiType: 接口类型（Messages/Responses/Gemini），用于日志标签前缀\nfunc (cm *ConfigManager) MarkKeyAsFailed(apiKey string, apiType string) {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif failure, exists := cm.failedKeysCache[apiKey]; exists {\n\t\tfailure.FailureCount++\n\t\tfailure.Timestamp = time.Now()\n\t} else {\n\t\tcm.failedKeysCache[apiKey] = &FailedKey{\n\t\t\tTimestamp:    time.Now(),\n\t\t\tFailureCount: 1,\n\t\t}\n\t}\n\n\tfailure := cm.failedKeysCache[apiKey]\n\trecoveryTime := cm.keyRecoveryTime\n\tif failure.FailureCount > cm.maxFailureCount {\n\t\trecoveryTime = cm.keyRecoveryTime * 2\n\t}\n\n\tlog.Printf(\"[%s-Key] 标记API密钥失败: %s (失败次数: %d, 恢复时间: %v)\",\n\t\tapiType, utils.MaskAPIKey(apiKey), failure.FailureCount, recoveryTime)\n}\n\n// isKeyFailed 检查密钥是否失败\nfunc (cm *ConfigManager) isKeyFailed(apiKey string) bool {\n\tcm.mu.RLock()\n\tdefer cm.mu.RUnlock()\n\n\tfailure, exists := cm.failedKeysCache[apiKey]\n\tif !exists {\n\t\treturn false\n\t}\n\n\trecoveryTime := cm.keyRecoveryTime\n\tif failure.FailureCount > cm.maxFailureCount {\n\t\trecoveryTime = cm.keyRecoveryTime * 2\n\t}\n\n\treturn time.Since(failure.Timestamp) < recoveryTime\n}\n\n// IsKeyFailed 检查 Key 是否在冷却期（公开方法）\nfunc (cm *ConfigManager) IsKeyFailed(apiKey string) bool {\n\treturn cm.isKeyFailed(apiKey)\n}\n\n// clearFailedKeysForUpstream 清理指定渠道的所有失败 key 记录\n// 当渠道被删除时调用，避免内存泄漏和冷却状态残留\n// apiType: 接口类型（Messages/Responses/Gemini），用于日志标签前缀\nfunc (cm *ConfigManager) clearFailedKeysForUpstream(upstream *UpstreamConfig, apiType string) {\n\tfor _, key := range upstream.APIKeys {\n\t\tif _, exists := cm.failedKeysCache[key]; exists {\n\t\t\tdelete(cm.failedKeysCache, key)\n\t\t\tlog.Printf(\"[%s-Key] 已清理被删除渠道 %s 的失败密钥记录: %s\", apiType, upstream.Name, utils.MaskAPIKey(key))\n\t\t}\n\t}\n}\n\n// cleanupExpiredFailures 清理过期的失败记录\nfunc (cm *ConfigManager) cleanupExpiredFailures() {\n\tticker := time.NewTicker(1 * time.Minute)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-cm.stopChan:\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\tcm.mu.Lock()\n\t\t\tnow := time.Now()\n\t\t\tfor key, failure := range cm.failedKeysCache {\n\t\t\t\trecoveryTime := cm.keyRecoveryTime\n\t\t\t\tif failure.FailureCount > cm.maxFailureCount {\n\t\t\t\t\trecoveryTime = cm.keyRecoveryTime * 2\n\t\t\t\t}\n\n\t\t\t\tif now.Sub(failure.Timestamp) > recoveryTime {\n\t\t\t\t\tdelete(cm.failedKeysCache, key)\n\t\t\t\t\tlog.Printf(\"[Config-Key] API密钥 %s 已从失败列表中恢复\", utils.MaskAPIKey(key))\n\t\t\t\t}\n\t\t\t}\n\t\t\tcm.mu.Unlock()\n\t\t}\n\t}\n}\n\n// ============== Fuzzy 模式相关方法 ==============\n\n// GetFuzzyModeEnabled 获取 Fuzzy 模式状态\nfunc (cm *ConfigManager) GetFuzzyModeEnabled() bool {\n\tcm.mu.RLock()\n\tdefer cm.mu.RUnlock()\n\treturn cm.config.FuzzyModeEnabled\n}\n\n// SetFuzzyModeEnabled 设置 Fuzzy 模式状态\nfunc (cm *ConfigManager) SetFuzzyModeEnabled(enabled bool) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tcm.config.FuzzyModeEnabled = enabled\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn err\n\t}\n\n\tstatus := \"关闭\"\n\tif enabled {\n\t\tstatus = \"启用\"\n\t}\n\tlog.Printf(\"[Config-FuzzyMode] Fuzzy 模式已%s\", status)\n\treturn nil\n}\n"
  },
  {
    "path": "backend-go/internal/config/config_baseurl_test.go",
    "content": "package config\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n)\n\n// TestUpdateUpstream_BaseURLConsistency 测试更新 baseUrl 时的一致性\n// 覆盖场景：\n// 1. 只更新 baseUrl 时，baseUrls 应被清空\n// 2. 只更新 baseUrls 时，baseUrl 应保持不变\n// 3. 同时更新 baseUrl 和 baseUrls 时，两者应独立更新\n// 4. 都不更新时，两者应保持原值\nfunc TestUpdateUpstream_BaseURLConsistency(t *testing.T) {\n\t// 创建临时配置文件\n\ttempDir := t.TempDir()\n\tconfigPath := filepath.Join(tempDir, \"config.json\")\n\tinitialConfig := `{\n\t\t\"upstream\": [{\n\t\t\t\"name\": \"test-channel\",\n\t\t\t\"baseUrl\": \"https://old.example.com\",\n\t\t\t\"baseUrls\": [\"https://old-1.example.com\", \"https://old-2.example.com\"],\n\t\t\t\"apiKeys\": [\"test-key\"],\n\t\t\t\"serviceType\": \"claude\"\n\t\t}],\n\t\t\"loadBalance\": \"failover\"\n\t}`\n\tif err := os.WriteFile(configPath, []byte(initialConfig), 0644); err != nil {\n\t\tt.Fatalf(\"写入初始配置失败: %v\", err)\n\t}\n\n\t// 初始化配置管理器\n\tcm, err := NewConfigManager(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"初始化配置管理器失败: %v\", err)\n\t}\n\tdefer cm.Close()\n\n\ttests := []struct {\n\t\tname            string\n\t\tupdates         UpstreamUpdate\n\t\twantBaseURL     string\n\t\twantBaseURLs    []string\n\t\twantBaseURLsNil bool // 期望 baseUrls 为 nil（而非空切片）\n\t}{\n\t\t{\n\t\t\tname: \"只更新 baseUrl 时 baseUrls 应被清空\",\n\t\t\tupdates: UpstreamUpdate{\n\t\t\t\tBaseURL: strPtr(\"https://new.example.com\"),\n\t\t\t},\n\t\t\twantBaseURL:     \"https://new.example.com\",\n\t\t\twantBaseURLsNil: true,\n\t\t},\n\t\t{\n\t\t\tname: \"只更新 baseUrls 时 baseUrl 应保持不变\",\n\t\t\tupdates: UpstreamUpdate{\n\t\t\t\tBaseURLs: []string{\"https://urls-1.example.com\", \"https://urls-2.example.com\"},\n\t\t\t},\n\t\t\twantBaseURL:  \"https://new.example.com\", // 保持上个测试的值\n\t\t\twantBaseURLs: []string{\"https://urls-1.example.com\", \"https://urls-2.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"同时更新 baseUrl 和 baseUrls 时两者独立更新\",\n\t\t\tupdates: UpstreamUpdate{\n\t\t\t\tBaseURL:  strPtr(\"https://both-base.example.com\"),\n\t\t\t\tBaseURLs: []string{\"https://both-urls-1.example.com\", \"https://both-urls-2.example.com\"},\n\t\t\t},\n\t\t\twantBaseURL:  \"https://both-base.example.com\",\n\t\t\twantBaseURLs: []string{\"https://both-urls-1.example.com\", \"https://both-urls-2.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname:         \"都不更新时保持原值\",\n\t\t\tupdates:      UpstreamUpdate{Name: strPtr(\"renamed-channel\")},\n\t\t\twantBaseURL:  \"https://both-base.example.com\",\n\t\t\twantBaseURLs: []string{\"https://both-urls-1.example.com\", \"https://both-urls-2.example.com\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t_, err := cm.UpdateUpstream(0, tt.updates)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"UpdateUpstream 失败: %v\", err)\n\t\t\t}\n\n\t\t\tcfg := cm.GetConfig()\n\t\t\tupstream := cfg.Upstream[0]\n\n\t\t\tif upstream.BaseURL != tt.wantBaseURL {\n\t\t\t\tt.Errorf(\"BaseURL = %q, want %q\", upstream.BaseURL, tt.wantBaseURL)\n\t\t\t}\n\n\t\t\tif tt.wantBaseURLsNil {\n\t\t\t\tif upstream.BaseURLs != nil {\n\t\t\t\t\tt.Errorf(\"BaseURLs = %v, want nil\", upstream.BaseURLs)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif len(upstream.BaseURLs) != len(tt.wantBaseURLs) {\n\t\t\t\t\tt.Errorf(\"BaseURLs length = %d, want %d\", len(upstream.BaseURLs), len(tt.wantBaseURLs))\n\t\t\t\t} else {\n\t\t\t\t\tfor i, url := range upstream.BaseURLs {\n\t\t\t\t\t\tif url != tt.wantBaseURLs[i] {\n\t\t\t\t\t\t\tt.Errorf(\"BaseURLs[%d] = %q, want %q\", i, url, tt.wantBaseURLs[i])\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestUpdateResponsesUpstream_BaseURLConsistency 测试 Responses 渠道的 baseUrl 一致性\nfunc TestUpdateResponsesUpstream_BaseURLConsistency(t *testing.T) {\n\ttempDir := t.TempDir()\n\tconfigPath := filepath.Join(tempDir, \"config.json\")\n\tinitialConfig := `{\n\t\t\"upstream\": [],\n\t\t\"responsesUpstream\": [{\n\t\t\t\"name\": \"responses-channel\",\n\t\t\t\"baseUrl\": \"https://old.responses.com\",\n\t\t\t\"baseUrls\": [\"https://old-1.responses.com\", \"https://old-2.responses.com\"],\n\t\t\t\"apiKeys\": [\"test-key\"],\n\t\t\t\"serviceType\": \"claude\"\n\t\t}],\n\t\t\"loadBalance\": \"failover\",\n\t\t\"responsesLoadBalance\": \"failover\"\n\t}`\n\tif err := os.WriteFile(configPath, []byte(initialConfig), 0644); err != nil {\n\t\tt.Fatalf(\"写入初始配置失败: %v\", err)\n\t}\n\n\tcm, err := NewConfigManager(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"初始化配置管理器失败: %v\", err)\n\t}\n\tdefer cm.Close()\n\n\t// 测试：只更新 baseUrl 时 baseUrls 应被清空\n\tt.Run(\"只更新 baseUrl 时 baseUrls 应被清空\", func(t *testing.T) {\n\t\t_, err := cm.UpdateResponsesUpstream(0, UpstreamUpdate{\n\t\t\tBaseURL: strPtr(\"https://new.responses.com\"),\n\t\t})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"UpdateResponsesUpstream 失败: %v\", err)\n\t\t}\n\n\t\tcfg := cm.GetConfig()\n\t\tupstream := cfg.ResponsesUpstream[0]\n\n\t\tif upstream.BaseURL != \"https://new.responses.com\" {\n\t\t\tt.Errorf(\"BaseURL = %q, want %q\", upstream.BaseURL, \"https://new.responses.com\")\n\t\t}\n\t\tif upstream.BaseURLs != nil {\n\t\t\tt.Errorf(\"BaseURLs = %v, want nil\", upstream.BaseURLs)\n\t\t}\n\t})\n}\n\n// TestUpdateGeminiUpstream_BaseURLConsistency 测试 Gemini 渠道的 baseUrl 一致性\nfunc TestUpdateGeminiUpstream_BaseURLConsistency(t *testing.T) {\n\ttempDir := t.TempDir()\n\tconfigPath := filepath.Join(tempDir, \"config.json\")\n\tinitialConfig := `{\n\t\t\"upstream\": [],\n\t\t\"geminiUpstream\": [{\n\t\t\t\"name\": \"gemini-channel\",\n\t\t\t\"baseUrl\": \"https://old.gemini.com\",\n\t\t\t\"baseUrls\": [\"https://old-1.gemini.com\", \"https://old-2.gemini.com\"],\n\t\t\t\"apiKeys\": [\"test-key\"],\n\t\t\t\"serviceType\": \"gemini\"\n\t\t}],\n\t\t\"loadBalance\": \"failover\",\n\t\t\"geminiLoadBalance\": \"failover\"\n\t}`\n\tif err := os.WriteFile(configPath, []byte(initialConfig), 0644); err != nil {\n\t\tt.Fatalf(\"写入初始配置失败: %v\", err)\n\t}\n\n\tcm, err := NewConfigManager(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"初始化配置管理器失败: %v\", err)\n\t}\n\tdefer cm.Close()\n\n\t// 测试：只更新 baseUrl 时 baseUrls 应被清空\n\tt.Run(\"只更新 baseUrl 时 baseUrls 应被清空\", func(t *testing.T) {\n\t\t_, err := cm.UpdateGeminiUpstream(0, UpstreamUpdate{\n\t\t\tBaseURL: strPtr(\"https://new.gemini.com\"),\n\t\t})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"UpdateGeminiUpstream 失败: %v\", err)\n\t\t}\n\n\t\tcfg := cm.GetConfig()\n\t\tupstream := cfg.GeminiUpstream[0]\n\n\t\tif upstream.BaseURL != \"https://new.gemini.com\" {\n\t\t\tt.Errorf(\"BaseURL = %q, want %q\", upstream.BaseURL, \"https://new.gemini.com\")\n\t\t}\n\t\tif upstream.BaseURLs != nil {\n\t\t\tt.Errorf(\"BaseURLs = %v, want nil\", upstream.BaseURLs)\n\t\t}\n\t})\n}\n\n// TestGetAllBaseURLs_Priority 测试 GetAllBaseURLs 的优先级逻辑\nfunc TestGetAllBaseURLs_Priority(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tupstream UpstreamConfig\n\t\twant     []string\n\t}{\n\t\t{\n\t\t\tname: \"baseUrls 非空时优先返回 baseUrls\",\n\t\t\tupstream: UpstreamConfig{\n\t\t\t\tBaseURL:  \"https://single.example.com\",\n\t\t\t\tBaseURLs: []string{\"https://multi-1.example.com\", \"https://multi-2.example.com\"},\n\t\t\t},\n\t\t\twant: []string{\"https://multi-1.example.com\", \"https://multi-2.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"baseUrls 为空时回退到 baseUrl\",\n\t\t\tupstream: UpstreamConfig{\n\t\t\t\tBaseURL:  \"https://single.example.com\",\n\t\t\t\tBaseURLs: nil,\n\t\t\t},\n\t\t\twant: []string{\"https://single.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"两者都为空时返回 nil\",\n\t\t\tupstream: UpstreamConfig{\n\t\t\t\tBaseURL:  \"\",\n\t\t\t\tBaseURLs: nil,\n\t\t\t},\n\t\t\twant: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"baseUrls 为空切片时回退到 baseUrl\",\n\t\t\tupstream: UpstreamConfig{\n\t\t\t\tBaseURL:  \"https://single.example.com\",\n\t\t\t\tBaseURLs: []string{},\n\t\t\t},\n\t\t\twant: []string{\"https://single.example.com\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := tt.upstream.GetAllBaseURLs()\n\n\t\t\tif len(got) != len(tt.want) {\n\t\t\t\tt.Errorf(\"GetAllBaseURLs() length = %d, want %d\", len(got), len(tt.want))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfor i := range got {\n\t\t\t\tif got[i] != tt.want[i] {\n\t\t\t\t\tt.Errorf(\"GetAllBaseURLs()[%d] = %q, want %q\", i, got[i], tt.want[i])\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestGetEffectiveBaseURL_Priority 测试 GetEffectiveBaseURL 的优先级逻辑\nfunc TestGetEffectiveBaseURL_Priority(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tupstream UpstreamConfig\n\t\twant     string\n\t}{\n\t\t{\n\t\t\tname: \"baseUrl 非空时优先返回 baseUrl\",\n\t\t\tupstream: UpstreamConfig{\n\t\t\t\tBaseURL:  \"https://single.example.com\",\n\t\t\t\tBaseURLs: []string{\"https://multi-1.example.com\", \"https://multi-2.example.com\"},\n\t\t\t},\n\t\t\twant: \"https://single.example.com\",\n\t\t},\n\t\t{\n\t\t\tname: \"baseUrl 为空时回退到 baseUrls[0]\",\n\t\t\tupstream: UpstreamConfig{\n\t\t\t\tBaseURL:  \"\",\n\t\t\t\tBaseURLs: []string{\"https://multi-1.example.com\", \"https://multi-2.example.com\"},\n\t\t\t},\n\t\t\twant: \"https://multi-1.example.com\",\n\t\t},\n\t\t{\n\t\t\tname: \"两者都为空时返回空字符串\",\n\t\t\tupstream: UpstreamConfig{\n\t\t\t\tBaseURL:  \"\",\n\t\t\t\tBaseURLs: nil,\n\t\t\t},\n\t\t\twant: \"\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := tt.upstream.GetEffectiveBaseURL()\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"GetEffectiveBaseURL() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestDeduplicateBaseURLs 测试 BaseURLs 去重逻辑\nfunc TestDeduplicateBaseURLs(t *testing.T) {\n\ttests := []struct {\n\t\tname  string\n\t\tinput []string\n\t\twant  []string\n\t}{\n\t\t{\n\t\t\tname:  \"精确重复应去重\",\n\t\t\tinput: []string{\"https://a.com\", \"https://b.com\", \"https://a.com\"},\n\t\t\twant:  []string{\"https://a.com\", \"https://b.com\"},\n\t\t},\n\t\t{\n\t\t\tname:  \"末尾斜杠差异应视为相同\",\n\t\t\tinput: []string{\"https://a.com\", \"https://a.com/\"},\n\t\t\twant:  []string{\"https://a.com\"},\n\t\t},\n\t\t{\n\t\t\tname:  \"末尾井号差异应视为相同\",\n\t\t\tinput: []string{\"https://a.com\", \"https://a.com#\"},\n\t\t\twant:  []string{\"https://a.com\"},\n\t\t},\n\t\t{\n\t\t\tname:  \"保持原始顺序\",\n\t\t\tinput: []string{\"https://c.com\", \"https://a.com\", \"https://b.com\"},\n\t\t\twant:  []string{\"https://c.com\", \"https://a.com\", \"https://b.com\"},\n\t\t},\n\t\t{\n\t\t\tname:  \"单个元素不变\",\n\t\t\tinput: []string{\"https://only.com\"},\n\t\t\twant:  []string{\"https://only.com\"},\n\t\t},\n\t\t{\n\t\t\tname:  \"空切片返回空切片\",\n\t\t\tinput: []string{},\n\t\t\twant:  []string{},\n\t\t},\n\t\t{\n\t\t\tname:  \"nil 返回 nil\",\n\t\t\tinput: nil,\n\t\t\twant:  nil,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := deduplicateBaseURLs(tt.input)\n\n\t\t\tif len(got) != len(tt.want) {\n\t\t\t\tt.Errorf(\"deduplicateBaseURLs() length = %d, want %d\", len(got), len(tt.want))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfor i := range got {\n\t\t\t\tif got[i] != tt.want[i] {\n\t\t\t\t\tt.Errorf(\"deduplicateBaseURLs()[%d] = %q, want %q\", i, got[i], tt.want[i])\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestAddUpstream_BaseURLDeduplication 测试添加渠道时的 BaseURLs 去重\nfunc TestAddUpstream_BaseURLDeduplication(t *testing.T) {\n\ttempDir := t.TempDir()\n\tconfigPath := filepath.Join(tempDir, \"config.json\")\n\tinitialConfig := `{\"upstream\": [], \"loadBalance\": \"failover\"}`\n\tif err := os.WriteFile(configPath, []byte(initialConfig), 0644); err != nil {\n\t\tt.Fatalf(\"写入初始配置失败: %v\", err)\n\t}\n\n\tcm, err := NewConfigManager(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"初始化配置管理器失败: %v\", err)\n\t}\n\tdefer cm.Close()\n\n\t// 添加包含重复 URL 的渠道\n\terr = cm.AddUpstream(UpstreamConfig{\n\t\tName:        \"dedup-test\",\n\t\tBaseURL:     \"https://main.example.com\",\n\t\tBaseURLs:    []string{\"https://a.com\", \"https://b.com\", \"https://a.com/\", \"https://c.com\"},\n\t\tAPIKeys:     []string{\"key1\"},\n\t\tServiceType: \"claude\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"AddUpstream 失败: %v\", err)\n\t}\n\n\tcfg := cm.GetConfig()\n\tupstream := cfg.Upstream[0]\n\n\t// 期望去重后只有 3 个 URL\n\texpectedURLs := []string{\"https://a.com\", \"https://b.com\", \"https://c.com\"}\n\tif len(upstream.BaseURLs) != len(expectedURLs) {\n\t\tt.Errorf(\"BaseURLs length = %d, want %d\", len(upstream.BaseURLs), len(expectedURLs))\n\t}\n\tfor i, url := range upstream.BaseURLs {\n\t\tif url != expectedURLs[i] {\n\t\t\tt.Errorf(\"BaseURLs[%d] = %q, want %q\", i, url, expectedURLs[i])\n\t\t}\n\t}\n}\n\n// strPtr 辅助函数：返回字符串指针\nfunc strPtr(s string) *string {\n\treturn &s\n}\n"
  },
  {
    "path": "backend-go/internal/config/config_gemini.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/utils\"\n)\n\n// ============== Gemini 渠道方法 ==============\n\n// GetCurrentGeminiUpstream 获取当前 Gemini 上游配置\n// 优先选择第一个 active 状态的渠道，若无则回退到第一个渠道\nfunc (cm *ConfigManager) GetCurrentGeminiUpstream() (*UpstreamConfig, error) {\n\tcm.mu.RLock()\n\tdefer cm.mu.RUnlock()\n\n\tif len(cm.config.GeminiUpstream) == 0 {\n\t\treturn nil, fmt.Errorf(\"未配置任何 Gemini 渠道\")\n\t}\n\n\t// 优先选择第一个 active 状态的渠道\n\tfor i := range cm.config.GeminiUpstream {\n\t\tstatus := cm.config.GeminiUpstream[i].Status\n\t\tif status == \"\" || status == \"active\" {\n\t\t\treturn &cm.config.GeminiUpstream[i], nil\n\t\t}\n\t}\n\n\t// 没有 active 渠道，回退到第一个渠道\n\treturn &cm.config.GeminiUpstream[0], nil\n}\n\n// AddGeminiUpstream 添加 Gemini 上游\nfunc (cm *ConfigManager) AddGeminiUpstream(upstream UpstreamConfig) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\t// 新建渠道默认设为 active\n\tif upstream.Status == \"\" {\n\t\tupstream.Status = \"active\"\n\t}\n\n\t// 去重 API Keys 和 Base URLs\n\tupstream.APIKeys = deduplicateStrings(upstream.APIKeys)\n\tupstream.BaseURLs = deduplicateBaseURLs(upstream.BaseURLs)\n\n\tcm.config.GeminiUpstream = append(cm.config.GeminiUpstream, upstream)\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Printf(\"[Config-Upstream] 已添加 Gemini 上游: %s\", upstream.Name)\n\treturn nil\n}\n\n// UpdateGeminiUpstream 更新 Gemini 上游\n// 返回值：shouldResetMetrics 表示是否需要重置渠道指标（熔断状态）\nfunc (cm *ConfigManager) UpdateGeminiUpstream(index int, updates UpstreamUpdate) (shouldResetMetrics bool, err error) {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif index < 0 || index >= len(cm.config.GeminiUpstream) {\n\t\treturn false, fmt.Errorf(\"无效的 Gemini 上游索引: %d\", index)\n\t}\n\n\tupstream := &cm.config.GeminiUpstream[index]\n\n\tif updates.Name != nil {\n\t\tupstream.Name = *updates.Name\n\t}\n\tif updates.BaseURL != nil {\n\t\tupstream.BaseURL = *updates.BaseURL\n\t\t// 当 BaseURL 被更新且 BaseURLs 未被显式设置时，清空 BaseURLs 保持一致性\n\t\t// 避免出现 baseUrl 和 baseUrls[0] 不一致的情况\n\t\tif updates.BaseURLs == nil {\n\t\t\tupstream.BaseURLs = nil\n\t\t}\n\t}\n\tif updates.BaseURLs != nil {\n\t\tupstream.BaseURLs = deduplicateBaseURLs(updates.BaseURLs)\n\t}\n\tif updates.ServiceType != nil {\n\t\tupstream.ServiceType = *updates.ServiceType\n\t}\n\tif updates.Description != nil {\n\t\tupstream.Description = *updates.Description\n\t}\n\tif updates.Website != nil {\n\t\tupstream.Website = *updates.Website\n\t}\n\tif updates.APIKeys != nil {\n\t\t// 记录被移除的 Key 到历史列表（用于统计聚合）\n\t\tnewKeys := make(map[string]bool)\n\t\tfor _, key := range updates.APIKeys {\n\t\t\tnewKeys[key] = true\n\t\t}\n\n\t\t// 找出被移除的 Key（在旧列表中但不在新列表中）\n\t\tfor _, key := range upstream.APIKeys {\n\t\t\tif !newKeys[key] {\n\t\t\t\t// 检查是否已在历史列表中\n\t\t\t\talreadyInHistory := false\n\t\t\t\tfor _, hk := range upstream.HistoricalAPIKeys {\n\t\t\t\t\tif hk == key {\n\t\t\t\t\t\talreadyInHistory = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif !alreadyInHistory {\n\t\t\t\t\tupstream.HistoricalAPIKeys = append(upstream.HistoricalAPIKeys, key)\n\t\t\t\t\tlog.Printf(\"[Config-Upstream] Gemini 渠道 [%d] %s: Key %s 已移入历史列表\", index, upstream.Name, utils.MaskAPIKey(key))\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 如果新 Key 在历史列表中，从历史列表移除（换回来了）\n\t\tvar newHistoricalKeys []string\n\t\tfor _, hk := range upstream.HistoricalAPIKeys {\n\t\t\tif !newKeys[hk] {\n\t\t\t\tnewHistoricalKeys = append(newHistoricalKeys, hk)\n\t\t\t} else {\n\t\t\t\tlog.Printf(\"[Config-Upstream] Gemini 渠道 [%d] %s: Key %s 已从历史列表恢复\", index, upstream.Name, utils.MaskAPIKey(hk))\n\t\t\t}\n\t\t}\n\t\tupstream.HistoricalAPIKeys = newHistoricalKeys\n\n\t\t// 只有单 key 场景且 key 被更换时，才自动激活并重置熔断\n\t\tif len(upstream.APIKeys) == 1 && len(updates.APIKeys) == 1 &&\n\t\t\tupstream.APIKeys[0] != updates.APIKeys[0] {\n\t\t\tshouldResetMetrics = true\n\t\t\tif upstream.Status == \"suspended\" {\n\t\t\t\tupstream.Status = \"active\"\n\t\t\t\tlog.Printf(\"[Config-Upstream] Gemini 渠道 [%d] %s 已从暂停状态自动激活（单 key 更换）\", index, upstream.Name)\n\t\t\t}\n\t\t}\n\t\tupstream.APIKeys = deduplicateStrings(updates.APIKeys)\n\t}\n\tif updates.ModelMapping != nil {\n\t\tupstream.ModelMapping = updates.ModelMapping\n\t}\n\tif updates.InsecureSkipVerify != nil {\n\t\tupstream.InsecureSkipVerify = *updates.InsecureSkipVerify\n\t}\n\tif updates.Priority != nil {\n\t\tupstream.Priority = *updates.Priority\n\t}\n\tif updates.Status != nil {\n\t\tupstream.Status = *updates.Status\n\t}\n\tif updates.PromotionUntil != nil {\n\t\tupstream.PromotionUntil = updates.PromotionUntil\n\t}\n\tif updates.LowQuality != nil {\n\t\tupstream.LowQuality = *updates.LowQuality\n\t}\n\tif updates.InjectDummyThoughtSignature != nil {\n\t\tupstream.InjectDummyThoughtSignature = *updates.InjectDummyThoughtSignature\n\t}\n\tif updates.StripThoughtSignature != nil {\n\t\tupstream.StripThoughtSignature = *updates.StripThoughtSignature\n\t}\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn false, err\n\t}\n\n\tlog.Printf(\"[Config-Upstream] 已更新 Gemini 上游: [%d] %s\", index, cm.config.GeminiUpstream[index].Name)\n\treturn shouldResetMetrics, nil\n}\n\n// RemoveGeminiUpstream 删除 Gemini 上游\nfunc (cm *ConfigManager) RemoveGeminiUpstream(index int) (*UpstreamConfig, error) {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif index < 0 || index >= len(cm.config.GeminiUpstream) {\n\t\treturn nil, fmt.Errorf(\"无效的 Gemini 上游索引: %d\", index)\n\t}\n\n\tremoved := cm.config.GeminiUpstream[index]\n\tcm.config.GeminiUpstream = append(cm.config.GeminiUpstream[:index], cm.config.GeminiUpstream[index+1:]...)\n\n\t// 清理被删除渠道的失败 key 冷却记录\n\tcm.clearFailedKeysForUpstream(&removed, \"Gemini\")\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn nil, err\n\t}\n\n\tlog.Printf(\"[Config-Upstream] 已删除 Gemini 上游: %s\", removed.Name)\n\treturn &removed, nil\n}\n\n// AddGeminiAPIKey 添加 Gemini 上游的 API 密钥\nfunc (cm *ConfigManager) AddGeminiAPIKey(index int, apiKey string) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif index < 0 || index >= len(cm.config.GeminiUpstream) {\n\t\treturn fmt.Errorf(\"无效的上游索引: %d\", index)\n\t}\n\n\t// 检查密钥是否已存在\n\tfor _, key := range cm.config.GeminiUpstream[index].APIKeys {\n\t\tif key == apiKey {\n\t\t\treturn fmt.Errorf(\"API密钥已存在\")\n\t\t}\n\t}\n\n\tcm.config.GeminiUpstream[index].APIKeys = append(cm.config.GeminiUpstream[index].APIKeys, apiKey)\n\n\t// 如果该 Key 在历史列表中，从历史列表移除（换回来了）\n\tvar newHistoricalKeys []string\n\tfor _, hk := range cm.config.GeminiUpstream[index].HistoricalAPIKeys {\n\t\tif hk != apiKey {\n\t\t\tnewHistoricalKeys = append(newHistoricalKeys, hk)\n\t\t} else {\n\t\t\tlog.Printf(\"[Gemini-Key] 上游 [%d] %s: Key %s 已从历史列表恢复\", index, cm.config.GeminiUpstream[index].Name, utils.MaskAPIKey(hk))\n\t\t}\n\t}\n\tcm.config.GeminiUpstream[index].HistoricalAPIKeys = newHistoricalKeys\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Printf(\"[Gemini-Key] 已添加API密钥到 Gemini 上游 [%d] %s\", index, cm.config.GeminiUpstream[index].Name)\n\treturn nil\n}\n\n// RemoveGeminiAPIKey 删除 Gemini 上游的 API 密钥\nfunc (cm *ConfigManager) RemoveGeminiAPIKey(index int, apiKey string) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif index < 0 || index >= len(cm.config.GeminiUpstream) {\n\t\treturn fmt.Errorf(\"无效的上游索引: %d\", index)\n\t}\n\n\t// 查找并删除密钥\n\tkeys := cm.config.GeminiUpstream[index].APIKeys\n\tfound := false\n\tfor i, key := range keys {\n\t\tif key == apiKey {\n\t\t\tcm.config.GeminiUpstream[index].APIKeys = append(keys[:i], keys[i+1:]...)\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif !found {\n\t\treturn fmt.Errorf(\"API密钥不存在\")\n\t}\n\n\t// 将被移除的 Key 添加到历史列表（用于统计聚合）\n\talreadyInHistory := false\n\tfor _, hk := range cm.config.GeminiUpstream[index].HistoricalAPIKeys {\n\t\tif hk == apiKey {\n\t\t\talreadyInHistory = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !alreadyInHistory {\n\t\tcm.config.GeminiUpstream[index].HistoricalAPIKeys = append(cm.config.GeminiUpstream[index].HistoricalAPIKeys, apiKey)\n\t\tlog.Printf(\"[Gemini-Key] 上游 [%d] %s: Key %s 已移入历史列表\", index, cm.config.GeminiUpstream[index].Name, utils.MaskAPIKey(apiKey))\n\t}\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Printf(\"[Gemini-Key] 已从 Gemini 上游 [%d] %s 删除API密钥\", index, cm.config.GeminiUpstream[index].Name)\n\treturn nil\n}\n\n// GetNextGeminiAPIKey 获取下一个 Gemini API 密钥（纯 failover 模式）\nfunc (cm *ConfigManager) GetNextGeminiAPIKey(upstream *UpstreamConfig, failedKeys map[string]bool) (string, error) {\n\treturn cm.GetNextAPIKey(upstream, failedKeys, \"Gemini\")\n}\n\n// MoveGeminiAPIKeyToTop 将指定 Gemini 渠道的 API 密钥移到最前面\nfunc (cm *ConfigManager) MoveGeminiAPIKeyToTop(upstreamIndex int, apiKey string) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif upstreamIndex < 0 || upstreamIndex >= len(cm.config.GeminiUpstream) {\n\t\treturn fmt.Errorf(\"无效的上游索引: %d\", upstreamIndex)\n\t}\n\n\tupstream := &cm.config.GeminiUpstream[upstreamIndex]\n\tindex := -1\n\tfor i, key := range upstream.APIKeys {\n\t\tif key == apiKey {\n\t\t\tindex = i\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif index <= 0 {\n\t\treturn nil\n\t}\n\n\tupstream.APIKeys = append([]string{apiKey}, append(upstream.APIKeys[:index], upstream.APIKeys[index+1:]...)...)\n\treturn cm.saveConfigLocked(cm.config)\n}\n\n// MoveGeminiAPIKeyToBottom 将指定 Gemini 渠道的 API 密钥移到最后面\nfunc (cm *ConfigManager) MoveGeminiAPIKeyToBottom(upstreamIndex int, apiKey string) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif upstreamIndex < 0 || upstreamIndex >= len(cm.config.GeminiUpstream) {\n\t\treturn fmt.Errorf(\"无效的上游索引: %d\", upstreamIndex)\n\t}\n\n\tupstream := &cm.config.GeminiUpstream[upstreamIndex]\n\tindex := -1\n\tfor i, key := range upstream.APIKeys {\n\t\tif key == apiKey {\n\t\t\tindex = i\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif index == -1 || index == len(upstream.APIKeys)-1 {\n\t\treturn nil\n\t}\n\n\tupstream.APIKeys = append(upstream.APIKeys[:index], upstream.APIKeys[index+1:]...)\n\tupstream.APIKeys = append(upstream.APIKeys, apiKey)\n\treturn cm.saveConfigLocked(cm.config)\n}\n\n// ReorderGeminiUpstreams 重新排序 Gemini 渠道优先级\n// order 是渠道索引数组，按新的优先级顺序排列（只更新传入的渠道，支持部分排序）\nfunc (cm *ConfigManager) ReorderGeminiUpstreams(order []int) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif len(order) == 0 {\n\t\treturn fmt.Errorf(\"排序数组不能为空\")\n\t}\n\n\tseen := make(map[int]bool)\n\tfor _, idx := range order {\n\t\tif idx < 0 || idx >= len(cm.config.GeminiUpstream) {\n\t\t\treturn fmt.Errorf(\"无效的渠道索引: %d\", idx)\n\t\t}\n\t\tif seen[idx] {\n\t\t\treturn fmt.Errorf(\"重复的渠道索引: %d\", idx)\n\t\t}\n\t\tseen[idx] = true\n\t}\n\n\t// 更新传入渠道的优先级（未传入的渠道保持原优先级不变）\n\t// 注意：priority 从 1 开始，避免 omitempty 吞掉 0 值\n\tfor i, idx := range order {\n\t\tcm.config.GeminiUpstream[idx].Priority = i + 1\n\t}\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Printf(\"[Config-Reorder] 已更新 Gemini 渠道优先级顺序 (%d 个渠道)\", len(order))\n\treturn nil\n}\n\n// SetGeminiChannelStatus 设置 Gemini 渠道状态\nfunc (cm *ConfigManager) SetGeminiChannelStatus(index int, status string) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif index < 0 || index >= len(cm.config.GeminiUpstream) {\n\t\treturn fmt.Errorf(\"无效的上游索引: %d\", index)\n\t}\n\n\t// 状态值转为小写，支持大小写不敏感\n\tstatus = strings.ToLower(status)\n\tif status != \"active\" && status != \"suspended\" && status != \"disabled\" {\n\t\treturn fmt.Errorf(\"无效的状态: %s (允许值: active, suspended, disabled)\", status)\n\t}\n\n\tcm.config.GeminiUpstream[index].Status = status\n\n\t// 暂停时清除促销期\n\tif status == \"suspended\" && cm.config.GeminiUpstream[index].PromotionUntil != nil {\n\t\tcm.config.GeminiUpstream[index].PromotionUntil = nil\n\t\tlog.Printf(\"[Config-Status] 已清除 Gemini 渠道 [%d] %s 的促销期\", index, cm.config.GeminiUpstream[index].Name)\n\t}\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Printf(\"[Config-Status] 已设置 Gemini 渠道 [%d] %s 状态为: %s\", index, cm.config.GeminiUpstream[index].Name, status)\n\treturn nil\n}\n\n// SetGeminiChannelPromotion 设置 Gemini 渠道促销期\nfunc (cm *ConfigManager) SetGeminiChannelPromotion(index int, duration time.Duration) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif index < 0 || index >= len(cm.config.GeminiUpstream) {\n\t\treturn fmt.Errorf(\"无效的 Gemini 上游索引: %d\", index)\n\t}\n\n\tif duration <= 0 {\n\t\tcm.config.GeminiUpstream[index].PromotionUntil = nil\n\t\tlog.Printf(\"[Config-Promotion] 已清除 Gemini 渠道 [%d] %s 的促销期\", index, cm.config.GeminiUpstream[index].Name)\n\t} else {\n\t\t// 清除其他渠道的促销期（同一时间只允许一个促销渠道）\n\t\tfor i := range cm.config.GeminiUpstream {\n\t\t\tif i != index && cm.config.GeminiUpstream[i].PromotionUntil != nil {\n\t\t\t\tcm.config.GeminiUpstream[i].PromotionUntil = nil\n\t\t\t}\n\t\t}\n\t\tpromotionEnd := time.Now().Add(duration)\n\t\tcm.config.GeminiUpstream[index].PromotionUntil = &promotionEnd\n\t\tlog.Printf(\"[Config-Promotion] 已设置 Gemini 渠道 [%d] %s 进入促销期，截止: %s\", index, cm.config.GeminiUpstream[index].Name, promotionEnd.Format(time.RFC3339))\n\t}\n\n\treturn cm.saveConfigLocked(cm.config)\n}\n\n// GetPromotedGeminiChannel 获取当前处于促销期的 Gemini 渠道索引\nfunc (cm *ConfigManager) GetPromotedGeminiChannel() (int, bool) {\n\tcm.mu.RLock()\n\tdefer cm.mu.RUnlock()\n\n\tfor i, upstream := range cm.config.GeminiUpstream {\n\t\tif IsChannelInPromotion(&upstream) && GetChannelStatus(&upstream) == \"active\" {\n\t\t\treturn i, true\n\t\t}\n\t}\n\treturn -1, false\n}\n\n// SetGeminiLoadBalance 设置 Gemini 负载均衡策略\nfunc (cm *ConfigManager) SetGeminiLoadBalance(strategy string) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif err := validateLoadBalanceStrategy(strategy); err != nil {\n\t\treturn err\n\t}\n\n\tcm.config.GeminiLoadBalance = strategy\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Printf(\"[Config-LoadBalance] 已设置 Gemini 负载均衡策略: %s\", strategy)\n\treturn nil\n}\n"
  },
  {
    "path": "backend-go/internal/config/config_loader.go",
    "content": "package config\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/fsnotify/fsnotify\"\n)\n\nconst (\n\tmaxBackups      = 10\n\tkeyRecoveryTime = 5 * time.Minute\n\tmaxFailureCount = 3\n)\n\n// NewConfigManager 创建配置管理器\nfunc NewConfigManager(configFile string) (*ConfigManager, error) {\n\tcm := &ConfigManager{\n\t\tconfigFile:      configFile,\n\t\tfailedKeysCache: make(map[string]*FailedKey),\n\t\tkeyRecoveryTime: keyRecoveryTime,\n\t\tmaxFailureCount: maxFailureCount,\n\t\tstopChan:        make(chan struct{}),\n\t}\n\n\t// 加载配置\n\tif err := cm.loadConfig(); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 启动文件监听\n\tif err := cm.startWatcher(); err != nil {\n\t\tlog.Printf(\"[Config-Watcher] 警告: 启动配置文件监听失败: %v\", err)\n\t}\n\n\t// 启动定期清理\n\tgo cm.cleanupExpiredFailures()\n\n\treturn cm, nil\n}\n\n// loadConfig 加载配置\nfunc (cm *ConfigManager) loadConfig() error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\t// 如果配置文件不存在，创建默认配置\n\tif _, err := os.Stat(cm.configFile); os.IsNotExist(err) {\n\t\treturn cm.createDefaultConfig()\n\t}\n\n\t// 读取配置文件\n\tdata, err := os.ReadFile(cm.configFile)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := json.Unmarshal(data, &cm.config); err != nil {\n\t\treturn err\n\t}\n\n\t// 兼容旧配置：检查 FuzzyModeEnabled 字段是否存在\n\t// 如果不存在，默认设为 true（新功能默认启用）\n\tneedSaveDefaults := cm.applyConfigDefaults(data)\n\n\t// 兼容旧格式：检测是否需要迁移\n\tneedMigration := cm.migrateOldFormat()\n\n\t// 如果有默认值迁移或格式迁移，保存配置\n\tif needSaveDefaults || needMigration {\n\t\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\t\tlog.Printf(\"[Config-Migration] 警告: 保存迁移后的配置失败: %v\", err)\n\t\t\treturn err\n\t\t}\n\t\tif needMigration {\n\t\t\tlog.Printf(\"[Config-Migration] 配置迁移完成\")\n\t\t}\n\t}\n\n\t// 自检：没有配置 key 的渠道自动暂停\n\tif cm.validateChannelKeys() {\n\t\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\t\tlog.Printf(\"[Config-Validate] 警告: 保存自检后的配置失败: %v\", err)\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// createDefaultConfig 创建默认配置\nfunc (cm *ConfigManager) createDefaultConfig() error {\n\tdefaultConfig := Config{\n\t\tUpstream:                 []UpstreamConfig{},\n\t\tCurrentUpstream:          0,\n\t\tLoadBalance:              \"failover\",\n\t\tResponsesUpstream:        []UpstreamConfig{},\n\t\tCurrentResponsesUpstream: 0,\n\t\tResponsesLoadBalance:     \"failover\",\n\t\tGeminiUpstream:           []UpstreamConfig{},\n\t\tGeminiLoadBalance:        \"failover\",\n\t\tFuzzyModeEnabled:         true, // 默认启用 Fuzzy 模式\n\t}\n\n\tif err := os.MkdirAll(filepath.Dir(cm.configFile), 0755); err != nil {\n\t\treturn err\n\t}\n\n\treturn cm.saveConfigLocked(defaultConfig)\n}\n\n// applyConfigDefaults 应用配置默认值\n// rawJSON: 原始 JSON 数据，用于检测字段是否存在\n// 返回: 是否有字段需要迁移（需要保存配置）\nfunc (cm *ConfigManager) applyConfigDefaults(rawJSON []byte) bool {\n\tneedSave := false\n\n\tif cm.config.LoadBalance == \"\" {\n\t\tcm.config.LoadBalance = \"failover\"\n\t}\n\tif cm.config.ResponsesLoadBalance == \"\" {\n\t\tcm.config.ResponsesLoadBalance = cm.config.LoadBalance\n\t}\n\tif cm.config.GeminiLoadBalance == \"\" {\n\t\tcm.config.GeminiLoadBalance = \"failover\"\n\t}\n\n\t// FuzzyModeEnabled 默认值处理：\n\t// 由于 bool 零值是 false，无法区分\"用户设为 false\"和\"字段不存在\"\n\t// 通过检查原始 JSON 是否包含该字段来判断\n\tvar rawMap map[string]json.RawMessage\n\tif err := json.Unmarshal(rawJSON, &rawMap); err == nil {\n\t\tif _, exists := rawMap[\"fuzzyModeEnabled\"]; !exists {\n\t\t\t// 字段不存在，设为默认值 true\n\t\t\tcm.config.FuzzyModeEnabled = true\n\t\t\tneedSave = true\n\t\t\tlog.Printf(\"[Config-Migration] FuzzyModeEnabled 字段不存在，设为默认值 true\")\n\t\t}\n\t}\n\n\treturn needSave\n}\n\n// migrateOldFormat 迁移旧格式配置，返回是否有迁移\nfunc (cm *ConfigManager) migrateOldFormat() bool {\n\tneedMigration := false\n\n\t// 迁移 Messages 渠道\n\tif cm.migrateUpstreams(cm.config.Upstream, cm.config.CurrentUpstream, \"Messages\") {\n\t\tneedMigration = true\n\t}\n\n\t// 迁移 Responses 渠道\n\tif cm.migrateUpstreams(cm.config.ResponsesUpstream, cm.config.CurrentResponsesUpstream, \"Responses\") {\n\t\tneedMigration = true\n\t}\n\n\tif needMigration {\n\t\tlog.Printf(\"[Config-Migration] 检测到旧格式配置，正在迁移到新格式...\")\n\t}\n\n\treturn needMigration\n}\n\n// migrateUpstreams 迁移单个渠道列表\nfunc (cm *ConfigManager) migrateUpstreams(upstreams []UpstreamConfig, currentIdx int, name string) bool {\n\tif len(upstreams) == 0 {\n\t\treturn false\n\t}\n\n\t// 检查是否已有 status 字段\n\tfor _, up := range upstreams {\n\t\tif up.Status != \"\" {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// 需要迁移\n\tif currentIdx < 0 || currentIdx >= len(upstreams) {\n\t\tcurrentIdx = 0\n\t}\n\n\tfor i := range upstreams {\n\t\tif i == currentIdx {\n\t\t\tupstreams[i].Status = \"active\"\n\t\t} else {\n\t\t\tupstreams[i].Status = \"disabled\"\n\t\t}\n\t}\n\n\tlog.Printf(\"[Config-Migration] %s 渠道 [%d] %s 已设置为 active，其他 %d 个渠道已设为 disabled\",\n\t\tname, currentIdx, upstreams[currentIdx].Name, len(upstreams)-1)\n\n\treturn true\n}\n\n// validateChannelKeys 自检渠道密钥配置\n// 没有配置 API key 的渠道，即使状态为 active 也应暂停\n// 返回 true 表示有配置被修改，需要保存\nfunc (cm *ConfigManager) validateChannelKeys() bool {\n\tmodified := false\n\n\t// 检查 Messages 渠道\n\tfor i := range cm.config.Upstream {\n\t\tupstream := &cm.config.Upstream[i]\n\t\tstatus := upstream.Status\n\t\tif status == \"\" {\n\t\t\tstatus = \"active\"\n\t\t}\n\n\t\t// 如果是 active 状态但没有配置 key，自动设为 suspended\n\t\tif status == \"active\" && len(upstream.APIKeys) == 0 {\n\t\t\tupstream.Status = \"suspended\"\n\t\t\tmodified = true\n\t\t\tlog.Printf(\"[Config-Validate] 警告: Messages 渠道 [%d] %s 没有配置 API key，已自动暂停\", i, upstream.Name)\n\t\t}\n\t}\n\n\t// 检查 Responses 渠道\n\tfor i := range cm.config.ResponsesUpstream {\n\t\tupstream := &cm.config.ResponsesUpstream[i]\n\t\tstatus := upstream.Status\n\t\tif status == \"\" {\n\t\t\tstatus = \"active\"\n\t\t}\n\n\t\t// 如果是 active 状态但没有配置 key，自动设为 suspended\n\t\tif status == \"active\" && len(upstream.APIKeys) == 0 {\n\t\t\tupstream.Status = \"suspended\"\n\t\t\tmodified = true\n\t\t\tlog.Printf(\"[Config-Validate] 警告: Responses 渠道 [%d] %s 没有配置 API key，已自动暂停\", i, upstream.Name)\n\t\t}\n\t}\n\n\t// 检查 Gemini 渠道\n\tfor i := range cm.config.GeminiUpstream {\n\t\tupstream := &cm.config.GeminiUpstream[i]\n\t\tstatus := upstream.Status\n\t\tif status == \"\" {\n\t\t\tstatus = \"active\"\n\t\t}\n\n\t\t// 如果是 active 状态但没有配置 key，自动设为 suspended\n\t\tif status == \"active\" && len(upstream.APIKeys) == 0 {\n\t\t\tupstream.Status = \"suspended\"\n\t\t\tmodified = true\n\t\t\tlog.Printf(\"[Config-Validate] 警告: Gemini 渠道 [%d] %s 没有配置 API key，已自动暂停\", i, upstream.Name)\n\t\t}\n\t}\n\n\treturn modified\n}\n\n// saveConfigLocked 保存配置（已加锁）\nfunc (cm *ConfigManager) saveConfigLocked(config Config) error {\n\t// 备份当前配置\n\tcm.backupConfig()\n\n\t// 清理已废弃字段，确保不会被序列化到 JSON\n\tconfig.CurrentUpstream = 0\n\tconfig.CurrentResponsesUpstream = 0\n\n\tdata, err := json.MarshalIndent(config, \"\", \"  \")\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tcm.config = config\n\treturn os.WriteFile(cm.configFile, data, 0644)\n}\n\n// SaveConfig 保存配置\nfunc (cm *ConfigManager) SaveConfig() error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\treturn cm.saveConfigLocked(cm.config)\n}\n\n// backupConfig 备份配置\nfunc (cm *ConfigManager) backupConfig() {\n\tif _, err := os.Stat(cm.configFile); os.IsNotExist(err) {\n\t\treturn\n\t}\n\n\tbackupDir := filepath.Join(filepath.Dir(cm.configFile), \"backups\")\n\tif err := os.MkdirAll(backupDir, 0755); err != nil {\n\t\tlog.Printf(\"[Config-Backup] 警告: 创建备份目录失败: %v\", err)\n\t\treturn\n\t}\n\n\t// 读取当前配置\n\tdata, err := os.ReadFile(cm.configFile)\n\tif err != nil {\n\t\tlog.Printf(\"[Config-Backup] 警告: 读取配置文件失败: %v\", err)\n\t\treturn\n\t}\n\n\t// 创建备份文件\n\ttimestamp := time.Now().Format(\"2006-01-02T15-04-05\")\n\tbackupFile := filepath.Join(backupDir, fmt.Sprintf(\"config-%s.json\", timestamp))\n\tif err := os.WriteFile(backupFile, data, 0644); err != nil {\n\t\tlog.Printf(\"[Config-Backup] 警告: 写入备份文件失败: %v\", err)\n\t\treturn\n\t}\n\n\t// 清理旧备份\n\tcm.cleanupOldBackups(backupDir)\n}\n\n// cleanupOldBackups 清理旧备份\nfunc (cm *ConfigManager) cleanupOldBackups(backupDir string) {\n\tentries, err := os.ReadDir(backupDir)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tif len(entries) <= maxBackups {\n\t\treturn\n\t}\n\n\t// 删除最旧的备份\n\tfor i := 0; i < len(entries)-maxBackups; i++ {\n\t\tos.Remove(filepath.Join(backupDir, entries[i].Name()))\n\t}\n}\n\n// startWatcher 启动文件监听\nfunc (cm *ConfigManager) startWatcher() error {\n\twatcher, err := fsnotify.NewWatcher()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tcm.watcher = watcher\n\n\tgo func() {\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-cm.stopChan:\n\t\t\t\treturn\n\t\t\tcase event, ok := <-watcher.Events:\n\t\t\t\tif !ok {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif event.Op&fsnotify.Write == fsnotify.Write {\n\t\t\t\t\tlog.Printf(\"[Config-Watcher] 检测到配置文件变化，重载配置...\")\n\t\t\t\t\tif err := cm.loadConfig(); err != nil {\n\t\t\t\t\t\tlog.Printf(\"[Config-Watcher] 警告: 配置重载失败: %v\", err)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlog.Printf(\"[Config-Watcher] 配置已重载\")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\tcase err, ok := <-watcher.Errors:\n\t\t\t\tif !ok {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tlog.Printf(\"[Config-Watcher] 警告: 文件监听错误: %v\", err)\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn watcher.Add(cm.configFile)\n}\n\n// Close 关闭 ConfigManager 并释放资源（幂等，可安全多次调用）\nfunc (cm *ConfigManager) Close() error {\n\tvar closeErr error\n\tcm.closeOnce.Do(func() {\n\t\t// 通知所有 goroutine 停止\n\t\tif cm.stopChan != nil {\n\t\t\tclose(cm.stopChan)\n\t\t}\n\n\t\t// 关闭文件监听器\n\t\tif cm.watcher != nil {\n\t\t\tcloseErr = cm.watcher.Close()\n\t\t}\n\t})\n\treturn closeErr\n}\n"
  },
  {
    "path": "backend-go/internal/config/config_messages.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/utils\"\n)\n\n// ============== Messages 渠道方法 ==============\n\n// GetCurrentUpstream 获取当前上游配置\n// 优先选择第一个 active 状态的渠道，若无则回退到第一个渠道\nfunc (cm *ConfigManager) GetCurrentUpstream() (*UpstreamConfig, error) {\n\tcm.mu.RLock()\n\tdefer cm.mu.RUnlock()\n\n\tif len(cm.config.Upstream) == 0 {\n\t\treturn nil, fmt.Errorf(\"未配置任何上游渠道\")\n\t}\n\n\t// 优先选择第一个 active 状态的渠道\n\tfor i := range cm.config.Upstream {\n\t\tstatus := cm.config.Upstream[i].Status\n\t\tif status == \"\" || status == \"active\" {\n\t\t\treturn &cm.config.Upstream[i], nil\n\t\t}\n\t}\n\n\t// 没有 active 渠道，回退到第一个渠道\n\treturn &cm.config.Upstream[0], nil\n}\n\n// AddUpstream 添加上游\nfunc (cm *ConfigManager) AddUpstream(upstream UpstreamConfig) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\t// 新建渠道默认设为 active\n\tif upstream.Status == \"\" {\n\t\tupstream.Status = \"active\"\n\t}\n\n\t// 去重 API Keys 和 Base URLs\n\tupstream.APIKeys = deduplicateStrings(upstream.APIKeys)\n\tupstream.BaseURLs = deduplicateBaseURLs(upstream.BaseURLs)\n\n\tcm.config.Upstream = append(cm.config.Upstream, upstream)\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Printf(\"[Config-Upstream] 已添加上游: %s\", upstream.Name)\n\treturn nil\n}\n\n// UpdateUpstream 更新上游\n// 返回值：shouldResetMetrics 表示是否需要重置渠道指标（熔断状态）\nfunc (cm *ConfigManager) UpdateUpstream(index int, updates UpstreamUpdate) (shouldResetMetrics bool, err error) {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif index < 0 || index >= len(cm.config.Upstream) {\n\t\treturn false, fmt.Errorf(\"无效的上游索引: %d\", index)\n\t}\n\n\tupstream := &cm.config.Upstream[index]\n\n\tif updates.Name != nil {\n\t\tupstream.Name = *updates.Name\n\t}\n\tif updates.BaseURL != nil {\n\t\tupstream.BaseURL = *updates.BaseURL\n\t\t// 当 BaseURL 被更新且 BaseURLs 未被显式设置时，清空 BaseURLs 保持一致性\n\t\t// 避免出现 baseUrl 和 baseUrls[0] 不一致的情况\n\t\tif updates.BaseURLs == nil {\n\t\t\tupstream.BaseURLs = nil\n\t\t}\n\t}\n\tif updates.BaseURLs != nil {\n\t\tupstream.BaseURLs = deduplicateBaseURLs(updates.BaseURLs)\n\t}\n\tif updates.ServiceType != nil {\n\t\tupstream.ServiceType = *updates.ServiceType\n\t}\n\tif updates.Description != nil {\n\t\tupstream.Description = *updates.Description\n\t}\n\tif updates.Website != nil {\n\t\tupstream.Website = *updates.Website\n\t}\n\tif updates.APIKeys != nil {\n\t\t// 记录被移除的 Key 到历史列表（用于统计聚合）\n\t\tnewKeys := make(map[string]bool)\n\t\tfor _, key := range updates.APIKeys {\n\t\t\tnewKeys[key] = true\n\t\t}\n\n\t\t// 找出被移除的 Key（在旧列表中但不在新列表中）\n\t\tfor _, key := range upstream.APIKeys {\n\t\t\tif !newKeys[key] {\n\t\t\t\t// 检查是否已在历史列表中\n\t\t\t\talreadyInHistory := false\n\t\t\t\tfor _, hk := range upstream.HistoricalAPIKeys {\n\t\t\t\t\tif hk == key {\n\t\t\t\t\t\talreadyInHistory = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif !alreadyInHistory {\n\t\t\t\t\tupstream.HistoricalAPIKeys = append(upstream.HistoricalAPIKeys, key)\n\t\t\t\t\tlog.Printf(\"[Config-Upstream] 渠道 [%d] %s: Key %s 已移入历史列表\", index, upstream.Name, utils.MaskAPIKey(key))\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 如果新 Key 在历史列表中，从历史列表移除（换回来了）\n\t\tvar newHistoricalKeys []string\n\t\tfor _, hk := range upstream.HistoricalAPIKeys {\n\t\t\tif !newKeys[hk] {\n\t\t\t\tnewHistoricalKeys = append(newHistoricalKeys, hk)\n\t\t\t} else {\n\t\t\t\tlog.Printf(\"[Config-Upstream] 渠道 [%d] %s: Key %s 已从历史列表恢复\", index, upstream.Name, utils.MaskAPIKey(hk))\n\t\t\t}\n\t\t}\n\t\tupstream.HistoricalAPIKeys = newHistoricalKeys\n\n\t\t// 只有单 key 场景且 key 被更换时，才自动激活并重置熔断\n\t\tif len(upstream.APIKeys) == 1 && len(updates.APIKeys) == 1 &&\n\t\t\tupstream.APIKeys[0] != updates.APIKeys[0] {\n\t\t\tshouldResetMetrics = true\n\t\t\tif upstream.Status == \"suspended\" {\n\t\t\t\tupstream.Status = \"active\"\n\t\t\t\tlog.Printf(\"[Config-Upstream] 渠道 [%d] %s 已从暂停状态自动激活（单 key 更换）\", index, upstream.Name)\n\t\t\t}\n\t\t}\n\t\tupstream.APIKeys = deduplicateStrings(updates.APIKeys)\n\t}\n\tif updates.ModelMapping != nil {\n\t\tupstream.ModelMapping = updates.ModelMapping\n\t}\n\tif updates.InsecureSkipVerify != nil {\n\t\tupstream.InsecureSkipVerify = *updates.InsecureSkipVerify\n\t}\n\tif updates.Priority != nil {\n\t\tupstream.Priority = *updates.Priority\n\t}\n\tif updates.Status != nil {\n\t\tupstream.Status = *updates.Status\n\t}\n\tif updates.PromotionUntil != nil {\n\t\tupstream.PromotionUntil = updates.PromotionUntil\n\t}\n\tif updates.LowQuality != nil {\n\t\tupstream.LowQuality = *updates.LowQuality\n\t}\n\tif updates.InjectDummyThoughtSignature != nil {\n\t\tupstream.InjectDummyThoughtSignature = *updates.InjectDummyThoughtSignature\n\t}\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn false, err\n\t}\n\n\tlog.Printf(\"[Config-Upstream] 已更新上游: [%d] %s\", index, cm.config.Upstream[index].Name)\n\treturn shouldResetMetrics, nil\n}\n\n// RemoveUpstream 删除上游\nfunc (cm *ConfigManager) RemoveUpstream(index int) (*UpstreamConfig, error) {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif index < 0 || index >= len(cm.config.Upstream) {\n\t\treturn nil, fmt.Errorf(\"无效的上游索引: %d\", index)\n\t}\n\n\tremoved := cm.config.Upstream[index]\n\tcm.config.Upstream = append(cm.config.Upstream[:index], cm.config.Upstream[index+1:]...)\n\n\t// 清理被删除渠道的失败 key 冷却记录\n\tcm.clearFailedKeysForUpstream(&removed, \"Messages\")\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn nil, err\n\t}\n\n\tlog.Printf(\"[Config-Upstream] 已删除上游: %s\", removed.Name)\n\treturn &removed, nil\n}\n\n// AddAPIKey 添加API密钥\nfunc (cm *ConfigManager) AddAPIKey(index int, apiKey string) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif index < 0 || index >= len(cm.config.Upstream) {\n\t\treturn fmt.Errorf(\"无效的上游索引: %d\", index)\n\t}\n\n\t// 检查密钥是否已存在\n\tfor _, key := range cm.config.Upstream[index].APIKeys {\n\t\tif key == apiKey {\n\t\t\treturn fmt.Errorf(\"API密钥已存在\")\n\t\t}\n\t}\n\n\tcm.config.Upstream[index].APIKeys = append(cm.config.Upstream[index].APIKeys, apiKey)\n\n\t// 如果该 Key 在历史列表中，从历史列表移除（换回来了）\n\tvar newHistoricalKeys []string\n\tfor _, hk := range cm.config.Upstream[index].HistoricalAPIKeys {\n\t\tif hk != apiKey {\n\t\t\tnewHistoricalKeys = append(newHistoricalKeys, hk)\n\t\t} else {\n\t\t\tlog.Printf(\"[Messages-Key] 上游 [%d] %s: Key %s 已从历史列表恢复\", index, cm.config.Upstream[index].Name, utils.MaskAPIKey(hk))\n\t\t}\n\t}\n\tcm.config.Upstream[index].HistoricalAPIKeys = newHistoricalKeys\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Printf(\"[Messages-Key] 已添加API密钥到上游 [%d] %s\", index, cm.config.Upstream[index].Name)\n\treturn nil\n}\n\n// RemoveAPIKey 删除API密钥\nfunc (cm *ConfigManager) RemoveAPIKey(index int, apiKey string) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif index < 0 || index >= len(cm.config.Upstream) {\n\t\treturn fmt.Errorf(\"无效的上游索引: %d\", index)\n\t}\n\n\t// 查找并删除密钥\n\tkeys := cm.config.Upstream[index].APIKeys\n\tfound := false\n\tfor i, key := range keys {\n\t\tif key == apiKey {\n\t\t\tcm.config.Upstream[index].APIKeys = append(keys[:i], keys[i+1:]...)\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif !found {\n\t\treturn fmt.Errorf(\"API密钥不存在\")\n\t}\n\n\t// 将被移除的 Key 添加到历史列表（用于统计聚合）\n\talreadyInHistory := false\n\tfor _, hk := range cm.config.Upstream[index].HistoricalAPIKeys {\n\t\tif hk == apiKey {\n\t\t\talreadyInHistory = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !alreadyInHistory {\n\t\tcm.config.Upstream[index].HistoricalAPIKeys = append(cm.config.Upstream[index].HistoricalAPIKeys, apiKey)\n\t\tlog.Printf(\"[Messages-Key] 上游 [%d] %s: Key %s 已移入历史列表\", index, cm.config.Upstream[index].Name, utils.MaskAPIKey(apiKey))\n\t}\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Printf(\"[Messages-Key] 已从上游 [%d] %s 删除API密钥\", index, cm.config.Upstream[index].Name)\n\treturn nil\n}\n\n// SetLoadBalance 设置 Messages 负载均衡策略\nfunc (cm *ConfigManager) SetLoadBalance(strategy string) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif err := validateLoadBalanceStrategy(strategy); err != nil {\n\t\treturn err\n\t}\n\n\tcm.config.LoadBalance = strategy\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Printf(\"[Config-LoadBalance] 已设置负载均衡策略: %s\", strategy)\n\treturn nil\n}\n\n// MoveAPIKeyToTop 将指定渠道的 API 密钥移到最前面\nfunc (cm *ConfigManager) MoveAPIKeyToTop(upstreamIndex int, apiKey string) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif upstreamIndex < 0 || upstreamIndex >= len(cm.config.Upstream) {\n\t\treturn fmt.Errorf(\"无效的上游索引: %d\", upstreamIndex)\n\t}\n\n\tupstream := &cm.config.Upstream[upstreamIndex]\n\tindex := -1\n\tfor i, key := range upstream.APIKeys {\n\t\tif key == apiKey {\n\t\t\tindex = i\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif index <= 0 {\n\t\treturn nil // 已经在最前面或未找到\n\t}\n\n\t// 移动到开头\n\tupstream.APIKeys = append([]string{apiKey}, append(upstream.APIKeys[:index], upstream.APIKeys[index+1:]...)...)\n\treturn cm.saveConfigLocked(cm.config)\n}\n\n// MoveAPIKeyToBottom 将指定渠道的 API 密钥移到最后面\nfunc (cm *ConfigManager) MoveAPIKeyToBottom(upstreamIndex int, apiKey string) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif upstreamIndex < 0 || upstreamIndex >= len(cm.config.Upstream) {\n\t\treturn fmt.Errorf(\"无效的上游索引: %d\", upstreamIndex)\n\t}\n\n\tupstream := &cm.config.Upstream[upstreamIndex]\n\tindex := -1\n\tfor i, key := range upstream.APIKeys {\n\t\tif key == apiKey {\n\t\t\tindex = i\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif index == -1 || index == len(upstream.APIKeys)-1 {\n\t\treturn nil // 已经在最后面或未找到\n\t}\n\n\t// 移动到末尾\n\tupstream.APIKeys = append(upstream.APIKeys[:index], upstream.APIKeys[index+1:]...)\n\tupstream.APIKeys = append(upstream.APIKeys, apiKey)\n\treturn cm.saveConfigLocked(cm.config)\n}\n\n// ReorderUpstreams 重新排序 Messages 渠道优先级\n// order 是渠道索引数组，按新的优先级顺序排列（只更新传入的渠道，支持部分排序）\nfunc (cm *ConfigManager) ReorderUpstreams(order []int) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif len(order) == 0 {\n\t\treturn fmt.Errorf(\"排序数组不能为空\")\n\t}\n\n\t// 验证所有索引都有效且不重复\n\tseen := make(map[int]bool)\n\tfor _, idx := range order {\n\t\tif idx < 0 || idx >= len(cm.config.Upstream) {\n\t\t\treturn fmt.Errorf(\"无效的渠道索引: %d\", idx)\n\t\t}\n\t\tif seen[idx] {\n\t\t\treturn fmt.Errorf(\"重复的渠道索引: %d\", idx)\n\t\t}\n\t\tseen[idx] = true\n\t}\n\n\t// 更新传入渠道的优先级（未传入的渠道保持原优先级不变）\n\t// 注意：priority 从 1 开始，避免 omitempty 吞掉 0 值\n\tfor i, idx := range order {\n\t\tcm.config.Upstream[idx].Priority = i + 1\n\t}\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Printf(\"[Config-Reorder] 已更新 Messages 渠道优先级顺序 (%d 个渠道)\", len(order))\n\treturn nil\n}\n\n// SetChannelStatus 设置 Messages 渠道状态\nfunc (cm *ConfigManager) SetChannelStatus(index int, status string) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif index < 0 || index >= len(cm.config.Upstream) {\n\t\treturn fmt.Errorf(\"无效的上游索引: %d\", index)\n\t}\n\n\t// 状态值转为小写，支持大小写不敏感\n\tstatus = strings.ToLower(status)\n\tif status != \"active\" && status != \"suspended\" && status != \"disabled\" {\n\t\treturn fmt.Errorf(\"无效的状态: %s (允许值: active, suspended, disabled)\", status)\n\t}\n\n\tcm.config.Upstream[index].Status = status\n\n\t// 暂停时清除促销期\n\tif status == \"suspended\" && cm.config.Upstream[index].PromotionUntil != nil {\n\t\tcm.config.Upstream[index].PromotionUntil = nil\n\t\tlog.Printf(\"[Config-Status] 已清除渠道 [%d] %s 的促销期\", index, cm.config.Upstream[index].Name)\n\t}\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Printf(\"[Config-Status] 已设置渠道 [%d] %s 状态为: %s\", index, cm.config.Upstream[index].Name, status)\n\treturn nil\n}\n\n// SetChannelPromotion 设置渠道促销期\n// duration 为促销持续时间，传入 0 表示清除促销期\nfunc (cm *ConfigManager) SetChannelPromotion(index int, duration time.Duration) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif index < 0 || index >= len(cm.config.Upstream) {\n\t\treturn fmt.Errorf(\"无效的上游索引: %d\", index)\n\t}\n\n\tif duration <= 0 {\n\t\tcm.config.Upstream[index].PromotionUntil = nil\n\t\tlog.Printf(\"[Config-Promotion] 已清除渠道 [%d] %s 的促销期\", index, cm.config.Upstream[index].Name)\n\t} else {\n\t\t// 清除其他渠道的促销期（同一时间只允许一个促销渠道）\n\t\tfor i := range cm.config.Upstream {\n\t\t\tif i != index && cm.config.Upstream[i].PromotionUntil != nil {\n\t\t\t\tcm.config.Upstream[i].PromotionUntil = nil\n\t\t\t}\n\t\t}\n\t\tpromotionEnd := time.Now().Add(duration)\n\t\tcm.config.Upstream[index].PromotionUntil = &promotionEnd\n\t\tlog.Printf(\"[Config-Promotion] 已设置渠道 [%d] %s 进入促销期，截止: %s\", index, cm.config.Upstream[index].Name, promotionEnd.Format(time.RFC3339))\n\t}\n\n\treturn cm.saveConfigLocked(cm.config)\n}\n\n// GetPromotedChannel 获取当前处于促销期的渠道索引（返回优先级最高的）\nfunc (cm *ConfigManager) GetPromotedChannel() (int, bool) {\n\tcm.mu.RLock()\n\tdefer cm.mu.RUnlock()\n\n\tfor i, upstream := range cm.config.Upstream {\n\t\tif IsChannelInPromotion(&upstream) && GetChannelStatus(&upstream) == \"active\" {\n\t\t\treturn i, true\n\t\t}\n\t}\n\treturn -1, false\n}\n\n// DeprioritizeAPIKey 降低API密钥优先级（在所有渠道中查找）\nfunc (cm *ConfigManager) DeprioritizeAPIKey(apiKey string) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\t// 遍历所有渠道查找该 API 密钥\n\tfor upstreamIdx := range cm.config.Upstream {\n\t\tupstream := &cm.config.Upstream[upstreamIdx]\n\t\tindex := -1\n\t\tfor i, key := range upstream.APIKeys {\n\t\t\tif key == apiKey {\n\t\t\t\tindex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif index != -1 && index != len(upstream.APIKeys)-1 {\n\t\t\t// 移动到末尾\n\t\t\tupstream.APIKeys = append(upstream.APIKeys[:index], upstream.APIKeys[index+1:]...)\n\t\t\tupstream.APIKeys = append(upstream.APIKeys, apiKey)\n\t\t\tlog.Printf(\"[Messages-Key] 已将API密钥移动到末尾以降低优先级: %s (渠道: %s)\", utils.MaskAPIKey(apiKey), upstream.Name)\n\t\t\treturn cm.saveConfigLocked(cm.config)\n\t\t}\n\t}\n\n\t// 同样遍历 Responses 渠道\n\tfor upstreamIdx := range cm.config.ResponsesUpstream {\n\t\tupstream := &cm.config.ResponsesUpstream[upstreamIdx]\n\t\tindex := -1\n\t\tfor i, key := range upstream.APIKeys {\n\t\t\tif key == apiKey {\n\t\t\t\tindex = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif index != -1 && index != len(upstream.APIKeys)-1 {\n\t\t\t// 移动到末尾\n\t\t\tupstream.APIKeys = append(upstream.APIKeys[:index], upstream.APIKeys[index+1:]...)\n\t\t\tupstream.APIKeys = append(upstream.APIKeys, apiKey)\n\t\t\tlog.Printf(\"[Responses-Key] 已将API密钥移动到末尾以降低优先级: %s (Responses渠道: %s)\", utils.MaskAPIKey(apiKey), upstream.Name)\n\t\t\treturn cm.saveConfigLocked(cm.config)\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "backend-go/internal/config/config_responses.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/utils\"\n)\n\n// ============== Responses 渠道方法 ==============\n\n// GetCurrentResponsesUpstream 获取当前 Responses 上游配置\n// 优先选择第一个 active 状态的渠道，若无则回退到第一个渠道\nfunc (cm *ConfigManager) GetCurrentResponsesUpstream() (*UpstreamConfig, error) {\n\tcm.mu.RLock()\n\tdefer cm.mu.RUnlock()\n\n\tif len(cm.config.ResponsesUpstream) == 0 {\n\t\treturn nil, fmt.Errorf(\"未配置任何 Responses 渠道\")\n\t}\n\n\t// 优先选择第一个 active 状态的渠道\n\tfor i := range cm.config.ResponsesUpstream {\n\t\tstatus := cm.config.ResponsesUpstream[i].Status\n\t\tif status == \"\" || status == \"active\" {\n\t\t\treturn &cm.config.ResponsesUpstream[i], nil\n\t\t}\n\t}\n\n\t// 没有 active 渠道，回退到第一个渠道\n\treturn &cm.config.ResponsesUpstream[0], nil\n}\n\n// AddResponsesUpstream 添加 Responses 上游\nfunc (cm *ConfigManager) AddResponsesUpstream(upstream UpstreamConfig) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\t// 新建渠道默认设为 active\n\tif upstream.Status == \"\" {\n\t\tupstream.Status = \"active\"\n\t}\n\n\t// 去重 API Keys 和 Base URLs\n\tupstream.APIKeys = deduplicateStrings(upstream.APIKeys)\n\tupstream.BaseURLs = deduplicateBaseURLs(upstream.BaseURLs)\n\n\tcm.config.ResponsesUpstream = append(cm.config.ResponsesUpstream, upstream)\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Printf(\"[Config-Upstream] 已添加 Responses 上游: %s\", upstream.Name)\n\treturn nil\n}\n\n// UpdateResponsesUpstream 更新 Responses 上游\n// 返回值：shouldResetMetrics 表示是否需要重置渠道指标（熔断状态）\nfunc (cm *ConfigManager) UpdateResponsesUpstream(index int, updates UpstreamUpdate) (shouldResetMetrics bool, err error) {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif index < 0 || index >= len(cm.config.ResponsesUpstream) {\n\t\treturn false, fmt.Errorf(\"无效的 Responses 上游索引: %d\", index)\n\t}\n\n\tupstream := &cm.config.ResponsesUpstream[index]\n\n\tif updates.Name != nil {\n\t\tupstream.Name = *updates.Name\n\t}\n\tif updates.BaseURL != nil {\n\t\tupstream.BaseURL = *updates.BaseURL\n\t\t// 当 BaseURL 被更新且 BaseURLs 未被显式设置时，清空 BaseURLs 保持一致性\n\t\t// 避免出现 baseUrl 和 baseUrls[0] 不一致的情况\n\t\tif updates.BaseURLs == nil {\n\t\t\tupstream.BaseURLs = nil\n\t\t}\n\t}\n\tif updates.BaseURLs != nil {\n\t\tupstream.BaseURLs = deduplicateBaseURLs(updates.BaseURLs)\n\t}\n\tif updates.ServiceType != nil {\n\t\tupstream.ServiceType = *updates.ServiceType\n\t}\n\tif updates.Description != nil {\n\t\tupstream.Description = *updates.Description\n\t}\n\tif updates.Website != nil {\n\t\tupstream.Website = *updates.Website\n\t}\n\tif updates.APIKeys != nil {\n\t\t// 记录被移除的 Key 到历史列表（用于统计聚合）\n\t\tnewKeys := make(map[string]bool)\n\t\tfor _, key := range updates.APIKeys {\n\t\t\tnewKeys[key] = true\n\t\t}\n\n\t\t// 找出被移除的 Key（在旧列表中但不在新列表中）\n\t\tfor _, key := range upstream.APIKeys {\n\t\t\tif !newKeys[key] {\n\t\t\t\t// 检查是否已在历史列表中\n\t\t\t\talreadyInHistory := false\n\t\t\t\tfor _, hk := range upstream.HistoricalAPIKeys {\n\t\t\t\t\tif hk == key {\n\t\t\t\t\t\talreadyInHistory = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif !alreadyInHistory {\n\t\t\t\t\tupstream.HistoricalAPIKeys = append(upstream.HistoricalAPIKeys, key)\n\t\t\t\t\tlog.Printf(\"[Config-Upstream] Responses 渠道 [%d] %s: Key %s 已移入历史列表\", index, upstream.Name, utils.MaskAPIKey(key))\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 如果新 Key 在历史列表中，从历史列表移除（换回来了）\n\t\tvar newHistoricalKeys []string\n\t\tfor _, hk := range upstream.HistoricalAPIKeys {\n\t\t\tif !newKeys[hk] {\n\t\t\t\tnewHistoricalKeys = append(newHistoricalKeys, hk)\n\t\t\t} else {\n\t\t\t\tlog.Printf(\"[Config-Upstream] Responses 渠道 [%d] %s: Key %s 已从历史列表恢复\", index, upstream.Name, utils.MaskAPIKey(hk))\n\t\t\t}\n\t\t}\n\t\tupstream.HistoricalAPIKeys = newHistoricalKeys\n\n\t\t// 只有单 key 场景且 key 被更换时，才自动激活并重置熔断\n\t\tif len(upstream.APIKeys) == 1 && len(updates.APIKeys) == 1 &&\n\t\t\tupstream.APIKeys[0] != updates.APIKeys[0] {\n\t\t\tshouldResetMetrics = true\n\t\t\tif upstream.Status == \"suspended\" {\n\t\t\t\tupstream.Status = \"active\"\n\t\t\t\tlog.Printf(\"[Config-Upstream] Responses 渠道 [%d] %s 已从暂停状态自动激活（单 key 更换）\", index, upstream.Name)\n\t\t\t}\n\t\t}\n\t\tupstream.APIKeys = deduplicateStrings(updates.APIKeys)\n\t}\n\tif updates.ModelMapping != nil {\n\t\tupstream.ModelMapping = updates.ModelMapping\n\t}\n\tif updates.InsecureSkipVerify != nil {\n\t\tupstream.InsecureSkipVerify = *updates.InsecureSkipVerify\n\t}\n\tif updates.Priority != nil {\n\t\tupstream.Priority = *updates.Priority\n\t}\n\tif updates.Status != nil {\n\t\tupstream.Status = *updates.Status\n\t}\n\tif updates.PromotionUntil != nil {\n\t\tupstream.PromotionUntil = updates.PromotionUntil\n\t}\n\tif updates.LowQuality != nil {\n\t\tupstream.LowQuality = *updates.LowQuality\n\t}\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn false, err\n\t}\n\n\tlog.Printf(\"[Config-Upstream] 已更新 Responses 上游: [%d] %s\", index, cm.config.ResponsesUpstream[index].Name)\n\treturn shouldResetMetrics, nil\n}\n\n// RemoveResponsesUpstream 删除 Responses 上游\nfunc (cm *ConfigManager) RemoveResponsesUpstream(index int) (*UpstreamConfig, error) {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif index < 0 || index >= len(cm.config.ResponsesUpstream) {\n\t\treturn nil, fmt.Errorf(\"无效的 Responses 上游索引: %d\", index)\n\t}\n\n\tremoved := cm.config.ResponsesUpstream[index]\n\tcm.config.ResponsesUpstream = append(cm.config.ResponsesUpstream[:index], cm.config.ResponsesUpstream[index+1:]...)\n\n\t// 清理被删除渠道的失败 key 冷却记录\n\tcm.clearFailedKeysForUpstream(&removed, \"Responses\")\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn nil, err\n\t}\n\n\tlog.Printf(\"[Config-Upstream] 已删除 Responses 上游: %s\", removed.Name)\n\treturn &removed, nil\n}\n\n// AddResponsesAPIKey 添加 Responses 上游的 API 密钥\nfunc (cm *ConfigManager) AddResponsesAPIKey(index int, apiKey string) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif index < 0 || index >= len(cm.config.ResponsesUpstream) {\n\t\treturn fmt.Errorf(\"无效的上游索引: %d\", index)\n\t}\n\n\t// 检查密钥是否已存在\n\tfor _, key := range cm.config.ResponsesUpstream[index].APIKeys {\n\t\tif key == apiKey {\n\t\t\treturn fmt.Errorf(\"API密钥已存在\")\n\t\t}\n\t}\n\n\tcm.config.ResponsesUpstream[index].APIKeys = append(cm.config.ResponsesUpstream[index].APIKeys, apiKey)\n\n\t// 如果该 Key 在历史列表中，从历史列表移除（换回来了）\n\tvar newHistoricalKeys []string\n\tfor _, hk := range cm.config.ResponsesUpstream[index].HistoricalAPIKeys {\n\t\tif hk != apiKey {\n\t\t\tnewHistoricalKeys = append(newHistoricalKeys, hk)\n\t\t} else {\n\t\t\tlog.Printf(\"[Responses-Key] 上游 [%d] %s: Key %s 已从历史列表恢复\", index, cm.config.ResponsesUpstream[index].Name, utils.MaskAPIKey(hk))\n\t\t}\n\t}\n\tcm.config.ResponsesUpstream[index].HistoricalAPIKeys = newHistoricalKeys\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Printf(\"[Responses-Key] 已添加API密钥到 Responses 上游 [%d] %s\", index, cm.config.ResponsesUpstream[index].Name)\n\treturn nil\n}\n\n// RemoveResponsesAPIKey 删除 Responses 上游的 API 密钥\nfunc (cm *ConfigManager) RemoveResponsesAPIKey(index int, apiKey string) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif index < 0 || index >= len(cm.config.ResponsesUpstream) {\n\t\treturn fmt.Errorf(\"无效的上游索引: %d\", index)\n\t}\n\n\t// 查找并删除密钥\n\tkeys := cm.config.ResponsesUpstream[index].APIKeys\n\tfound := false\n\tfor i, key := range keys {\n\t\tif key == apiKey {\n\t\t\tcm.config.ResponsesUpstream[index].APIKeys = append(keys[:i], keys[i+1:]...)\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif !found {\n\t\treturn fmt.Errorf(\"API密钥不存在\")\n\t}\n\n\t// 将被移除的 Key 添加到历史列表（用于统计聚合）\n\talreadyInHistory := false\n\tfor _, hk := range cm.config.ResponsesUpstream[index].HistoricalAPIKeys {\n\t\tif hk == apiKey {\n\t\t\talreadyInHistory = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !alreadyInHistory {\n\t\tcm.config.ResponsesUpstream[index].HistoricalAPIKeys = append(cm.config.ResponsesUpstream[index].HistoricalAPIKeys, apiKey)\n\t\tlog.Printf(\"[Responses-Key] 上游 [%d] %s: Key %s 已移入历史列表\", index, cm.config.ResponsesUpstream[index].Name, utils.MaskAPIKey(apiKey))\n\t}\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Printf(\"[Responses-Key] 已从 Responses 上游 [%d] %s 删除API密钥\", index, cm.config.ResponsesUpstream[index].Name)\n\treturn nil\n}\n\n// GetNextResponsesAPIKey 获取下一个 API 密钥（Responses 负载均衡 - 纯 failover 模式）\nfunc (cm *ConfigManager) GetNextResponsesAPIKey(upstream *UpstreamConfig, failedKeys map[string]bool) (string, error) {\n\treturn cm.GetNextAPIKey(upstream, failedKeys, \"Responses\")\n}\n\n// SetResponsesLoadBalance 设置 Responses 负载均衡策略\nfunc (cm *ConfigManager) SetResponsesLoadBalance(strategy string) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif err := validateLoadBalanceStrategy(strategy); err != nil {\n\t\treturn err\n\t}\n\n\tcm.config.ResponsesLoadBalance = strategy\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Printf(\"[Config-LoadBalance] 已设置 Responses 负载均衡策略: %s\", strategy)\n\treturn nil\n}\n\n// MoveResponsesAPIKeyToTop 将指定 Responses 渠道的 API 密钥移到最前面\nfunc (cm *ConfigManager) MoveResponsesAPIKeyToTop(upstreamIndex int, apiKey string) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif upstreamIndex < 0 || upstreamIndex >= len(cm.config.ResponsesUpstream) {\n\t\treturn fmt.Errorf(\"无效的上游索引: %d\", upstreamIndex)\n\t}\n\n\tupstream := &cm.config.ResponsesUpstream[upstreamIndex]\n\tindex := -1\n\tfor i, key := range upstream.APIKeys {\n\t\tif key == apiKey {\n\t\t\tindex = i\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif index <= 0 {\n\t\treturn nil\n\t}\n\n\tupstream.APIKeys = append([]string{apiKey}, append(upstream.APIKeys[:index], upstream.APIKeys[index+1:]...)...)\n\treturn cm.saveConfigLocked(cm.config)\n}\n\n// MoveResponsesAPIKeyToBottom 将指定 Responses 渠道的 API 密钥移到最后面\nfunc (cm *ConfigManager) MoveResponsesAPIKeyToBottom(upstreamIndex int, apiKey string) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif upstreamIndex < 0 || upstreamIndex >= len(cm.config.ResponsesUpstream) {\n\t\treturn fmt.Errorf(\"无效的上游索引: %d\", upstreamIndex)\n\t}\n\n\tupstream := &cm.config.ResponsesUpstream[upstreamIndex]\n\tindex := -1\n\tfor i, key := range upstream.APIKeys {\n\t\tif key == apiKey {\n\t\t\tindex = i\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif index == -1 || index == len(upstream.APIKeys)-1 {\n\t\treturn nil\n\t}\n\n\tupstream.APIKeys = append(upstream.APIKeys[:index], upstream.APIKeys[index+1:]...)\n\tupstream.APIKeys = append(upstream.APIKeys, apiKey)\n\treturn cm.saveConfigLocked(cm.config)\n}\n\n// ReorderResponsesUpstreams 重新排序 Responses 渠道优先级\n// order 是渠道索引数组，按新的优先级顺序排列（只更新传入的渠道，支持部分排序）\nfunc (cm *ConfigManager) ReorderResponsesUpstreams(order []int) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif len(order) == 0 {\n\t\treturn fmt.Errorf(\"排序数组不能为空\")\n\t}\n\n\tseen := make(map[int]bool)\n\tfor _, idx := range order {\n\t\tif idx < 0 || idx >= len(cm.config.ResponsesUpstream) {\n\t\t\treturn fmt.Errorf(\"无效的渠道索引: %d\", idx)\n\t\t}\n\t\tif seen[idx] {\n\t\t\treturn fmt.Errorf(\"重复的渠道索引: %d\", idx)\n\t\t}\n\t\tseen[idx] = true\n\t}\n\n\t// 更新传入渠道的优先级（未传入的渠道保持原优先级不变）\n\t// 注意：priority 从 1 开始，避免 omitempty 吞掉 0 值\n\tfor i, idx := range order {\n\t\tcm.config.ResponsesUpstream[idx].Priority = i + 1\n\t}\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Printf(\"[Config-Reorder] 已更新 Responses 渠道优先级顺序 (%d 个渠道)\", len(order))\n\treturn nil\n}\n\n// SetResponsesChannelStatus 设置 Responses 渠道状态\nfunc (cm *ConfigManager) SetResponsesChannelStatus(index int, status string) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif index < 0 || index >= len(cm.config.ResponsesUpstream) {\n\t\treturn fmt.Errorf(\"无效的上游索引: %d\", index)\n\t}\n\n\t// 状态值转为小写，支持大小写不敏感\n\tstatus = strings.ToLower(status)\n\tif status != \"active\" && status != \"suspended\" && status != \"disabled\" {\n\t\treturn fmt.Errorf(\"无效的状态: %s (允许值: active, suspended, disabled)\", status)\n\t}\n\n\tcm.config.ResponsesUpstream[index].Status = status\n\n\t// 暂停时清除促销期\n\tif status == \"suspended\" && cm.config.ResponsesUpstream[index].PromotionUntil != nil {\n\t\tcm.config.ResponsesUpstream[index].PromotionUntil = nil\n\t\tlog.Printf(\"[Config-Status] 已清除 Responses 渠道 [%d] %s 的促销期\", index, cm.config.ResponsesUpstream[index].Name)\n\t}\n\n\tif err := cm.saveConfigLocked(cm.config); err != nil {\n\t\treturn err\n\t}\n\n\tlog.Printf(\"[Config-Status] 已设置 Responses 渠道 [%d] %s 状态为: %s\", index, cm.config.ResponsesUpstream[index].Name, status)\n\treturn nil\n}\n\n// SetResponsesChannelPromotion 设置 Responses 渠道促销期\nfunc (cm *ConfigManager) SetResponsesChannelPromotion(index int, duration time.Duration) error {\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\tif index < 0 || index >= len(cm.config.ResponsesUpstream) {\n\t\treturn fmt.Errorf(\"无效的 Responses 上游索引: %d\", index)\n\t}\n\n\tif duration <= 0 {\n\t\tcm.config.ResponsesUpstream[index].PromotionUntil = nil\n\t\tlog.Printf(\"[Config-Promotion] 已清除 Responses 渠道 [%d] %s 的促销期\", index, cm.config.ResponsesUpstream[index].Name)\n\t} else {\n\t\t// 清除其他渠道的促销期（同一时间只允许一个促销渠道）\n\t\tfor i := range cm.config.ResponsesUpstream {\n\t\t\tif i != index && cm.config.ResponsesUpstream[i].PromotionUntil != nil {\n\t\t\t\tcm.config.ResponsesUpstream[i].PromotionUntil = nil\n\t\t\t}\n\t\t}\n\t\tpromotionEnd := time.Now().Add(duration)\n\t\tcm.config.ResponsesUpstream[index].PromotionUntil = &promotionEnd\n\t\tlog.Printf(\"[Config-Promotion] 已设置 Responses 渠道 [%d] %s 进入促销期，截止: %s\", index, cm.config.ResponsesUpstream[index].Name, promotionEnd.Format(time.RFC3339))\n\t}\n\n\treturn cm.saveConfigLocked(cm.config)\n}\n\n// GetPromotedResponsesChannel 获取当前处于促销期的 Responses 渠道索引\nfunc (cm *ConfigManager) GetPromotedResponsesChannel() (int, bool) {\n\tcm.mu.RLock()\n\tdefer cm.mu.RUnlock()\n\n\tfor i, upstream := range cm.config.ResponsesUpstream {\n\t\tif IsChannelInPromotion(&upstream) && GetChannelStatus(&upstream) == \"active\" {\n\t\t\treturn i, true\n\t\t}\n\t}\n\treturn -1, false\n}\n"
  },
  {
    "path": "backend-go/internal/config/config_utils.go",
    "content": "package config\n\nimport (\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n)\n\n// ============== 工具函数 ==============\n\n// deduplicateStrings 去重字符串切片，保持原始顺序\nfunc deduplicateStrings(items []string) []string {\n\tif len(items) <= 1 {\n\t\treturn items\n\t}\n\tseen := make(map[string]struct{}, len(items))\n\tresult := make([]string, 0, len(items))\n\tfor _, item := range items {\n\t\tif _, exists := seen[item]; !exists {\n\t\t\tseen[item] = struct{}{}\n\t\t\tresult = append(result, item)\n\t\t}\n\t}\n\treturn result\n}\n\n// deduplicateBaseURLs 去重 BaseURLs，忽略末尾 / 和 # 差异\nfunc deduplicateBaseURLs(urls []string) []string {\n\tif len(urls) <= 1 {\n\t\treturn urls\n\t}\n\tseen := make(map[string]struct{}, len(urls))\n\tresult := make([]string, 0, len(urls))\n\tfor _, url := range urls {\n\t\tnormalized := strings.TrimRight(url, \"/#\")\n\t\tif _, exists := seen[normalized]; !exists {\n\t\t\tseen[normalized] = struct{}{}\n\t\t\tresult = append(result, url)\n\t\t}\n\t}\n\treturn result\n}\n\n// validateLoadBalanceStrategy 验证负载均衡策略\nfunc validateLoadBalanceStrategy(strategy string) error {\n\t// 只接受 failover 策略（round-robin 和 random 已移除）\n\t// 为兼容旧配置，仍允许旧值但静默忽略\n\tif strategy != \"failover\" && strategy != \"round-robin\" && strategy != \"random\" {\n\t\treturn &ConfigError{Message: \"无效的负载均衡策略: \" + strategy}\n\t}\n\treturn nil\n}\n\n// ConfigError 配置错误\ntype ConfigError struct {\n\tMessage string\n}\n\nfunc (e *ConfigError) Error() string {\n\treturn e.Message\n}\n\n// ============== 模型重定向 ==============\n\n// RedirectModel 模型重定向\nfunc RedirectModel(model string, upstream *UpstreamConfig) string {\n\tif upstream.ModelMapping == nil || len(upstream.ModelMapping) == 0 {\n\t\treturn model\n\t}\n\n\t// 直接匹配（精确匹配优先）\n\tif mapped, ok := upstream.ModelMapping[model]; ok {\n\t\treturn mapped\n\t}\n\n\t// 模糊匹配：按源模型长度从长到短排序，确保最长匹配优先\n\t// 例如：同时配置 \"codex\" 和 \"gpt-5.1-codex\" 时，\"gpt-5.1-codex\" 应该先匹配\n\ttype mapping struct {\n\t\tsource string\n\t\ttarget string\n\t}\n\tmappings := make([]mapping, 0, len(upstream.ModelMapping))\n\tfor source, target := range upstream.ModelMapping {\n\t\tmappings = append(mappings, mapping{source, target})\n\t}\n\t// 按源模型长度降序排序\n\tsort.Slice(mappings, func(i, j int) bool {\n\t\treturn len(mappings[i].source) > len(mappings[j].source)\n\t})\n\n\t// 按排序后的顺序进行模糊匹配\n\tfor _, m := range mappings {\n\t\tif strings.Contains(model, m.source) || strings.Contains(m.source, model) {\n\t\t\treturn m.target\n\t\t}\n\t}\n\n\treturn model\n}\n\n// ============== 渠道状态与优先级辅助函数 ==============\n\n// GetChannelStatus 获取渠道状态（带默认值处理）\nfunc GetChannelStatus(upstream *UpstreamConfig) string {\n\tif upstream.Status == \"\" {\n\t\treturn \"active\"\n\t}\n\treturn upstream.Status\n}\n\n// GetChannelPriority 获取渠道优先级（带默认值处理）\nfunc GetChannelPriority(upstream *UpstreamConfig, index int) int {\n\tif upstream.Priority == 0 {\n\t\treturn index\n\t}\n\treturn upstream.Priority\n}\n\n// IsChannelInPromotion 检查渠道是否处于促销期\nfunc IsChannelInPromotion(upstream *UpstreamConfig) bool {\n\tif upstream.PromotionUntil == nil {\n\t\treturn false\n\t}\n\treturn time.Now().Before(*upstream.PromotionUntil)\n}\n\n// ============== UpstreamConfig 方法 ==============\n\n// Clone 深拷贝 UpstreamConfig（用于避免并发修改问题）\n// 在多 BaseURL failover 场景下，需要临时修改 BaseURL 字段，\n// 使用深拷贝可避免并发请求之间的竞态条件\nfunc (u *UpstreamConfig) Clone() *UpstreamConfig {\n\tcloned := *u // 浅拷贝\n\n\t// 深拷贝切片字段\n\tif u.BaseURLs != nil {\n\t\tcloned.BaseURLs = make([]string, len(u.BaseURLs))\n\t\tcopy(cloned.BaseURLs, u.BaseURLs)\n\t}\n\tif u.APIKeys != nil {\n\t\tcloned.APIKeys = make([]string, len(u.APIKeys))\n\t\tcopy(cloned.APIKeys, u.APIKeys)\n\t}\n\tif u.HistoricalAPIKeys != nil {\n\t\tcloned.HistoricalAPIKeys = make([]string, len(u.HistoricalAPIKeys))\n\t\tcopy(cloned.HistoricalAPIKeys, u.HistoricalAPIKeys)\n\t}\n\tif u.ModelMapping != nil {\n\t\tcloned.ModelMapping = make(map[string]string, len(u.ModelMapping))\n\t\tfor k, v := range u.ModelMapping {\n\t\t\tcloned.ModelMapping[k] = v\n\t\t}\n\t}\n\tif u.PromotionUntil != nil {\n\t\tt := *u.PromotionUntil\n\t\tcloned.PromotionUntil = &t\n\t}\n\n\treturn &cloned\n}\n\n// GetEffectiveBaseURL 获取当前应使用的 BaseURL（纯 failover 模式）\n// 优先使用 BaseURL 字段（支持调用方临时覆盖），否则从 BaseURLs 数组获取\nfunc (u *UpstreamConfig) GetEffectiveBaseURL() string {\n\t// 优先使用 BaseURL（可能被调用方临时设置用于指定本次请求的 URL）\n\tif u.BaseURL != \"\" {\n\t\treturn u.BaseURL\n\t}\n\n\t// 回退到 BaseURLs 数组\n\tif len(u.BaseURLs) > 0 {\n\t\treturn u.BaseURLs[0]\n\t}\n\n\treturn \"\"\n}\n\n// GetAllBaseURLs 获取所有 BaseURL（用于延迟测试）\nfunc (u *UpstreamConfig) GetAllBaseURLs() []string {\n\tif len(u.BaseURLs) > 0 {\n\t\treturn u.BaseURLs\n\t}\n\tif u.BaseURL != \"\" {\n\t\treturn []string{u.BaseURL}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "backend-go/internal/config/env.go",
    "content": "package config\n\nimport (\n\t\"os\"\n\t\"strconv\"\n)\n\ntype EnvConfig struct {\n\tPort                 int\n\tEnv                  string\n\tEnableWebUI          bool\n\tProxyAccessKey       string\n\tLogLevel             string\n\tEnableRequestLogs    bool\n\tEnableResponseLogs   bool\n\tQuietPollingLogs     bool   // 静默轮询端点日志\n\tRawLogOutput         bool   // 原始日志输出（不缩进、不截断、不重排序）\n\tSSEDebugLevel        string // SSE 调试级别: off, summary, full\n\tRewriteResponseModel bool   // 是否改写响应中的 model 字段为请求的 model（默认 false）\n\n\tRequestTimeout     int\n\tMaxRequestBodySize int64 // 请求体最大大小 (字节)，由 MB 配置转换\n\tEnableCORS         bool\n\tCORSOrigin         string\n\t// 指标配置\n\tMetricsWindowSize       int     // 滑动窗口大小\n\tMetricsFailureThreshold float64 // 失败率阈值\n\t// 指标持久化配置\n\tMetricsPersistenceEnabled bool // 是否启用 SQLite 持久化\n\tMetricsRetentionDays      int  // 数据保留天数（3-30）\n\t// HTTP 客户端配置\n\tResponseHeaderTimeout int // 等待响应头超时时间（秒）\n\t// 日志文件相关配置\n\tLogDir        string\n\tLogFile       string\n\tLogMaxSize    int  // 单个日志文件最大大小 (MB)\n\tLogMaxBackups int  // 保留的旧日志文件最大数量\n\tLogMaxAge     int  // 保留的旧日志文件最大天数\n\tLogCompress   bool // 是否压缩旧日志文件\n\tLogToConsole  bool // 是否同时输出到控制台\n}\n\n// NewEnvConfig 创建环境配置\nfunc NewEnvConfig() *EnvConfig {\n\t// 支持 ENV 和 NODE_ENV（向后兼容）\n\tenv := getEnv(\"ENV\", \"\")\n\tif env == \"\" {\n\t\tenv = getEnv(\"NODE_ENV\", \"development\")\n\t}\n\n\treturn &EnvConfig{\n\t\tPort:                 getEnvAsInt(\"PORT\", 3000),\n\t\tEnv:                  env,\n\t\tEnableWebUI:          getEnv(\"ENABLE_WEB_UI\", \"true\") != \"false\",\n\t\tProxyAccessKey:       getEnv(\"PROXY_ACCESS_KEY\", \"your-proxy-access-key\"),\n\t\tLogLevel:             getEnv(\"LOG_LEVEL\", \"info\"),\n\t\tEnableRequestLogs:    getEnv(\"ENABLE_REQUEST_LOGS\", \"true\") != \"false\",\n\t\tEnableResponseLogs:   getEnv(\"ENABLE_RESPONSE_LOGS\", \"true\") != \"false\",\n\t\tQuietPollingLogs:     getEnv(\"QUIET_POLLING_LOGS\", \"true\") != \"false\",\n\t\tRawLogOutput:         getEnv(\"RAW_LOG_OUTPUT\", \"false\") == \"true\",\n\t\tSSEDebugLevel:        getEnv(\"SSE_DEBUG_LEVEL\", \"off\"),\n\t\tRewriteResponseModel: getEnv(\"REWRITE_RESPONSE_MODEL\", \"false\") == \"true\",\n\n\t\tRequestTimeout:     getEnvAsInt(\"REQUEST_TIMEOUT\", 300000),\n\t\tMaxRequestBodySize: getEnvAsInt64(\"MAX_REQUEST_BODY_SIZE_MB\", 50) * 1024 * 1024, // MB 转换为字节\n\t\tEnableCORS:         getEnv(\"ENABLE_CORS\", \"true\") != \"false\",\n\t\tCORSOrigin:         getEnv(\"CORS_ORIGIN\", \"*\"),\n\t\t// 指标配置\n\t\tMetricsWindowSize:       getEnvAsInt(\"METRICS_WINDOW_SIZE\", 10),\n\t\tMetricsFailureThreshold: getEnvAsFloat(\"METRICS_FAILURE_THRESHOLD\", 0.5),\n\t\t// 指标持久化配置\n\t\tMetricsPersistenceEnabled: getEnv(\"METRICS_PERSISTENCE_ENABLED\", \"true\") != \"false\",\n\t\tMetricsRetentionDays:      clampInt(getEnvAsInt(\"METRICS_RETENTION_DAYS\", 7), 3, 30),\n\t\t// HTTP 客户端配置\n\t\tResponseHeaderTimeout: clampInt(getEnvAsInt(\"RESPONSE_HEADER_TIMEOUT\", 60), 30, 120), // 30-120 秒\n\t\t// 日志文件配置\n\t\tLogDir:        getEnv(\"LOG_DIR\", \"logs\"),\n\t\tLogFile:       getEnv(\"LOG_FILE\", \"app.log\"),\n\t\tLogMaxSize:    getEnvAsInt(\"LOG_MAX_SIZE\", 100),   // 默认 100MB\n\t\tLogMaxBackups: getEnvAsInt(\"LOG_MAX_BACKUPS\", 10), // 默认保留 10 个\n\t\tLogMaxAge:     getEnvAsInt(\"LOG_MAX_AGE\", 30),     // 默认保留 30 天\n\t\tLogCompress:   getEnv(\"LOG_COMPRESS\", \"true\") != \"false\",\n\t\tLogToConsole:  getEnv(\"LOG_TO_CONSOLE\", \"true\") != \"false\",\n\t}\n}\n\n// IsDevelopment 是否为开发环境\nfunc (c *EnvConfig) IsDevelopment() bool {\n\treturn c.Env == \"development\"\n}\n\n// IsProduction 是否为生产环境\nfunc (c *EnvConfig) IsProduction() bool {\n\treturn c.Env == \"production\"\n}\n\n// ShouldLog 是否应该记录日志\nfunc (c *EnvConfig) ShouldLog(level string) bool {\n\tlevels := map[string]int{\n\t\t\"error\": 0,\n\t\t\"warn\":  1,\n\t\t\"info\":  2,\n\t\t\"debug\": 3,\n\t}\n\n\tcurrentLevel, ok := levels[c.LogLevel]\n\tif !ok {\n\t\tcurrentLevel = 2 // 默认 info\n\t}\n\n\trequestLevel, ok := levels[level]\n\tif !ok {\n\t\treturn false\n\t}\n\n\treturn requestLevel <= currentLevel\n}\n\n// getEnv 获取环境变量，如果不存在则返回默认值\nfunc getEnv(key, defaultValue string) string {\n\tif value := os.Getenv(key); value != \"\" {\n\t\treturn value\n\t}\n\treturn defaultValue\n}\n\n// getEnvAsInt 获取环境变量并转换为整数\nfunc getEnvAsInt(key string, defaultValue int) int {\n\tif value := os.Getenv(key); value != \"\" {\n\t\tif intValue, err := strconv.Atoi(value); err == nil {\n\t\t\treturn intValue\n\t\t}\n\t}\n\treturn defaultValue\n}\n\n// getEnvAsInt64 获取环境变量并转换为 int64\nfunc getEnvAsInt64(key string, defaultValue int64) int64 {\n\tif value := os.Getenv(key); value != \"\" {\n\t\tif intValue, err := strconv.ParseInt(value, 10, 64); err == nil {\n\t\t\treturn intValue\n\t\t}\n\t}\n\treturn defaultValue\n}\n\n// getEnvAsFloat 获取环境变量并转换为浮点数\nfunc getEnvAsFloat(key string, defaultValue float64) float64 {\n\tif value := os.Getenv(key); value != \"\" {\n\t\tif floatValue, err := strconv.ParseFloat(value, 64); err == nil {\n\t\t\treturn floatValue\n\t\t}\n\t}\n\treturn defaultValue\n}\n\n// clampInt 将整数限制在指定范围内\nfunc clampInt(value, minVal, maxVal int) int {\n\tif value < minVal {\n\t\treturn minVal\n\t}\n\tif value > maxVal {\n\t\treturn maxVal\n\t}\n\treturn value\n}\n"
  },
  {
    "path": "backend-go/internal/converters/chat_to_responses.go",
    "content": "package converters\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// chatToResponsesState 流式转换状态\ntype chatToResponsesState struct {\n\tSeq          int\n\tResponseID   string\n\tCreatedAt    int64\n\tCurrentMsgID string\n\tCurrentFCID  string\n\tInTextBlock  bool\n\tInFuncBlock  bool\n\tFuncArgsBuf  map[int]*strings.Builder // index -> args\n\tFuncNames    map[int]string           // index -> function name\n\tFuncCallIDs  map[int]string           // index -> call id\n\tTextBuf      strings.Builder\n\t// reasoning state\n\tReasoningActive    bool\n\tReasoningItemID    string\n\tReasoningBuf       strings.Builder\n\tReasoningPartAdded bool\n\tReasoningIndex     int\n\t// usage（完整支持详细字段，参考 claude-code-hub）\n\tInputTokens     int64\n\tOutputTokens    int64\n\tCachedTokens    int64 // input_tokens_details.cached_tokens / cache_read_input_tokens\n\tReasoningTokens int64 // output_tokens_details.reasoning_tokens\n\tUsageSeen       bool\n\t// Claude 缓存 TTL 细分\n\tCacheCreationTokens   int64  // cache_creation_input_tokens\n\tCacheCreation5mTokens int64  // cache_creation_5m_input_tokens\n\tCacheCreation1hTokens int64  // cache_creation_1h_input_tokens\n\tCacheTTL              string // \"5m\" | \"1h\" | \"mixed\"\n\t// 首次消息标记\n\tFirstChunk bool\n}\n\nvar chatDataTag = []byte(\"data:\")\n\nfunc emitResponsesEvent(event string, payload string) string {\n\treturn fmt.Sprintf(\"event: %s\\ndata: %s\\n\\n\", event, payload)\n}\n\n// ConvertOpenAIChatToResponses 将 OpenAI Chat Completions SSE 转换为 Responses SSE 事件\n// ctx: 上下文\n// modelName: 模型名称\n// originalRequestRawJSON: 原始的 Responses API 请求 JSON（用于回显字段）\n// requestRawJSON: 转换后的 Chat Completions 请求 JSON\n// rawJSON: OpenAI Chat Completions SSE 行\n// param: 状态指针（在多次调用间保持状态）\nfunc ConvertOpenAIChatToResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {\n\tif *param == nil {\n\t\t*param = &chatToResponsesState{\n\t\t\tFuncArgsBuf: make(map[int]*strings.Builder),\n\t\t\tFuncNames:   make(map[int]string),\n\t\t\tFuncCallIDs: make(map[int]string),\n\t\t\tFirstChunk:  true,\n\t\t}\n\t}\n\tst := (*param).(*chatToResponsesState)\n\n\t// 期望 `data: {..}` 格式\n\tif !bytes.HasPrefix(rawJSON, chatDataTag) {\n\t\treturn []string{}\n\t}\n\trawJSON = bytes.TrimSpace(rawJSON[5:])\n\n\t// 检查 [DONE] 标记\n\tif string(rawJSON) == \"[DONE]\" {\n\t\t// 生成完成事件\n\t\treturn st.generateCompletedEvents(originalRequestRawJSON)\n\t}\n\n\troot := gjson.ParseBytes(rawJSON)\n\tvar out []string\n\n\tnextSeq := func() int { st.Seq++; return st.Seq }\n\n\t// 处理首次 chunk - 初始化并生成 response.created 和 response.in_progress\n\tif st.FirstChunk {\n\t\tst.FirstChunk = false\n\t\t// 从 chunk 中提取 id\n\t\tif id := root.Get(\"id\"); id.Exists() {\n\t\t\tst.ResponseID = id.String()\n\t\t} else {\n\t\t\tst.ResponseID = fmt.Sprintf(\"resp_%d\", time.Now().UnixNano())\n\t\t}\n\t\tst.CreatedAt = time.Now().Unix()\n\n\t\t// 重置状态\n\t\tst.TextBuf.Reset()\n\t\tst.ReasoningBuf.Reset()\n\t\tst.ReasoningActive = false\n\t\tst.InTextBlock = false\n\t\tst.InFuncBlock = false\n\t\tst.CurrentMsgID = \"\"\n\t\tst.CurrentFCID = \"\"\n\t\tst.ReasoningItemID = \"\"\n\t\tst.ReasoningIndex = 0\n\t\tst.ReasoningPartAdded = false\n\t\tst.FuncArgsBuf = make(map[int]*strings.Builder)\n\t\tst.FuncNames = make(map[int]string)\n\t\tst.FuncCallIDs = make(map[int]string)\n\t\tst.InputTokens = 0\n\t\tst.OutputTokens = 0\n\t\tst.CachedTokens = 0\n\t\tst.ReasoningTokens = 0\n\t\tst.CacheCreationTokens = 0\n\t\tst.CacheCreation5mTokens = 0\n\t\tst.CacheCreation1hTokens = 0\n\t\tst.CacheTTL = \"\"\n\t\tst.UsageSeen = false\n\n\t\t// 发送 response.created\n\t\tcreated := `{\"type\":\"response.created\",\"sequence_number\":0,\"response\":{\"id\":\"\",\"object\":\"response\",\"created_at\":0,\"status\":\"in_progress\",\"background\":false,\"error\":null,\"instructions\":\"\"}}`\n\t\tcreated, _ = sjson.Set(created, \"sequence_number\", nextSeq())\n\t\tcreated, _ = sjson.Set(created, \"response.id\", st.ResponseID)\n\t\tcreated, _ = sjson.Set(created, \"response.created_at\", st.CreatedAt)\n\t\tout = append(out, emitResponsesEvent(\"response.created\", created))\n\n\t\t// 发送 response.in_progress\n\t\tinprog := `{\"type\":\"response.in_progress\",\"sequence_number\":0,\"response\":{\"id\":\"\",\"object\":\"response\",\"created_at\":0,\"status\":\"in_progress\"}}`\n\t\tinprog, _ = sjson.Set(inprog, \"sequence_number\", nextSeq())\n\t\tinprog, _ = sjson.Set(inprog, \"response.id\", st.ResponseID)\n\t\tinprog, _ = sjson.Set(inprog, \"response.created_at\", st.CreatedAt)\n\t\tout = append(out, emitResponsesEvent(\"response.in_progress\", inprog))\n\t}\n\n\t// 解析 choices\n\tchoices := root.Get(\"choices\")\n\tif !choices.Exists() || !choices.IsArray() {\n\t\treturn out\n\t}\n\n\tfor _, choice := range choices.Array() {\n\t\tdelta := choice.Get(\"delta\")\n\t\tif !delta.Exists() {\n\t\t\tcontinue\n\t\t}\n\n\t\tfinishReason := choice.Get(\"finish_reason\").String()\n\n\t\t// 处理 reasoning_content（OpenAI o1 模型的 reasoning）\n\t\tif reasoning := delta.Get(\"reasoning_content\"); reasoning.Exists() && reasoning.String() != \"\" {\n\t\t\treasoningText := reasoning.String()\n\n\t\t\t// 开始 reasoning block\n\t\t\tif !st.ReasoningActive {\n\t\t\t\tst.ReasoningActive = true\n\t\t\t\tst.ReasoningIndex = 0\n\t\t\t\tst.ReasoningBuf.Reset()\n\t\t\t\tst.ReasoningItemID = fmt.Sprintf(\"rs_%s_0\", st.ResponseID)\n\n\t\t\t\t// response.output_item.added for reasoning\n\t\t\t\titem := `{\"type\":\"response.output_item.added\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"reasoning\",\"status\":\"in_progress\",\"summary\":[]}}`\n\t\t\t\titem, _ = sjson.Set(item, \"sequence_number\", nextSeq())\n\t\t\t\titem, _ = sjson.Set(item, \"output_index\", st.ReasoningIndex)\n\t\t\t\titem, _ = sjson.Set(item, \"item.id\", st.ReasoningItemID)\n\t\t\t\tout = append(out, emitResponsesEvent(\"response.output_item.added\", item))\n\n\t\t\t\t// response.reasoning_summary_part.added\n\t\t\t\tpart := `{\"type\":\"response.reasoning_summary_part.added\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"summary_index\":0,\"part\":{\"type\":\"summary_text\",\"text\":\"\"}}`\n\t\t\t\tpart, _ = sjson.Set(part, \"sequence_number\", nextSeq())\n\t\t\t\tpart, _ = sjson.Set(part, \"item_id\", st.ReasoningItemID)\n\t\t\t\tpart, _ = sjson.Set(part, \"output_index\", st.ReasoningIndex)\n\t\t\t\tout = append(out, emitResponsesEvent(\"response.reasoning_summary_part.added\", part))\n\t\t\t\tst.ReasoningPartAdded = true\n\t\t\t}\n\n\t\t\t// 发送 reasoning delta\n\t\t\tst.ReasoningBuf.WriteString(reasoningText)\n\t\t\tmsg := `{\"type\":\"response.reasoning_summary_text.delta\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"summary_index\":0,\"text\":\"\"}`\n\t\t\tmsg, _ = sjson.Set(msg, \"sequence_number\", nextSeq())\n\t\t\tmsg, _ = sjson.Set(msg, \"item_id\", st.ReasoningItemID)\n\t\t\tmsg, _ = sjson.Set(msg, \"output_index\", st.ReasoningIndex)\n\t\t\tmsg, _ = sjson.Set(msg, \"text\", reasoningText)\n\t\t\tout = append(out, emitResponsesEvent(\"response.reasoning_summary_text.delta\", msg))\n\t\t}\n\n\t\t// 处理 content（文本内容）\n\t\tif content := delta.Get(\"content\"); content.Exists() && content.String() != \"\" {\n\t\t\tcontentText := content.String()\n\n\t\t\t// 如果 reasoning 还在活跃状态，先关闭它\n\t\t\tif st.ReasoningActive {\n\t\t\t\tout = append(out, st.closeReasoningBlock(nextSeq)...)\n\t\t\t}\n\n\t\t\t// 开始 text block\n\t\t\tif !st.InTextBlock {\n\t\t\t\tst.InTextBlock = true\n\t\t\t\t// 计算 output_index：如果有 reasoning 则为 1，否则为 0\n\t\t\t\toutputIndex := 0\n\t\t\t\tif st.ReasoningPartAdded {\n\t\t\t\t\toutputIndex = 1\n\t\t\t\t}\n\t\t\t\tst.CurrentMsgID = fmt.Sprintf(\"msg_%s_%d\", st.ResponseID, outputIndex)\n\n\t\t\t\t// response.output_item.added for message\n\t\t\t\titem := `{\"type\":\"response.output_item.added\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"}}`\n\t\t\t\titem, _ = sjson.Set(item, \"sequence_number\", nextSeq())\n\t\t\t\titem, _ = sjson.Set(item, \"output_index\", outputIndex)\n\t\t\t\titem, _ = sjson.Set(item, \"item.id\", st.CurrentMsgID)\n\t\t\t\tout = append(out, emitResponsesEvent(\"response.output_item.added\", item))\n\n\t\t\t\t// response.content_part.added\n\t\t\t\tpart := `{\"type\":\"response.content_part.added\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"}}`\n\t\t\t\tpart, _ = sjson.Set(part, \"sequence_number\", nextSeq())\n\t\t\t\tpart, _ = sjson.Set(part, \"item_id\", st.CurrentMsgID)\n\t\t\t\tpart, _ = sjson.Set(part, \"output_index\", outputIndex)\n\t\t\t\tout = append(out, emitResponsesEvent(\"response.content_part.added\", part))\n\t\t\t}\n\n\t\t\t// 发送 text delta\n\t\t\tst.TextBuf.WriteString(contentText)\n\t\t\toutputIndex := 0\n\t\t\tif st.ReasoningPartAdded {\n\t\t\t\toutputIndex = 1\n\t\t\t}\n\t\t\tmsg := `{\"type\":\"response.output_text.delta\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"content_index\":0,\"delta\":\"\",\"logprobs\":[]}`\n\t\t\tmsg, _ = sjson.Set(msg, \"sequence_number\", nextSeq())\n\t\t\tmsg, _ = sjson.Set(msg, \"item_id\", st.CurrentMsgID)\n\t\t\tmsg, _ = sjson.Set(msg, \"output_index\", outputIndex)\n\t\t\tmsg, _ = sjson.Set(msg, \"delta\", contentText)\n\t\t\tout = append(out, emitResponsesEvent(\"response.output_text.delta\", msg))\n\t\t}\n\n\t\t// 处理 tool_calls\n\t\tif toolCalls := delta.Get(\"tool_calls\"); toolCalls.Exists() && toolCalls.IsArray() {\n\t\t\tfor _, tc := range toolCalls.Array() {\n\t\t\t\tidx := int(tc.Get(\"index\").Int())\n\n\t\t\t\t// 如果 reasoning 还在活跃状态，先关闭它\n\t\t\t\tif st.ReasoningActive {\n\t\t\t\t\tout = append(out, st.closeReasoningBlock(nextSeq)...)\n\t\t\t\t}\n\n\t\t\t\t// 如果 text block 还在活跃状态，先关闭它\n\t\t\t\tif st.InTextBlock {\n\t\t\t\t\tout = append(out, st.closeTextBlock(nextSeq)...)\n\t\t\t\t}\n\n\t\t\t\t// 初始化 tool call 状态\n\t\t\t\tif st.FuncArgsBuf[idx] == nil {\n\t\t\t\t\tst.FuncArgsBuf[idx] = &strings.Builder{}\n\t\t\t\t}\n\n\t\t\t\t// 处理 tool call ID\n\t\t\t\tif tcID := tc.Get(\"id\"); tcID.Exists() && tcID.String() != \"\" {\n\t\t\t\t\tst.FuncCallIDs[idx] = tcID.String()\n\t\t\t\t\tst.CurrentFCID = tcID.String()\n\n\t\t\t\t\t// 开始新的 function_call item\n\t\t\t\t\tst.InFuncBlock = true\n\n\t\t\t\t\t// 计算 output_index\n\t\t\t\t\toutputIndex := idx\n\t\t\t\t\tif st.ReasoningPartAdded {\n\t\t\t\t\t\toutputIndex += 1\n\t\t\t\t\t}\n\t\t\t\t\tif st.CurrentMsgID != \"\" {\n\t\t\t\t\t\toutputIndex += 1\n\t\t\t\t\t}\n\n\t\t\t\t\titem := `{\"type\":\"response.output_item.added\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"\",\"name\":\"\"}}`\n\t\t\t\t\titem, _ = sjson.Set(item, \"sequence_number\", nextSeq())\n\t\t\t\t\titem, _ = sjson.Set(item, \"output_index\", outputIndex)\n\t\t\t\t\titem, _ = sjson.Set(item, \"item.id\", fmt.Sprintf(\"fc_%s\", st.CurrentFCID))\n\t\t\t\t\titem, _ = sjson.Set(item, \"item.call_id\", st.CurrentFCID)\n\t\t\t\t\tout = append(out, emitResponsesEvent(\"response.output_item.added\", item))\n\t\t\t\t}\n\n\t\t\t\t// 处理 function\n\t\t\t\tif function := tc.Get(\"function\"); function.Exists() {\n\t\t\t\t\t// 处理函数名\n\t\t\t\t\tif name := function.Get(\"name\"); name.Exists() && name.String() != \"\" {\n\t\t\t\t\t\tst.FuncNames[idx] = name.String()\n\t\t\t\t\t}\n\n\t\t\t\t\t// 处理参数\n\t\t\t\t\tif args := function.Get(\"arguments\"); args.Exists() && args.String() != \"\" {\n\t\t\t\t\t\tst.FuncArgsBuf[idx].WriteString(args.String())\n\n\t\t\t\t\t\t// 计算 output_index\n\t\t\t\t\t\toutputIndex := idx\n\t\t\t\t\t\tif st.ReasoningPartAdded {\n\t\t\t\t\t\t\toutputIndex += 1\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif st.CurrentMsgID != \"\" {\n\t\t\t\t\t\t\toutputIndex += 1\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tmsg := `{\"type\":\"response.function_call_arguments.delta\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"delta\":\"\"}`\n\t\t\t\t\t\tmsg, _ = sjson.Set(msg, \"sequence_number\", nextSeq())\n\t\t\t\t\t\tmsg, _ = sjson.Set(msg, \"item_id\", fmt.Sprintf(\"fc_%s\", st.FuncCallIDs[idx]))\n\t\t\t\t\t\tmsg, _ = sjson.Set(msg, \"output_index\", outputIndex)\n\t\t\t\t\t\tmsg, _ = sjson.Set(msg, \"delta\", args.String())\n\t\t\t\t\t\tout = append(out, emitResponsesEvent(\"response.function_call_arguments.delta\", msg))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 处理 finish_reason\n\t\tif finishReason != \"\" && finishReason != \"null\" {\n\t\t\t// 关闭所有打开的 blocks\n\t\t\tif st.ReasoningActive {\n\t\t\t\tout = append(out, st.closeReasoningBlock(nextSeq)...)\n\t\t\t}\n\t\t\tif st.InTextBlock {\n\t\t\t\tout = append(out, st.closeTextBlock(nextSeq)...)\n\t\t\t}\n\t\t\tif st.InFuncBlock {\n\t\t\t\tout = append(out, st.closeFuncBlocks(nextSeq)...)\n\t\t\t}\n\t\t}\n\t}\n\n\t// 处理 usage（完整支持多格式详细字段，参考 claude-code-hub）\n\tif usage := root.Get(\"usage\"); usage.Exists() {\n\t\tst.UsageSeen = true\n\n\t\t// OpenAI 格式基础字段\n\t\tif v := usage.Get(\"prompt_tokens\"); v.Exists() {\n\t\t\tst.InputTokens = v.Int()\n\t\t}\n\t\tif v := usage.Get(\"completion_tokens\"); v.Exists() {\n\t\t\tst.OutputTokens = v.Int()\n\t\t}\n\n\t\t// OpenAI 格式详细字段\n\t\tif v := usage.Get(\"prompt_tokens_details.cached_tokens\"); v.Exists() {\n\t\t\tst.CachedTokens = v.Int()\n\t\t}\n\t\tif v := usage.Get(\"completion_tokens_details.reasoning_tokens\"); v.Exists() {\n\t\t\tst.ReasoningTokens = v.Int()\n\t\t}\n\n\t\t// Claude 格式基础字段（优先级高于 OpenAI）\n\t\tif v := usage.Get(\"input_tokens\"); v.Exists() {\n\t\t\tst.InputTokens = v.Int()\n\t\t}\n\t\tif v := usage.Get(\"output_tokens\"); v.Exists() {\n\t\t\tst.OutputTokens = v.Int()\n\t\t}\n\n\t\t// Claude 格式缓存字段\n\t\tif v := usage.Get(\"cache_read_input_tokens\"); v.Exists() {\n\t\t\tst.CachedTokens = v.Int()\n\t\t}\n\t\tif v := usage.Get(\"cache_creation_input_tokens\"); v.Exists() {\n\t\t\tst.CacheCreationTokens = v.Int()\n\t\t}\n\t\tif v := usage.Get(\"cache_creation_5m_input_tokens\"); v.Exists() {\n\t\t\tst.CacheCreation5mTokens = v.Int()\n\t\t}\n\t\tif v := usage.Get(\"cache_creation_1h_input_tokens\"); v.Exists() {\n\t\t\tst.CacheCreation1hTokens = v.Int()\n\t\t}\n\n\t\t// 设置缓存 TTL 标识\n\t\thas5m := st.CacheCreation5mTokens > 0\n\t\thas1h := st.CacheCreation1hTokens > 0\n\t\tif has5m && has1h {\n\t\t\tst.CacheTTL = \"mixed\"\n\t\t} else if has1h {\n\t\t\tst.CacheTTL = \"1h\"\n\t\t} else if has5m {\n\t\t\tst.CacheTTL = \"5m\"\n\t\t}\n\n\t\t// Gemini 格式（自动去重）\n\t\tif v := usage.Get(\"promptTokenCount\"); v.Exists() {\n\t\t\tpromptTokens := v.Int()\n\t\t\tcachedTokens := usage.Get(\"cachedContentTokenCount\").Int()\n\t\t\t// Gemini 的 promptTokenCount 已包含 cachedContentTokenCount，需要扣除\n\t\t\tactualInput := promptTokens - cachedTokens\n\t\t\tif actualInput < 0 {\n\t\t\t\tactualInput = 0\n\t\t\t}\n\t\t\tst.InputTokens = actualInput\n\t\t\tst.CachedTokens = cachedTokens\n\t\t}\n\t\tif v := usage.Get(\"candidatesTokenCount\"); v.Exists() {\n\t\t\tst.OutputTokens = v.Int()\n\t\t}\n\t}\n\n\treturn out\n}\n\n// closeReasoningBlock 关闭 reasoning block\nfunc (st *chatToResponsesState) closeReasoningBlock(nextSeq func() int) []string {\n\tif !st.ReasoningActive {\n\t\treturn nil\n\t}\n\n\tvar out []string\n\tfull := st.ReasoningBuf.String()\n\n\t// response.reasoning_summary_text.done\n\ttextDone := `{\"type\":\"response.reasoning_summary_text.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"summary_index\":0,\"text\":\"\"}`\n\ttextDone, _ = sjson.Set(textDone, \"sequence_number\", nextSeq())\n\ttextDone, _ = sjson.Set(textDone, \"item_id\", st.ReasoningItemID)\n\ttextDone, _ = sjson.Set(textDone, \"output_index\", st.ReasoningIndex)\n\ttextDone, _ = sjson.Set(textDone, \"text\", full)\n\tout = append(out, emitResponsesEvent(\"response.reasoning_summary_text.done\", textDone))\n\n\t// response.reasoning_summary_part.done\n\tpartDone := `{\"type\":\"response.reasoning_summary_part.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"summary_index\":0,\"part\":{\"type\":\"summary_text\",\"text\":\"\"}}`\n\tpartDone, _ = sjson.Set(partDone, \"sequence_number\", nextSeq())\n\tpartDone, _ = sjson.Set(partDone, \"item_id\", st.ReasoningItemID)\n\tpartDone, _ = sjson.Set(partDone, \"output_index\", st.ReasoningIndex)\n\tpartDone, _ = sjson.Set(partDone, \"part.text\", full)\n\tout = append(out, emitResponsesEvent(\"response.reasoning_summary_part.done\", partDone))\n\n\t// response.output_item.done for reasoning\n\titemDone := `{\"type\":\"response.output_item.done\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"reasoning\",\"status\":\"completed\",\"summary\":[]}}`\n\titemDone, _ = sjson.Set(itemDone, \"sequence_number\", nextSeq())\n\titemDone, _ = sjson.Set(itemDone, \"output_index\", st.ReasoningIndex)\n\titemDone, _ = sjson.Set(itemDone, \"item.id\", st.ReasoningItemID)\n\titemDone, _ = sjson.Set(itemDone, \"item.summary\", []interface{}{map[string]interface{}{\"type\": \"summary_text\", \"text\": full}})\n\tout = append(out, emitResponsesEvent(\"response.output_item.done\", itemDone))\n\n\tst.ReasoningActive = false\n\treturn out\n}\n\n// closeTextBlock 关闭 text block\nfunc (st *chatToResponsesState) closeTextBlock(nextSeq func() int) []string {\n\tif !st.InTextBlock {\n\t\treturn nil\n\t}\n\n\tvar out []string\n\toutputIndex := 0\n\tif st.ReasoningPartAdded {\n\t\toutputIndex = 1\n\t}\n\n\t// response.output_text.done\n\tdone := `{\"type\":\"response.output_text.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"content_index\":0,\"text\":\"\",\"logprobs\":[]}`\n\tdone, _ = sjson.Set(done, \"sequence_number\", nextSeq())\n\tdone, _ = sjson.Set(done, \"item_id\", st.CurrentMsgID)\n\tdone, _ = sjson.Set(done, \"output_index\", outputIndex)\n\tdone, _ = sjson.Set(done, \"text\", st.TextBuf.String())\n\tout = append(out, emitResponsesEvent(\"response.output_text.done\", done))\n\n\t// response.content_part.done\n\tpartDone := `{\"type\":\"response.content_part.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"}}`\n\tpartDone, _ = sjson.Set(partDone, \"sequence_number\", nextSeq())\n\tpartDone, _ = sjson.Set(partDone, \"item_id\", st.CurrentMsgID)\n\tpartDone, _ = sjson.Set(partDone, \"output_index\", outputIndex)\n\tpartDone, _ = sjson.Set(partDone, \"part.text\", st.TextBuf.String())\n\tout = append(out, emitResponsesEvent(\"response.content_part.done\", partDone))\n\n\t// response.output_item.done for message\n\tfinal := `{\"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\"}}`\n\tfinal, _ = sjson.Set(final, \"sequence_number\", nextSeq())\n\tfinal, _ = sjson.Set(final, \"output_index\", outputIndex)\n\tfinal, _ = sjson.Set(final, \"item.id\", st.CurrentMsgID)\n\tfinal, _ = sjson.Set(final, \"item.content.0.text\", st.TextBuf.String())\n\tout = append(out, emitResponsesEvent(\"response.output_item.done\", final))\n\n\tst.InTextBlock = false\n\treturn out\n}\n\n// closeFuncBlocks 关闭所有 function call blocks\nfunc (st *chatToResponsesState) closeFuncBlocks(nextSeq func() int) []string {\n\tif !st.InFuncBlock || len(st.FuncArgsBuf) == 0 {\n\t\treturn nil\n\t}\n\n\tvar out []string\n\n\t// 收集并排序索引\n\tidxs := make([]int, 0, len(st.FuncArgsBuf))\n\tfor idx := range st.FuncArgsBuf {\n\t\tidxs = append(idxs, idx)\n\t}\n\t// 简单排序\n\tfor i := 0; i < len(idxs); i++ {\n\t\tfor j := i + 1; j < len(idxs); j++ {\n\t\t\tif idxs[j] < idxs[i] {\n\t\t\t\tidxs[i], idxs[j] = idxs[j], idxs[i]\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, idx := range idxs {\n\t\targs := \"{}\"\n\t\tif buf := st.FuncArgsBuf[idx]; buf != nil && buf.Len() > 0 {\n\t\t\targs = buf.String()\n\t\t}\n\t\tcallID := st.FuncCallIDs[idx]\n\t\tname := st.FuncNames[idx]\n\n\t\t// 计算 output_index\n\t\toutputIndex := idx\n\t\tif st.ReasoningPartAdded {\n\t\t\toutputIndex += 1\n\t\t}\n\t\tif st.CurrentMsgID != \"\" {\n\t\t\toutputIndex += 1\n\t\t}\n\n\t\t// response.function_call_arguments.done\n\t\tfcDone := `{\"type\":\"response.function_call_arguments.done\",\"sequence_number\":0,\"item_id\":\"\",\"output_index\":0,\"arguments\":\"\"}`\n\t\tfcDone, _ = sjson.Set(fcDone, \"sequence_number\", nextSeq())\n\t\tfcDone, _ = sjson.Set(fcDone, \"item_id\", fmt.Sprintf(\"fc_%s\", callID))\n\t\tfcDone, _ = sjson.Set(fcDone, \"output_index\", outputIndex)\n\t\tfcDone, _ = sjson.Set(fcDone, \"arguments\", args)\n\t\tout = append(out, emitResponsesEvent(\"response.function_call_arguments.done\", fcDone))\n\n\t\t// response.output_item.done for function_call\n\t\titemDone := `{\"type\":\"response.output_item.done\",\"sequence_number\":0,\"output_index\":0,\"item\":{\"id\":\"\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"\",\"call_id\":\"\",\"name\":\"\"}}`\n\t\titemDone, _ = sjson.Set(itemDone, \"sequence_number\", nextSeq())\n\t\titemDone, _ = sjson.Set(itemDone, \"output_index\", outputIndex)\n\t\titemDone, _ = sjson.Set(itemDone, \"item.id\", fmt.Sprintf(\"fc_%s\", callID))\n\t\titemDone, _ = sjson.Set(itemDone, \"item.arguments\", args)\n\t\titemDone, _ = sjson.Set(itemDone, \"item.call_id\", callID)\n\t\titemDone, _ = sjson.Set(itemDone, \"item.name\", name)\n\t\tout = append(out, emitResponsesEvent(\"response.output_item.done\", itemDone))\n\t}\n\n\tst.InFuncBlock = false\n\treturn out\n}\n\n// generateCompletedEvents 生成完成事件\nfunc (st *chatToResponsesState) generateCompletedEvents(originalRequestRawJSON []byte) []string {\n\tvar out []string\n\tnextSeq := func() int { st.Seq++; return st.Seq }\n\n\t// 先关闭所有打开的 blocks\n\tif st.ReasoningActive {\n\t\tout = append(out, st.closeReasoningBlock(nextSeq)...)\n\t}\n\tif st.InTextBlock {\n\t\tout = append(out, st.closeTextBlock(nextSeq)...)\n\t}\n\tif st.InFuncBlock {\n\t\tout = append(out, st.closeFuncBlocks(nextSeq)...)\n\t}\n\n\t// 构建 response.completed\n\tcompleted := `{\"type\":\"response.completed\",\"sequence_number\":0,\"response\":{\"id\":\"\",\"object\":\"response\",\"created_at\":0,\"status\":\"completed\",\"background\":false,\"error\":null}}`\n\tcompleted, _ = sjson.Set(completed, \"sequence_number\", nextSeq())\n\tcompleted, _ = sjson.Set(completed, \"response.id\", st.ResponseID)\n\tcompleted, _ = sjson.Set(completed, \"response.created_at\", st.CreatedAt)\n\n\t// 注入原始请求字段\n\tif originalRequestRawJSON != nil {\n\t\treq := gjson.ParseBytes(originalRequestRawJSON)\n\t\tif v := req.Get(\"instructions\"); v.Exists() {\n\t\t\tcompleted, _ = sjson.Set(completed, \"response.instructions\", v.String())\n\t\t}\n\t\tif v := req.Get(\"max_output_tokens\"); v.Exists() {\n\t\t\tcompleted, _ = sjson.Set(completed, \"response.max_output_tokens\", v.Int())\n\t\t}\n\t\tif v := req.Get(\"model\"); v.Exists() {\n\t\t\tcompleted, _ = sjson.Set(completed, \"response.model\", v.String())\n\t\t}\n\t\tif v := req.Get(\"parallel_tool_calls\"); v.Exists() {\n\t\t\tcompleted, _ = sjson.Set(completed, \"response.parallel_tool_calls\", v.Bool())\n\t\t}\n\t\tif v := req.Get(\"previous_response_id\"); v.Exists() {\n\t\t\tcompleted, _ = sjson.Set(completed, \"response.previous_response_id\", v.String())\n\t\t}\n\t\tif v := req.Get(\"reasoning\"); v.Exists() {\n\t\t\tcompleted, _ = sjson.Set(completed, \"response.reasoning\", v.Value())\n\t\t}\n\t\tif v := req.Get(\"temperature\"); v.Exists() {\n\t\t\tcompleted, _ = sjson.Set(completed, \"response.temperature\", v.Float())\n\t\t}\n\t\tif v := req.Get(\"tool_choice\"); v.Exists() {\n\t\t\tcompleted, _ = sjson.Set(completed, \"response.tool_choice\", v.Value())\n\t\t}\n\t\tif v := req.Get(\"tools\"); v.Exists() {\n\t\t\tcompleted, _ = sjson.Set(completed, \"response.tools\", v.Value())\n\t\t}\n\t\tif v := req.Get(\"top_p\"); v.Exists() {\n\t\t\tcompleted, _ = sjson.Set(completed, \"response.top_p\", v.Float())\n\t\t}\n\t\tif v := req.Get(\"metadata\"); v.Exists() {\n\t\t\tcompleted, _ = sjson.Set(completed, \"response.metadata\", v.Value())\n\t\t}\n\t}\n\n\t// 构建 output 数组\n\tvar outputs []interface{}\n\n\t// reasoning item（如果有）\n\tif st.ReasoningBuf.Len() > 0 || st.ReasoningPartAdded {\n\t\tr := map[string]interface{}{\n\t\t\t\"id\":     st.ReasoningItemID,\n\t\t\t\"type\":   \"reasoning\",\n\t\t\t\"status\": \"completed\",\n\t\t\t\"summary\": []interface{}{map[string]interface{}{\n\t\t\t\t\"type\": \"summary_text\",\n\t\t\t\t\"text\": st.ReasoningBuf.String(),\n\t\t\t}},\n\t\t}\n\t\toutputs = append(outputs, r)\n\t}\n\n\t// message item（如果有文本）\n\tif st.TextBuf.Len() > 0 || st.CurrentMsgID != \"\" {\n\t\tm := map[string]interface{}{\n\t\t\t\"id\":     st.CurrentMsgID,\n\t\t\t\"type\":   \"message\",\n\t\t\t\"status\": \"completed\",\n\t\t\t\"content\": []interface{}{map[string]interface{}{\n\t\t\t\t\"type\":        \"output_text\",\n\t\t\t\t\"annotations\": []interface{}{},\n\t\t\t\t\"logprobs\":    []interface{}{},\n\t\t\t\t\"text\":        st.TextBuf.String(),\n\t\t\t}},\n\t\t\t\"role\": \"assistant\",\n\t\t}\n\t\toutputs = append(outputs, m)\n\t}\n\n\t// function_call items\n\tif len(st.FuncArgsBuf) > 0 {\n\t\tidxs := make([]int, 0, len(st.FuncArgsBuf))\n\t\tfor idx := range st.FuncArgsBuf {\n\t\t\tidxs = append(idxs, idx)\n\t\t}\n\t\tfor i := 0; i < len(idxs); i++ {\n\t\t\tfor j := i + 1; j < len(idxs); j++ {\n\t\t\t\tif idxs[j] < idxs[i] {\n\t\t\t\t\tidxs[i], idxs[j] = idxs[j], idxs[i]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tfor _, idx := range idxs {\n\t\t\targs := \"\"\n\t\t\tif b := st.FuncArgsBuf[idx]; b != nil {\n\t\t\t\targs = b.String()\n\t\t\t}\n\t\t\tif args == \"\" {\n\t\t\t\targs = \"{}\"\n\t\t\t}\n\t\t\tcallID := st.FuncCallIDs[idx]\n\t\t\tname := st.FuncNames[idx]\n\t\t\titem := map[string]interface{}{\n\t\t\t\t\"id\":        fmt.Sprintf(\"fc_%s\", callID),\n\t\t\t\t\"type\":      \"function_call\",\n\t\t\t\t\"status\":    \"completed\",\n\t\t\t\t\"arguments\": args,\n\t\t\t\t\"call_id\":   callID,\n\t\t\t\t\"name\":      name,\n\t\t\t}\n\t\t\toutputs = append(outputs, item)\n\t\t}\n\t}\n\n\tif len(outputs) > 0 {\n\t\tcompleted, _ = sjson.Set(completed, \"response.output\", outputs)\n\t}\n\n\t// 添加 usage（完整支持多格式详细字段，参考 claude-code-hub）\n\treasoningTokens := st.ReasoningTokens\n\tif reasoningTokens == 0 && st.ReasoningBuf.Len() > 0 {\n\t\treasoningTokens = int64(st.ReasoningBuf.Len() / 4)\n\t}\n\n\t// 始终添加基础 usage 字段，即使值为 0\n\t// 这样 handler 可以检测到 usage 存在，并在需要时用本地估算值替换 0 值\n\t// 参见 handler.go 中的 patchResponsesCompletedEventUsage 和 injectResponsesUsageToCompletedEvent\n\tcompleted, _ = sjson.Set(completed, \"response.usage.input_tokens\", st.InputTokens)\n\tcompleted, _ = sjson.Set(completed, \"response.usage.output_tokens\", st.OutputTokens)\n\ttotal := st.InputTokens + st.OutputTokens\n\tcompleted, _ = sjson.Set(completed, \"response.usage.total_tokens\", total)\n\n\t// 可选的详情字段，仅在有值时添加\n\t// input_tokens_details\n\tif st.CachedTokens > 0 {\n\t\tcompleted, _ = sjson.Set(completed, \"response.usage.input_tokens_details.cached_tokens\", st.CachedTokens)\n\t}\n\n\t// output_tokens_details\n\tif reasoningTokens > 0 {\n\t\tcompleted, _ = sjson.Set(completed, \"response.usage.output_tokens_details.reasoning_tokens\", reasoningTokens)\n\t}\n\n\t// Claude 缓存 TTL 细分字段\n\tif st.CacheCreationTokens > 0 {\n\t\tcompleted, _ = sjson.Set(completed, \"response.usage.cache_creation_input_tokens\", st.CacheCreationTokens)\n\t}\n\tif st.CacheCreation5mTokens > 0 {\n\t\tcompleted, _ = sjson.Set(completed, \"response.usage.cache_creation_5m_input_tokens\", st.CacheCreation5mTokens)\n\t}\n\tif st.CacheCreation1hTokens > 0 {\n\t\tcompleted, _ = sjson.Set(completed, \"response.usage.cache_creation_1h_input_tokens\", st.CacheCreation1hTokens)\n\t}\n\tif st.CachedTokens > 0 {\n\t\tcompleted, _ = sjson.Set(completed, \"response.usage.cache_read_input_tokens\", st.CachedTokens)\n\t}\n\tif st.CacheTTL != \"\" {\n\t\tcompleted, _ = sjson.Set(completed, \"response.usage.cache_ttl\", st.CacheTTL)\n\t}\n\n\tout = append(out, emitResponsesEvent(\"response.completed\", completed))\n\treturn out\n}\n\n// ConvertOpenAIChatToResponsesNonStream 将 OpenAI Chat Completions 响应转换为 Responses 格式（非流式）\nfunc ConvertOpenAIChatToResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {\n\troot := gjson.ParseBytes(rawJSON)\n\n\t// 基础响应模板\n\tout := `{\"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}}`\n\n\t// 提取基本字段\n\tresponseID := root.Get(\"id\").String()\n\tif responseID == \"\" {\n\t\tresponseID = fmt.Sprintf(\"resp_%d\", time.Now().UnixNano())\n\t}\n\tcreatedAt := root.Get(\"created\").Int()\n\tif createdAt == 0 {\n\t\tcreatedAt = time.Now().Unix()\n\t}\n\n\tout, _ = sjson.Set(out, \"id\", responseID)\n\tout, _ = sjson.Set(out, \"created_at\", createdAt)\n\n\t// 注入原始请求字段\n\tif originalRequestRawJSON != nil {\n\t\treq := gjson.ParseBytes(originalRequestRawJSON)\n\t\tif v := req.Get(\"instructions\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"instructions\", v.String())\n\t\t}\n\t\tif v := req.Get(\"max_output_tokens\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"max_output_tokens\", v.Int())\n\t\t}\n\t\tif v := req.Get(\"model\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"model\", v.String())\n\t\t}\n\t\tif v := req.Get(\"parallel_tool_calls\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"parallel_tool_calls\", v.Bool())\n\t\t}\n\t\tif v := req.Get(\"previous_response_id\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"previous_response_id\", v.String())\n\t\t}\n\t\tif v := req.Get(\"reasoning\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"reasoning\", v.Value())\n\t\t}\n\t\tif v := req.Get(\"temperature\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"temperature\", v.Float())\n\t\t}\n\t\tif v := req.Get(\"tool_choice\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"tool_choice\", v.Value())\n\t\t}\n\t\tif v := req.Get(\"tools\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"tools\", v.Value())\n\t\t}\n\t\tif v := req.Get(\"top_p\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"top_p\", v.Float())\n\t\t}\n\t\tif v := req.Get(\"metadata\"); v.Exists() {\n\t\t\tout, _ = sjson.Set(out, \"metadata\", v.Value())\n\t\t}\n\t}\n\n\t// 解析 choices\n\tchoices := root.Get(\"choices\")\n\tif !choices.Exists() || !choices.IsArray() || len(choices.Array()) == 0 {\n\t\treturn out\n\t}\n\n\tvar outputs []interface{}\n\tvar textBuf strings.Builder\n\tvar reasoningBuf strings.Builder\n\tcurrentMsgID := fmt.Sprintf(\"msg_%s_0\", responseID)\n\n\tfor _, choice := range choices.Array() {\n\t\tmessage := choice.Get(\"message\")\n\t\tif !message.Exists() {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 处理 reasoning_content\n\t\tif reasoning := message.Get(\"reasoning_content\"); reasoning.Exists() && reasoning.String() != \"\" {\n\t\t\treasoningBuf.WriteString(reasoning.String())\n\t\t}\n\n\t\t// 处理 content\n\t\tif content := message.Get(\"content\"); content.Exists() && content.String() != \"\" {\n\t\t\ttextBuf.WriteString(content.String())\n\t\t}\n\n\t\t// 处理 tool_calls\n\t\tif toolCalls := message.Get(\"tool_calls\"); toolCalls.Exists() && toolCalls.IsArray() {\n\t\t\tfor _, tc := range toolCalls.Array() {\n\t\t\t\tcallID := tc.Get(\"id\").String()\n\t\t\t\tfuncName := tc.Get(\"function.name\").String()\n\t\t\t\tfuncArgs := tc.Get(\"function.arguments\").String()\n\t\t\t\tif funcArgs == \"\" {\n\t\t\t\t\tfuncArgs = \"{}\"\n\t\t\t\t}\n\n\t\t\t\titem := map[string]interface{}{\n\t\t\t\t\t\"id\":        fmt.Sprintf(\"fc_%s\", callID),\n\t\t\t\t\t\"type\":      \"function_call\",\n\t\t\t\t\t\"status\":    \"completed\",\n\t\t\t\t\t\"arguments\": funcArgs,\n\t\t\t\t\t\"call_id\":   callID,\n\t\t\t\t\t\"name\":      funcName,\n\t\t\t\t}\n\t\t\t\toutputs = append(outputs, item)\n\t\t\t}\n\t\t}\n\t}\n\n\t// 构建 output 数组\n\toutputIndex := 0\n\n\t// reasoning item（如果有）\n\tif reasoningBuf.Len() > 0 {\n\t\treasoningItemID := fmt.Sprintf(\"rs_%s_0\", responseID)\n\t\tr := map[string]interface{}{\n\t\t\t\"id\":     reasoningItemID,\n\t\t\t\"type\":   \"reasoning\",\n\t\t\t\"status\": \"completed\",\n\t\t\t\"summary\": []interface{}{map[string]interface{}{\n\t\t\t\t\"type\": \"summary_text\",\n\t\t\t\t\"text\": reasoningBuf.String(),\n\t\t\t}},\n\t\t}\n\t\t// 在开头插入\n\t\toutputs = append([]interface{}{r}, outputs...)\n\t\toutputIndex = 1\n\t\tcurrentMsgID = fmt.Sprintf(\"msg_%s_%d\", responseID, outputIndex)\n\t}\n\n\t// message item（如果有文本）\n\tif textBuf.Len() > 0 {\n\t\tm := map[string]interface{}{\n\t\t\t\"id\":     currentMsgID,\n\t\t\t\"type\":   \"message\",\n\t\t\t\"status\": \"completed\",\n\t\t\t\"content\": []interface{}{map[string]interface{}{\n\t\t\t\t\"type\":        \"output_text\",\n\t\t\t\t\"annotations\": []interface{}{},\n\t\t\t\t\"logprobs\":    []interface{}{},\n\t\t\t\t\"text\":        textBuf.String(),\n\t\t\t}},\n\t\t\t\"role\": \"assistant\",\n\t\t}\n\t\t// 在 reasoning 之后，tool_calls 之前插入\n\t\tif outputIndex > 0 {\n\t\t\t// 有 reasoning，插入到位置 1\n\t\t\tnewOutputs := make([]interface{}, 0, len(outputs)+1)\n\t\t\tnewOutputs = append(newOutputs, outputs[0]) // reasoning\n\t\t\tnewOutputs = append(newOutputs, m)          // message\n\t\t\tnewOutputs = append(newOutputs, outputs[1:]...)\n\t\t\toutputs = newOutputs\n\t\t} else {\n\t\t\t// 没有 reasoning，插入到开头\n\t\t\toutputs = append([]interface{}{m}, outputs...)\n\t\t}\n\t}\n\n\tif len(outputs) > 0 {\n\t\tout, _ = sjson.Set(out, \"output\", outputs)\n\t}\n\n\t// 处理 usage（完整支持多格式详细字段，参考 claude-code-hub）\n\tif usage := root.Get(\"usage\"); usage.Exists() {\n\t\tvar inputTokens, outputTokens, totalTokens, cachedTokens int64\n\t\tvar cacheCreation, cacheCreation5m, cacheCreation1h int64\n\t\tvar cacheTTL string\n\n\t\t// OpenAI 格式\n\t\tif v := usage.Get(\"prompt_tokens\"); v.Exists() {\n\t\t\tinputTokens = v.Int()\n\t\t}\n\t\tif v := usage.Get(\"completion_tokens\"); v.Exists() {\n\t\t\toutputTokens = v.Int()\n\t\t}\n\t\tif v := usage.Get(\"total_tokens\"); v.Exists() {\n\t\t\ttotalTokens = v.Int()\n\t\t}\n\t\tif v := usage.Get(\"prompt_tokens_details.cached_tokens\"); v.Exists() {\n\t\t\tcachedTokens = v.Int()\n\t\t}\n\t\treasoningTokensFromUsage := usage.Get(\"completion_tokens_details.reasoning_tokens\").Int()\n\n\t\t// Claude 格式（优先级高于 OpenAI）\n\t\tif v := usage.Get(\"input_tokens\"); v.Exists() {\n\t\t\tinputTokens = v.Int()\n\t\t}\n\t\tif v := usage.Get(\"output_tokens\"); v.Exists() {\n\t\t\toutputTokens = v.Int()\n\t\t}\n\t\tif v := usage.Get(\"cache_read_input_tokens\"); v.Exists() {\n\t\t\tcachedTokens = v.Int()\n\t\t}\n\t\tif v := usage.Get(\"cache_creation_input_tokens\"); v.Exists() {\n\t\t\tcacheCreation = v.Int()\n\t\t}\n\t\tif v := usage.Get(\"cache_creation_5m_input_tokens\"); v.Exists() {\n\t\t\tcacheCreation5m = v.Int()\n\t\t}\n\t\tif v := usage.Get(\"cache_creation_1h_input_tokens\"); v.Exists() {\n\t\t\tcacheCreation1h = v.Int()\n\t\t}\n\n\t\t// 设置缓存 TTL 标识\n\t\tif cacheCreation5m > 0 && cacheCreation1h > 0 {\n\t\t\tcacheTTL = \"mixed\"\n\t\t} else if cacheCreation1h > 0 {\n\t\t\tcacheTTL = \"1h\"\n\t\t} else if cacheCreation5m > 0 {\n\t\t\tcacheTTL = \"5m\"\n\t\t}\n\n\t\t// Gemini 格式（自动去重）\n\t\tif v := usage.Get(\"promptTokenCount\"); v.Exists() {\n\t\t\tpromptTokens := v.Int()\n\t\t\tgeminiCached := usage.Get(\"cachedContentTokenCount\").Int()\n\t\t\t// Gemini 的 promptTokenCount 已包含 cachedContentTokenCount，需要扣除\n\t\t\tactualInput := promptTokens - geminiCached\n\t\t\tif actualInput < 0 {\n\t\t\t\tactualInput = 0\n\t\t\t}\n\t\t\tinputTokens = actualInput\n\t\t\tcachedTokens = geminiCached\n\t\t}\n\t\tif v := usage.Get(\"candidatesTokenCount\"); v.Exists() {\n\t\t\toutputTokens = v.Int()\n\t\t}\n\n\t\t// 计算总量\n\t\tif totalTokens == 0 {\n\t\t\ttotalTokens = inputTokens + outputTokens\n\t\t}\n\n\t\t// 写入基础字段\n\t\tout, _ = sjson.Set(out, \"usage.input_tokens\", inputTokens)\n\t\tout, _ = sjson.Set(out, \"usage.output_tokens\", outputTokens)\n\t\tout, _ = sjson.Set(out, \"usage.total_tokens\", totalTokens)\n\n\t\t// input_tokens_details\n\t\tif cachedTokens > 0 {\n\t\t\tout, _ = sjson.Set(out, \"usage.input_tokens_details.cached_tokens\", cachedTokens)\n\t\t}\n\n\t\t// output_tokens_details\n\t\treasoningTokens := reasoningTokensFromUsage\n\t\tif reasoningTokens == 0 && reasoningBuf.Len() > 0 {\n\t\t\treasoningTokens = int64(reasoningBuf.Len() / 4)\n\t\t}\n\t\tif reasoningTokens > 0 {\n\t\t\tout, _ = sjson.Set(out, \"usage.output_tokens_details.reasoning_tokens\", reasoningTokens)\n\t\t}\n\n\t\t// Claude 缓存 TTL 细分字段\n\t\tif cacheCreation > 0 {\n\t\t\tout, _ = sjson.Set(out, \"usage.cache_creation_input_tokens\", cacheCreation)\n\t\t}\n\t\tif cacheCreation5m > 0 {\n\t\t\tout, _ = sjson.Set(out, \"usage.cache_creation_5m_input_tokens\", cacheCreation5m)\n\t\t}\n\t\tif cacheCreation1h > 0 {\n\t\t\tout, _ = sjson.Set(out, \"usage.cache_creation_1h_input_tokens\", cacheCreation1h)\n\t\t}\n\t\tif cachedTokens > 0 {\n\t\t\tout, _ = sjson.Set(out, \"usage.cache_read_input_tokens\", cachedTokens)\n\t\t}\n\t\tif cacheTTL != \"\" {\n\t\t\tout, _ = sjson.Set(out, \"usage.cache_ttl\", cacheTTL)\n\t\t}\n\t}\n\n\treturn out\n}\n"
  },
  {
    "path": "backend-go/internal/converters/chat_to_responses_test.go",
    "content": "package converters\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/tidwall/gjson\"\n)\n\nfunc TestConvertResponsesToOpenAIChatRequest(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    string\n\t\tmodel    string\n\t\tstream   bool\n\t\tvalidate func(t *testing.T, result []byte)\n\t}{\n\t\t{\n\t\t\tname: \"基本文本输入\",\n\t\t\tinput: `{\n\t\t\t\t\"model\": \"gpt-4\",\n\t\t\t\t\"input\": \"Hello, world!\",\n\t\t\t\t\"instructions\": \"You are a helpful assistant.\"\n\t\t\t}`,\n\t\t\tmodel:  \"gpt-4o\",\n\t\t\tstream: false,\n\t\t\tvalidate: func(t *testing.T, result []byte) {\n\t\t\t\troot := gjson.ParseBytes(result)\n\t\t\t\tif root.Get(\"model\").String() != \"gpt-4o\" {\n\t\t\t\t\tt.Errorf(\"model should be gpt-4o, got %s\", root.Get(\"model\").String())\n\t\t\t\t}\n\t\t\t\tif root.Get(\"stream\").Bool() != false {\n\t\t\t\t\tt.Error(\"stream should be false\")\n\t\t\t\t}\n\t\t\t\tmessages := root.Get(\"messages\").Array()\n\t\t\t\tif len(messages) != 2 {\n\t\t\t\t\tt.Errorf(\"should have 2 messages (system + user), got %d\", len(messages))\n\t\t\t\t}\n\t\t\t\tif messages[0].Get(\"role\").String() != \"system\" {\n\t\t\t\t\tt.Error(\"first message should be system\")\n\t\t\t\t}\n\t\t\t\tif messages[1].Get(\"role\").String() != \"user\" {\n\t\t\t\t\tt.Error(\"second message should be user\")\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"带 tools 的请求\",\n\t\t\tinput: `{\n\t\t\t\t\"model\": \"gpt-4\",\n\t\t\t\t\"input\": [{\"type\": \"message\", \"role\": \"user\", \"content\": [{\"type\": \"input_text\", \"text\": \"What's the weather?\"}]}],\n\t\t\t\t\"tools\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\t\t\"description\": \"Get weather info\",\n\t\t\t\t\t\t\"parameters\": {\"type\": \"object\", \"properties\": {\"location\": {\"type\": \"string\"}}}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}`,\n\t\t\tmodel:  \"gpt-4o\",\n\t\t\tstream: true,\n\t\t\tvalidate: func(t *testing.T, result []byte) {\n\t\t\t\troot := gjson.ParseBytes(result)\n\t\t\t\tif root.Get(\"stream\").Bool() != true {\n\t\t\t\t\tt.Error(\"stream should be true\")\n\t\t\t\t}\n\t\t\t\ttools := root.Get(\"tools\").Array()\n\t\t\t\tif len(tools) != 1 {\n\t\t\t\t\tt.Errorf(\"should have 1 tool, got %d\", len(tools))\n\t\t\t\t}\n\t\t\t\tif tools[0].Get(\"function.name\").String() != \"get_weather\" {\n\t\t\t\t\tt.Error(\"tool name should be get_weather\")\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"function_call 和 function_call_output\",\n\t\t\tinput: `{\n\t\t\t\t\"model\": \"gpt-4\",\n\t\t\t\t\"input\": [\n\t\t\t\t\t{\"type\": \"message\", \"role\": \"user\", \"content\": [{\"type\": \"input_text\", \"text\": \"What's the weather in NYC?\"}]},\n\t\t\t\t\t{\"type\": \"function_call\", \"call_id\": \"call_123\", \"name\": \"get_weather\", \"arguments\": \"{\\\"location\\\": \\\"NYC\\\"}\"},\n\t\t\t\t\t{\"type\": \"function_call_output\", \"call_id\": \"call_123\", \"output\": \"Sunny, 72°F\"}\n\t\t\t\t]\n\t\t\t}`,\n\t\t\tmodel:  \"gpt-4o\",\n\t\t\tstream: false,\n\t\t\tvalidate: func(t *testing.T, result []byte) {\n\t\t\t\troot := gjson.ParseBytes(result)\n\t\t\t\tmessages := root.Get(\"messages\").Array()\n\t\t\t\tif len(messages) != 3 {\n\t\t\t\t\tt.Errorf(\"should have 3 messages, got %d\", len(messages))\n\t\t\t\t}\n\t\t\t\t// 第二条消息应该是 assistant with tool_calls\n\t\t\t\tif messages[1].Get(\"role\").String() != \"assistant\" {\n\t\t\t\t\tt.Error(\"second message should be assistant\")\n\t\t\t\t}\n\t\t\t\tif !messages[1].Get(\"tool_calls\").Exists() {\n\t\t\t\t\tt.Error(\"assistant message should have tool_calls\")\n\t\t\t\t}\n\t\t\t\t// 第三条消息应该是 tool\n\t\t\t\tif messages[2].Get(\"role\").String() != \"tool\" {\n\t\t\t\t\tt.Error(\"third message should be tool\")\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"reasoning effort 转换\",\n\t\t\tinput: `{\n\t\t\t\t\"model\": \"o1-mini\",\n\t\t\t\t\"input\": \"Think about this\",\n\t\t\t\t\"reasoning\": {\"effort\": \"high\"}\n\t\t\t}`,\n\t\t\tmodel:  \"o1-mini\",\n\t\t\tstream: false,\n\t\t\tvalidate: func(t *testing.T, result []byte) {\n\t\t\t\troot := gjson.ParseBytes(result)\n\t\t\t\tif root.Get(\"reasoning_effort\").String() != \"high\" {\n\t\t\t\t\tt.Errorf(\"reasoning_effort should be high, got %s\", root.Get(\"reasoning_effort\").String())\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := ConvertResponsesToOpenAIChatRequest(tt.model, []byte(tt.input), tt.stream)\n\t\t\ttt.validate(t, result)\n\t\t})\n\t}\n}\n\nfunc TestConvertOpenAIChatToResponses_Stream(t *testing.T) {\n\tctx := context.Background()\n\n\t// 模拟 OpenAI Chat Completions SSE 流\n\tsseLines := []string{\n\t\t`data: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"created\":1234567890,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}`,\n\t\t`data: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"created\":1234567890,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}`,\n\t\t`data: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"created\":1234567890,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" world!\"},\"finish_reason\":null}]}`,\n\t\t`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}}`,\n\t\t`data: [DONE]`,\n\t}\n\n\toriginalReq := []byte(`{\"model\":\"gpt-4o\",\"input\":\"Hi\"}`)\n\n\tvar state any\n\tvar allEvents []string\n\n\tfor _, line := range sseLines {\n\t\tevents := ConvertOpenAIChatToResponses(ctx, \"gpt-4o\", originalReq, nil, []byte(line), &state)\n\t\tallEvents = append(allEvents, events...)\n\t}\n\n\t// 验证事件序列\n\tif len(allEvents) == 0 {\n\t\tt.Fatal(\"should produce events\")\n\t}\n\n\t// 检查是否有 response.created 事件\n\thasCreated := false\n\thasInProgress := false\n\thasCompleted := false\n\thasTextDelta := false\n\n\tfor _, ev := range allEvents {\n\t\tif strings.Contains(ev, \"response.created\") {\n\t\t\thasCreated = true\n\t\t}\n\t\tif strings.Contains(ev, \"response.in_progress\") {\n\t\t\thasInProgress = true\n\t\t}\n\t\tif strings.Contains(ev, \"response.completed\") {\n\t\t\thasCompleted = true\n\t\t}\n\t\tif strings.Contains(ev, \"response.output_text.delta\") {\n\t\t\thasTextDelta = true\n\t\t}\n\t}\n\n\tif !hasCreated {\n\t\tt.Error(\"should have response.created event\")\n\t}\n\tif !hasInProgress {\n\t\tt.Error(\"should have response.in_progress event\")\n\t}\n\tif !hasCompleted {\n\t\tt.Error(\"should have response.completed event\")\n\t}\n\tif !hasTextDelta {\n\t\tt.Error(\"should have response.output_text.delta event\")\n\t}\n}\n\nfunc TestConvertOpenAIChatToResponses_ToolCall(t *testing.T) {\n\tctx := context.Background()\n\n\t// 模拟带 tool_call 的 SSE 流\n\tsseLines := []string{\n\t\t`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}]}`,\n\t\t`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}]}`,\n\t\t`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}]}`,\n\t\t`data: {\"id\":\"chatcmpl-456\",\"object\":\"chat.completion.chunk\",\"created\":1234567890,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}`,\n\t\t`data: [DONE]`,\n\t}\n\n\toriginalReq := []byte(`{\"model\":\"gpt-4o\",\"input\":\"What's the weather?\",\"tools\":[{\"name\":\"get_weather\"}]}`)\n\n\tvar state any\n\tvar allEvents []string\n\n\tfor _, line := range sseLines {\n\t\tevents := ConvertOpenAIChatToResponses(ctx, \"gpt-4o\", originalReq, nil, []byte(line), &state)\n\t\tallEvents = append(allEvents, events...)\n\t}\n\n\t// 验证是否有 function_call 相关事件\n\thasFuncAdded := false\n\thasFuncDelta := false\n\thasFuncDone := false\n\n\tfor _, ev := range allEvents {\n\t\tif strings.Contains(ev, \"response.output_item.added\") && strings.Contains(ev, \"function_call\") {\n\t\t\thasFuncAdded = true\n\t\t}\n\t\tif strings.Contains(ev, \"response.function_call_arguments.delta\") {\n\t\t\thasFuncDelta = true\n\t\t}\n\t\tif strings.Contains(ev, \"response.function_call_arguments.done\") {\n\t\t\thasFuncDone = true\n\t\t}\n\t}\n\n\tif !hasFuncAdded {\n\t\tt.Error(\"should have function_call output_item.added event\")\n\t}\n\tif !hasFuncDelta {\n\t\tt.Error(\"should have function_call_arguments.delta event\")\n\t}\n\tif !hasFuncDone {\n\t\tt.Error(\"should have function_call_arguments.done event\")\n\t}\n}\n\nfunc TestConvertOpenAIChatToResponsesNonStream(t *testing.T) {\n\tctx := context.Background()\n\n\t// 模拟 OpenAI Chat Completions 非流式响应\n\tchatResponse := `{\n\t\t\"id\": \"chatcmpl-789\",\n\t\t\"object\": \"chat.completion\",\n\t\t\"created\": 1234567890,\n\t\t\"model\": \"gpt-4o\",\n\t\t\"choices\": [{\n\t\t\t\"index\": 0,\n\t\t\t\"message\": {\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": \"Hello! How can I help you today?\"\n\t\t\t},\n\t\t\t\"finish_reason\": \"stop\"\n\t\t}],\n\t\t\"usage\": {\n\t\t\t\"prompt_tokens\": 10,\n\t\t\t\"completion_tokens\": 8,\n\t\t\t\"total_tokens\": 18\n\t\t}\n\t}`\n\n\toriginalReq := []byte(`{\"model\":\"gpt-4o\",\"input\":\"Hi\",\"instructions\":\"Be helpful\"}`)\n\n\tresult := ConvertOpenAIChatToResponsesNonStream(ctx, \"gpt-4o\", originalReq, nil, []byte(chatResponse), nil)\n\n\t// 解析结果\n\tvar resp map[string]interface{}\n\tif err := json.Unmarshal([]byte(result), &resp); err != nil {\n\t\tt.Fatalf(\"failed to parse result: %v\", err)\n\t}\n\n\t// 验证基本字段\n\tif resp[\"object\"] != \"response\" {\n\t\tt.Errorf(\"object should be response, got %v\", resp[\"object\"])\n\t}\n\tif resp[\"status\"] != \"completed\" {\n\t\tt.Errorf(\"status should be completed, got %v\", resp[\"status\"])\n\t}\n\n\t// 验证 output\n\toutput, ok := resp[\"output\"].([]interface{})\n\tif !ok || len(output) == 0 {\n\t\tt.Fatal(\"output should have items\")\n\t}\n\n\tmsgItem := output[0].(map[string]interface{})\n\tif msgItem[\"type\"] != \"message\" {\n\t\tt.Errorf(\"first output item should be message, got %v\", msgItem[\"type\"])\n\t}\n\n\t// 验证 usage\n\tusage, ok := resp[\"usage\"].(map[string]interface{})\n\tif !ok {\n\t\tt.Fatal(\"usage should exist\")\n\t}\n\tif usage[\"input_tokens\"].(float64) != 10 {\n\t\tt.Errorf(\"input_tokens should be 10, got %v\", usage[\"input_tokens\"])\n\t}\n\tif usage[\"output_tokens\"].(float64) != 8 {\n\t\tt.Errorf(\"output_tokens should be 8, got %v\", usage[\"output_tokens\"])\n\t}\n}\n\nfunc TestConvertOpenAIChatToResponsesNonStream_ToolCalls(t *testing.T) {\n\tctx := context.Background()\n\n\t// 模拟带 tool_calls 的响应\n\tchatResponse := `{\n\t\t\"id\": \"chatcmpl-tool\",\n\t\t\"object\": \"chat.completion\",\n\t\t\"created\": 1234567890,\n\t\t\"model\": \"gpt-4o\",\n\t\t\"choices\": [{\n\t\t\t\"index\": 0,\n\t\t\t\"message\": {\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": null,\n\t\t\t\t\"tool_calls\": [\n\t\t\t\t\t{\n\t\t\t\t\t\t\"id\": \"call_xyz\",\n\t\t\t\t\t\t\"type\": \"function\",\n\t\t\t\t\t\t\"function\": {\n\t\t\t\t\t\t\t\"name\": \"search\",\n\t\t\t\t\t\t\t\"arguments\": \"{\\\"query\\\": \\\"test\\\"}\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t},\n\t\t\t\"finish_reason\": \"tool_calls\"\n\t\t}],\n\t\t\"usage\": {\"prompt_tokens\": 5, \"completion_tokens\": 10, \"total_tokens\": 15}\n\t}`\n\n\toriginalReq := []byte(`{\"model\":\"gpt-4o\",\"input\":\"Search for test\"}`)\n\n\tresult := ConvertOpenAIChatToResponsesNonStream(ctx, \"gpt-4o\", originalReq, nil, []byte(chatResponse), nil)\n\n\tvar resp map[string]interface{}\n\tif err := json.Unmarshal([]byte(result), &resp); err != nil {\n\t\tt.Fatalf(\"failed to parse result: %v\", err)\n\t}\n\n\toutput, ok := resp[\"output\"].([]interface{})\n\tif !ok || len(output) == 0 {\n\t\tt.Fatal(\"output should have items\")\n\t}\n\n\t// 查找 function_call item\n\tvar funcItem map[string]interface{}\n\tfor _, item := range output {\n\t\titemMap := item.(map[string]interface{})\n\t\tif itemMap[\"type\"] == \"function_call\" {\n\t\t\tfuncItem = itemMap\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif funcItem == nil {\n\t\tt.Fatal(\"should have function_call item\")\n\t}\n\n\tif funcItem[\"name\"] != \"search\" {\n\t\tt.Errorf(\"function name should be search, got %v\", funcItem[\"name\"])\n\t}\n\tif funcItem[\"call_id\"] != \"call_xyz\" {\n\t\tt.Errorf(\"call_id should be call_xyz, got %v\", funcItem[\"call_id\"])\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/converters/claude_converter.go",
    "content": "package converters\n\nimport (\n\t\"github.com/BenedictKing/claude-proxy/internal/session\"\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n)\n\n// ============== Claude Messages API 转换器 ==============\n\n// ClaudeConverter 实现 Responses → Claude Messages API 转换\ntype ClaudeConverter struct{}\n\n// ToProviderRequest 将 Responses 请求转换为 Claude Messages 格式\nfunc (c *ClaudeConverter) ToProviderRequest(sess *session.Session, req *types.ResponsesRequest) (interface{}, error) {\n\t// 转换 messages 和 system\n\tmessages, system, err := ResponsesToClaudeMessages(sess, req.Input, req.Instructions)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 构建 Claude 请求\n\tclaudeReq := map[string]interface{}{\n\t\t\"model\":    req.Model,\n\t\t\"messages\": messages,\n\t\t\"stream\":   req.Stream,\n\t}\n\n\t// Claude 使用独立的 system 参数（不在 messages 中）\n\tif system != \"\" {\n\t\tclaudeReq[\"system\"] = system\n\t}\n\n\t// 复制其他参数\n\tif req.MaxTokens > 0 {\n\t\tclaudeReq[\"max_tokens\"] = req.MaxTokens\n\t}\n\tif req.Temperature > 0 {\n\t\tclaudeReq[\"temperature\"] = req.Temperature\n\t}\n\tif req.TopP > 0 {\n\t\tclaudeReq[\"top_p\"] = req.TopP\n\t}\n\tif req.Stop != nil {\n\t\tclaudeReq[\"stop_sequences\"] = req.Stop // Claude 使用 stop_sequences\n\t}\n\n\treturn claudeReq, nil\n}\n\n// FromProviderResponse 将 Claude 响应转换为 Responses 格式\nfunc (c *ClaudeConverter) FromProviderResponse(resp map[string]interface{}, sessionID string) (*types.ResponsesResponse, error) {\n\treturn ClaudeResponseToResponses(resp, sessionID)\n}\n\n// GetProviderName 获取上游服务名称\nfunc (c *ClaudeConverter) GetProviderName() string {\n\treturn \"Claude Messages API\"\n}\n"
  },
  {
    "path": "backend-go/internal/converters/converter.go",
    "content": "package converters\n\nimport (\n\t\"github.com/BenedictKing/claude-proxy/internal/session\"\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n)\n\n// ============== FinishReason 映射 ==============\n\n// OpenAIFinishReasonToAnthropic 将 OpenAI finish_reason 转换为 Anthropic stop_reason\n// 未知原因保持原值透传，避免隐藏上游状态\nfunc OpenAIFinishReasonToAnthropic(reason string) string {\n\tswitch reason {\n\tcase \"stop\":\n\t\treturn \"end_turn\"\n\tcase \"length\":\n\t\treturn \"max_tokens\"\n\tcase \"tool_calls\", \"function_call\":\n\t\treturn \"tool_use\"\n\tcase \"content_filter\":\n\t\treturn \"refusal\"\n\tcase \"\", \"empty\":\n\t\treturn \"end_turn\"\n\tdefault:\n\t\treturn reason // 未知原因透传\n\t}\n}\n\n// AnthropicStopReasonToOpenAI 将 Anthropic stop_reason 转换为 OpenAI finish_reason\n// 未知原因保持原值透传，避免隐藏上游状态\nfunc AnthropicStopReasonToOpenAI(reason string) string {\n\tswitch reason {\n\tcase \"end_turn\":\n\t\treturn \"stop\"\n\tcase \"max_tokens\":\n\t\treturn \"length\"\n\tcase \"stop_sequence\", \"pause_turn\":\n\t\treturn \"stop\"\n\tcase \"tool_use\":\n\t\treturn \"tool_calls\"\n\tcase \"refusal\":\n\t\treturn \"content_filter\"\n\tcase \"\", \"empty\":\n\t\treturn \"stop\"\n\tdefault:\n\t\treturn reason // 未知原因透传\n\t}\n}\n\n// OpenAIFinishReasonToResponses 将 OpenAI finish_reason 转换为 Responses API status\n// 未知原因映射为 incomplete，避免将潜在错误误报为成功\nfunc OpenAIFinishReasonToResponses(reason string) string {\n\tswitch reason {\n\tcase \"stop\", \"tool_calls\", \"function_call\":\n\t\treturn \"completed\"\n\tcase \"length\":\n\t\treturn \"incomplete\"\n\tcase \"content_filter\":\n\t\treturn \"failed\"\n\tcase \"\", \"empty\":\n\t\treturn \"completed\"\n\tdefault:\n\t\treturn \"incomplete\" // 未知原因视为未完成，避免误报成功\n\t}\n}\n\n// ResponsesConverter 定义 Responses API 转换器接口\n// 用于将 Responses 格式转换为不同上游服务的格式\ntype ResponsesConverter interface {\n\t// ToProviderRequest 将 Responses 请求转换为上游服务的请求格式\n\t// 返回：请求体（map 或其他类型）、错误\n\tToProviderRequest(sess *session.Session, req *types.ResponsesRequest) (interface{}, error)\n\n\t// FromProviderResponse 将上游服务的响应转换为 Responses 格式\n\t// 返回：Responses 响应、错误\n\tFromProviderResponse(resp map[string]interface{}, sessionID string) (*types.ResponsesResponse, error)\n\n\t// GetProviderName 获取上游服务名称（用于日志和调试）\n\tGetProviderName() string\n}\n"
  },
  {
    "path": "backend-go/internal/converters/converter_test.go",
    "content": "package converters\n\nimport (\n\t\"testing\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/session\"\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n)\n\n// ============== extractTextFromContent 测试 ==============\n\nfunc TestExtractTextFromContent_String(t *testing.T) {\n\tcontent := \"Hello, world!\"\n\tresult := extractTextFromContent(content)\n\n\tif result != \"Hello, world!\" {\n\t\tt.Errorf(\"期望 'Hello, world!'，实际得到 '%s'\", result)\n\t}\n}\n\nfunc TestExtractTextFromContent_ContentBlockArray(t *testing.T) {\n\tcontent := []interface{}{\n\t\tmap[string]interface{}{\n\t\t\t\"type\": \"input_text\",\n\t\t\t\"text\": \"First message\",\n\t\t},\n\t\tmap[string]interface{}{\n\t\t\t\"type\": \"input_text\",\n\t\t\t\"text\": \"Second message\",\n\t\t},\n\t}\n\n\tresult := extractTextFromContent(content)\n\texpected := \"First message\\nSecond message\"\n\n\tif result != expected {\n\t\tt.Errorf(\"期望 '%s'，实际得到 '%s'\", expected, result)\n\t}\n}\n\nfunc TestExtractTextFromContent_MixedTypes(t *testing.T) {\n\tcontent := []interface{}{\n\t\tmap[string]interface{}{\n\t\t\t\"type\": \"input_text\",\n\t\t\t\"text\": \"User message\",\n\t\t},\n\t\tmap[string]interface{}{\n\t\t\t\"type\": \"output_text\",\n\t\t\t\"text\": \"Assistant message\",\n\t\t},\n\t\tmap[string]interface{}{\n\t\t\t\"type\": \"unknown\",\n\t\t\t\"text\": \"Should be ignored\",\n\t\t},\n\t}\n\n\tresult := extractTextFromContent(content)\n\texpected := \"User message\\nAssistant message\"\n\n\tif result != expected {\n\t\tt.Errorf(\"期望 '%s'，实际得到 '%s'\", expected, result)\n\t}\n}\n\nfunc TestExtractTextFromContent_EmptyArray(t *testing.T) {\n\tcontent := []interface{}{}\n\tresult := extractTextFromContent(content)\n\n\tif result != \"\" {\n\t\tt.Errorf(\"期望空字符串，实际得到 '%s'\", result)\n\t}\n}\n\n// ============== OpenAI 转换器测试 ==============\n\nfunc TestOpenAIChatConverter_WithInstructions(t *testing.T) {\n\tconverter := &OpenAIChatConverter{}\n\tsess := &session.Session{\n\t\tID:       \"sess_test\",\n\t\tMessages: []types.ResponsesItem{},\n\t}\n\n\treq := &types.ResponsesRequest{\n\t\tModel:        \"gpt-4\",\n\t\tInstructions: \"You are a helpful assistant.\",\n\t\tInput:        \"Hello!\",\n\t\tMaxTokens:    100,\n\t\tTemperature:  0.7,\n\t}\n\n\tresult, err := converter.ToProviderRequest(sess, req)\n\tif err != nil {\n\t\tt.Fatalf(\"转换失败: %v\", err)\n\t}\n\n\tresultMap, ok := result.(map[string]interface{})\n\tif !ok {\n\t\tt.Fatal(\"结果不是 map[string]interface{}\")\n\t}\n\n\t// 检查 model\n\tif resultMap[\"model\"] != \"gpt-4\" {\n\t\tt.Errorf(\"期望 model 为 'gpt-4'，实际为 '%v'\", resultMap[\"model\"])\n\t}\n\n\t// 检查 messages\n\tmessages, ok := resultMap[\"messages\"].([]map[string]interface{})\n\tif !ok {\n\t\tt.Fatal(\"messages 不是正确的类型\")\n\t}\n\n\tif len(messages) != 2 {\n\t\tt.Fatalf(\"期望 2 条消息（system + user），实际为 %d\", len(messages))\n\t}\n\n\t// 检查第一条是 system\n\tif messages[0][\"role\"] != \"system\" {\n\t\tt.Errorf(\"第一条消息应该是 system，实际为 '%v'\", messages[0][\"role\"])\n\t}\n\tif messages[0][\"content\"] != \"You are a helpful assistant.\" {\n\t\tt.Errorf(\"system 内容不匹配\")\n\t}\n\n\t// 检查第二条是 user\n\tif messages[1][\"role\"] != \"user\" {\n\t\tt.Errorf(\"第二条消息应该是 user，实际为 '%v'\", messages[1][\"role\"])\n\t}\n\tif messages[1][\"content\"] != \"Hello!\" {\n\t\tt.Errorf(\"user 内容不匹配\")\n\t}\n\n\t// 检查其他参数\n\tif resultMap[\"max_tokens\"] != 100 {\n\t\tt.Errorf(\"max_tokens 不匹配\")\n\t}\n\tif resultMap[\"temperature\"] != 0.7 {\n\t\tt.Errorf(\"temperature 不匹配\")\n\t}\n}\n\nfunc TestOpenAIChatConverter_WithMessageType(t *testing.T) {\n\tconverter := &OpenAIChatConverter{}\n\tsess := &session.Session{\n\t\tID:       \"sess_test\",\n\t\tMessages: []types.ResponsesItem{},\n\t}\n\n\treq := &types.ResponsesRequest{\n\t\tModel: \"gpt-4\",\n\t\tInput: []interface{}{\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"type\": \"message\",\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": []interface{}{\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"type\": \"input_text\",\n\t\t\t\t\t\t\"text\": \"Hello from message type!\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tresult, err := converter.ToProviderRequest(sess, req)\n\tif err != nil {\n\t\tt.Fatalf(\"转换失败: %v\", err)\n\t}\n\n\tresultMap := result.(map[string]interface{})\n\tmessages := resultMap[\"messages\"].([]map[string]interface{})\n\n\tif len(messages) != 1 {\n\t\tt.Fatalf(\"期望 1 条消息，实际为 %d\", len(messages))\n\t}\n\n\tif messages[0][\"role\"] != \"user\" {\n\t\tt.Errorf(\"角色应该是 user\")\n\t}\n\tif messages[0][\"content\"] != \"Hello from message type!\" {\n\t\tt.Errorf(\"内容不匹配，实际为 '%v'\", messages[0][\"content\"])\n\t}\n}\n\n// ============== Claude 转换器测试 ==============\n\nfunc TestClaudeConverter_WithInstructions(t *testing.T) {\n\tconverter := &ClaudeConverter{}\n\tsess := &session.Session{\n\t\tID:       \"sess_test\",\n\t\tMessages: []types.ResponsesItem{},\n\t}\n\n\treq := &types.ResponsesRequest{\n\t\tModel:        \"claude-3-opus\",\n\t\tInstructions: \"You are Claude.\",\n\t\tInput:        \"Hello!\",\n\t\tMaxTokens:    1000,\n\t}\n\n\tresult, err := converter.ToProviderRequest(sess, req)\n\tif err != nil {\n\t\tt.Fatalf(\"转换失败: %v\", err)\n\t}\n\n\tresultMap := result.(map[string]interface{})\n\n\t// 检查 system 参数（Claude 使用独立的 system 字段）\n\tif resultMap[\"system\"] != \"You are Claude.\" {\n\t\tt.Errorf(\"system 参数不匹配\")\n\t}\n\n\t// 检查 messages\n\tmessages, ok := resultMap[\"messages\"].([]types.ClaudeMessage)\n\tif !ok {\n\t\tt.Fatal(\"messages 不是正确的类型\")\n\t}\n\n\tif len(messages) != 1 {\n\t\tt.Fatalf(\"期望 1 条消息，实际为 %d\", len(messages))\n\t}\n\n\tif messages[0].Role != \"user\" {\n\t\tt.Errorf(\"角色应该是 user\")\n\t}\n}\n\n// ============== 工厂模式测试 ==============\n\nfunc TestConverterFactory(t *testing.T) {\n\ttests := []struct {\n\t\tserviceType  string\n\t\texpectedType string\n\t}{\n\t\t{\"openai\", \"*converters.OpenAIChatConverter\"},\n\t\t{\"claude\", \"*converters.ClaudeConverter\"},\n\t\t{\"responses\", \"*converters.ResponsesPassthroughConverter\"},\n\t\t{\"unknown\", \"*converters.OpenAIChatConverter\"}, // 默认\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.serviceType, func(t *testing.T) {\n\t\t\tconverter := NewConverter(tt.serviceType)\n\t\t\tif converter == nil {\n\t\t\t\tt.Errorf(\"工厂返回 nil\")\n\t\t\t}\n\t\t\t// 检查类型（简单验证）\n\t\t\tif converter.GetProviderName() == \"\" {\n\t\t\t\tt.Errorf(\"GetProviderName 返回空字符串\")\n\t\t\t}\n\t\t})\n\t}\n}\n\n// ============== 会话历史测试 ==============\n\nfunc TestOpenAIChatConverter_WithSessionHistory(t *testing.T) {\n\tconverter := &OpenAIChatConverter{}\n\tsess := &session.Session{\n\t\tID: \"sess_test\",\n\t\tMessages: []types.ResponsesItem{\n\t\t\t{\n\t\t\t\tType:    \"message\",\n\t\t\t\tRole:    \"user\",\n\t\t\t\tContent: \"Previous user message\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tType:    \"message\",\n\t\t\t\tRole:    \"assistant\",\n\t\t\t\tContent: \"Previous assistant message\",\n\t\t\t},\n\t\t},\n\t}\n\n\treq := &types.ResponsesRequest{\n\t\tModel: \"gpt-4\",\n\t\tInput: \"New user message\",\n\t}\n\n\tresult, err := converter.ToProviderRequest(sess, req)\n\tif err != nil {\n\t\tt.Fatalf(\"转换失败: %v\", err)\n\t}\n\n\tresultMap := result.(map[string]interface{})\n\tmessages := resultMap[\"messages\"].([]map[string]interface{})\n\n\t// 应该有 3 条消息：2 条历史 + 1 条新消息\n\tif len(messages) != 3 {\n\t\tt.Fatalf(\"期望 3 条消息，实际为 %d\", len(messages))\n\t}\n\n\t// 检查顺序\n\tif messages[0][\"content\"] != \"Previous user message\" {\n\t\tt.Errorf(\"第一条消息内容不匹配\")\n\t}\n\tif messages[1][\"content\"] != \"Previous assistant message\" {\n\t\tt.Errorf(\"第二条消息内容不匹配\")\n\t}\n\tif messages[2][\"content\"] != \"New user message\" {\n\t\tt.Errorf(\"第三条消息内容不匹配\")\n\t}\n}\n\n// ============== FinishReason 映射测试 ==============\n\nfunc TestOpenAIFinishReasonToAnthropic(t *testing.T) {\n\ttests := []struct {\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\"stop\", \"end_turn\"},\n\t\t{\"length\", \"max_tokens\"},\n\t\t{\"tool_calls\", \"tool_use\"},\n\t\t{\"function_call\", \"tool_use\"},\n\t\t{\"content_filter\", \"refusal\"},\n\t\t{\"empty\", \"end_turn\"},\n\t\t{\"unknown_reason\", \"unknown_reason\"}, // 未知原因透传\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tresult := OpenAIFinishReasonToAnthropic(tt.input)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"OpenAIFinishReasonToAnthropic(%q) = %q, want %q\", tt.input, result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAnthropicStopReasonToOpenAI(t *testing.T) {\n\ttests := []struct {\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\"end_turn\", \"stop\"},\n\t\t{\"max_tokens\", \"length\"},\n\t\t{\"stop_sequence\", \"stop\"},\n\t\t{\"pause_turn\", \"stop\"},\n\t\t{\"tool_use\", \"tool_calls\"},\n\t\t{\"refusal\", \"content_filter\"},\n\t\t{\"empty\", \"stop\"},\n\t\t{\"unknown_reason\", \"unknown_reason\"}, // 未知原因透传\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tresult := AnthropicStopReasonToOpenAI(tt.input)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"AnthropicStopReasonToOpenAI(%q) = %q, want %q\", tt.input, result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestOpenAIFinishReasonToResponses(t *testing.T) {\n\ttests := []struct {\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\"stop\", \"completed\"},\n\t\t{\"tool_calls\", \"completed\"},\n\t\t{\"function_call\", \"completed\"},\n\t\t{\"length\", \"incomplete\"},\n\t\t{\"content_filter\", \"failed\"},\n\t\t{\"empty\", \"completed\"},\n\t\t{\"unknown_reason\", \"incomplete\"}, // 未知原因视为未完成\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tresult := OpenAIFinishReasonToResponses(tt.input)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"OpenAIFinishReasonToResponses(%q) = %q, want %q\", tt.input, result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/converters/factory.go",
    "content": "package converters\n\n// ConverterFactory 转换器工厂\n// 根据上游服务类型返回对应的转换器实例\n\n// NewConverter 创建转换器实例\n// serviceType: \"openai\", \"claude\", \"responses\"\nfunc NewConverter(serviceType string) ResponsesConverter {\n\tswitch serviceType {\n\tcase \"openai\":\n\t\treturn &OpenAIChatConverter{}\n\tcase \"claude\":\n\t\treturn &ClaudeConverter{}\n\tcase \"responses\":\n\t\treturn &ResponsesPassthroughConverter{}\n\tdefault:\n\t\t// 默认使用 OpenAI Chat 转换器\n\t\treturn &OpenAIChatConverter{}\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/converters/gemini_converter.go",
    "content": "package converters\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n)\n\n// ============== Gemini -> Claude/OpenAI 转换器 ==============\n\n// GeminiToClaudeRequest 将 Gemini 请求转换为 Claude Messages API 格式\nfunc GeminiToClaudeRequest(geminiReq *types.GeminiRequest, model string) (map[string]interface{}, error) {\n\tclaudeReq := map[string]interface{}{\n\t\t\"model\": model,\n\t}\n\n\t// 1. 转换 systemInstruction -> system\n\tif geminiReq.SystemInstruction != nil && len(geminiReq.SystemInstruction.Parts) > 0 {\n\t\tsystemText := extractTextFromGeminiParts(geminiReq.SystemInstruction.Parts)\n\t\tif systemText != \"\" {\n\t\t\tclaudeReq[\"system\"] = systemText\n\t\t}\n\t}\n\n\t// 2. 转换 contents -> messages\n\tmessages := []map[string]interface{}{}\n\tfor _, content := range geminiReq.Contents {\n\t\tmsg, err := geminiContentToClaudeMessage(&content)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif msg != nil {\n\t\t\tmessages = append(messages, msg)\n\t\t}\n\t}\n\tclaudeReq[\"messages\"] = messages\n\n\t// 3. 转换 generationConfig\n\tif geminiReq.GenerationConfig != nil {\n\t\tcfg := geminiReq.GenerationConfig\n\t\tif cfg.MaxOutputTokens > 0 {\n\t\t\tclaudeReq[\"max_tokens\"] = cfg.MaxOutputTokens\n\t\t}\n\t\tif cfg.Temperature != nil {\n\t\t\tclaudeReq[\"temperature\"] = *cfg.Temperature\n\t\t}\n\t\tif cfg.TopP != nil {\n\t\t\tclaudeReq[\"top_p\"] = *cfg.TopP\n\t\t}\n\t\tif cfg.TopK != nil {\n\t\t\tclaudeReq[\"top_k\"] = *cfg.TopK\n\t\t}\n\t\tif len(cfg.StopSequences) > 0 {\n\t\t\tclaudeReq[\"stop_sequences\"] = cfg.StopSequences\n\t\t}\n\t}\n\n\t// 4. 转换 tools -> tools\n\tif len(geminiReq.Tools) > 0 {\n\t\tclaudeTools := []map[string]interface{}{}\n\t\tfor _, tool := range geminiReq.Tools {\n\t\t\tfor _, fn := range tool.FunctionDeclarations {\n\t\t\t\tclaudeTool := map[string]interface{}{\n\t\t\t\t\t\"name\": fn.Name,\n\t\t\t\t}\n\t\t\t\tif fn.Description != \"\" {\n\t\t\t\t\tclaudeTool[\"description\"] = fn.Description\n\t\t\t\t}\n\t\t\t\tif fn.Parameters != nil {\n\t\t\t\t\tclaudeTool[\"input_schema\"] = fn.Parameters\n\t\t\t\t} else {\n\t\t\t\t\t// Claude 需要 input_schema，提供空 schema\n\t\t\t\t\tclaudeTool[\"input_schema\"] = map[string]interface{}{\n\t\t\t\t\t\t\"type\":       \"object\",\n\t\t\t\t\t\t\"properties\": map[string]interface{}{},\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tclaudeTools = append(claudeTools, claudeTool)\n\t\t\t}\n\t\t}\n\t\tif len(claudeTools) > 0 {\n\t\t\tclaudeReq[\"tools\"] = claudeTools\n\t\t}\n\t}\n\n\treturn claudeReq, nil\n}\n\n// GeminiToOpenAIRequest 将 Gemini 请求转换为 OpenAI Chat Completions 格式\nfunc GeminiToOpenAIRequest(geminiReq *types.GeminiRequest, model string) (map[string]interface{}, error) {\n\topenaiReq := map[string]interface{}{\n\t\t\"model\": model,\n\t}\n\n\tmessages := []map[string]interface{}{}\n\n\t// 1. 转换 systemInstruction -> system message\n\tif geminiReq.SystemInstruction != nil && len(geminiReq.SystemInstruction.Parts) > 0 {\n\t\tsystemText := extractTextFromGeminiParts(geminiReq.SystemInstruction.Parts)\n\t\tif systemText != \"\" {\n\t\t\tmessages = append(messages, map[string]interface{}{\n\t\t\t\t\"role\":    \"system\",\n\t\t\t\t\"content\": systemText,\n\t\t\t})\n\t\t}\n\t}\n\n\t// 2. 转换 contents -> messages\n\tfor _, content := range geminiReq.Contents {\n\t\tmsg, err := geminiContentToOpenAIMessage(&content)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif msg != nil {\n\t\t\tmessages = append(messages, msg)\n\t\t}\n\t}\n\topenaiReq[\"messages\"] = messages\n\n\t// 3. 转换 generationConfig\n\tif geminiReq.GenerationConfig != nil {\n\t\tcfg := geminiReq.GenerationConfig\n\t\tif cfg.MaxOutputTokens > 0 {\n\t\t\topenaiReq[\"max_tokens\"] = cfg.MaxOutputTokens\n\t\t}\n\t\tif cfg.Temperature != nil {\n\t\t\topenaiReq[\"temperature\"] = *cfg.Temperature\n\t\t}\n\t\tif cfg.TopP != nil {\n\t\t\topenaiReq[\"top_p\"] = *cfg.TopP\n\t\t}\n\t\tif len(cfg.StopSequences) > 0 {\n\t\t\topenaiReq[\"stop\"] = cfg.StopSequences\n\t\t}\n\t}\n\n\t// 4. 转换 tools -> tools\n\tif len(geminiReq.Tools) > 0 {\n\t\topenaiTools := []map[string]interface{}{}\n\t\tfor _, tool := range geminiReq.Tools {\n\t\t\tfor _, fn := range tool.FunctionDeclarations {\n\t\t\t\topenaiTool := map[string]interface{}{\n\t\t\t\t\t\"type\": \"function\",\n\t\t\t\t\t\"function\": map[string]interface{}{\n\t\t\t\t\t\t\"name\":        fn.Name,\n\t\t\t\t\t\t\"description\": fn.Description,\n\t\t\t\t\t\t\"parameters\":  fn.Parameters,\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t\topenaiTools = append(openaiTools, openaiTool)\n\t\t\t}\n\t\t}\n\t\tif len(openaiTools) > 0 {\n\t\t\topenaiReq[\"tools\"] = openaiTools\n\t\t}\n\t}\n\n\treturn openaiReq, nil\n}\n\n// ============== Claude/OpenAI -> Gemini 响应转换 ==============\n\n// ClaudeResponseToGemini 将 Claude 响应转换为 Gemini 格式\nfunc ClaudeResponseToGemini(claudeResp map[string]interface{}) (*types.GeminiResponse, error) {\n\tgeminiResp := &types.GeminiResponse{\n\t\tCandidates: []types.GeminiCandidate{},\n\t}\n\n\t// 1. 转换 content -> candidates[0].content.parts\n\tcontent, ok := claudeResp[\"content\"].([]interface{})\n\tif !ok {\n\t\treturn geminiResp, nil\n\t}\n\n\tparts := []types.GeminiPart{}\n\tfor _, c := range content {\n\t\tcontentBlock, ok := c.(map[string]interface{})\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tblockType, _ := contentBlock[\"type\"].(string)\n\t\tswitch blockType {\n\t\tcase \"text\":\n\t\t\ttext, _ := contentBlock[\"text\"].(string)\n\t\t\tparts = append(parts, types.GeminiPart{\n\t\t\t\tText: text,\n\t\t\t})\n\t\tcase \"tool_use\":\n\t\t\tname, _ := contentBlock[\"name\"].(string)\n\t\t\targs, _ := contentBlock[\"input\"].(map[string]interface{})\n\n\t\t\tfunctionCall := &types.GeminiFunctionCall{\n\t\t\t\tName: name,\n\t\t\t\tArgs: args,\n\t\t\t}\n\n\t\t\t// 处理 thought_signature:\n\t\t\t// 1. 如果 Claude 响应中包含 signature，保留原值\n\t\t\t// 2. 否则使用 dummy signature 跳过 Gemini 验证\n\t\t\tif signature, ok := contentBlock[\"signature\"].(string); ok && signature != \"\" {\n\t\t\t\tfunctionCall.ThoughtSignature = signature\n\t\t\t} else {\n\t\t\t\tfunctionCall.ThoughtSignature = types.DummyThoughtSignature\n\t\t\t}\n\n\t\t\tparts = append(parts, types.GeminiPart{\n\t\t\t\tFunctionCall: functionCall,\n\t\t\t})\n\t\t}\n\t}\n\n\t// 2. 转换 stop_reason -> finishReason\n\tfinishReason := \"STOP\"\n\tif stopReason, ok := claudeResp[\"stop_reason\"].(string); ok {\n\t\tfinishReason = claudeStopReasonToGemini(stopReason)\n\t}\n\n\tcandidate := types.GeminiCandidate{\n\t\tContent: &types.GeminiContent{\n\t\t\tParts: parts,\n\t\t\tRole:  \"model\",\n\t\t},\n\t\tFinishReason: finishReason,\n\t\tIndex:        0,\n\t}\n\tgeminiResp.Candidates = append(geminiResp.Candidates, candidate)\n\n\t// 3. 转换 usage -> usageMetadata\n\tif usageRaw, ok := claudeResp[\"usage\"].(map[string]interface{}); ok {\n\t\tinputTokens, _ := getIntFromMap(usageRaw, \"input_tokens\")\n\t\toutputTokens, _ := getIntFromMap(usageRaw, \"output_tokens\")\n\t\tcacheRead, _ := getIntFromMap(usageRaw, \"cache_read_input_tokens\")\n\n\t\tgeminiResp.UsageMetadata = &types.GeminiUsageMetadata{\n\t\t\tPromptTokenCount:        inputTokens + cacheRead, // Gemini 格式包含缓存\n\t\t\tCandidatesTokenCount:    outputTokens,\n\t\t\tTotalTokenCount:         inputTokens + cacheRead + outputTokens,\n\t\t\tCachedContentTokenCount: cacheRead,\n\t\t}\n\t}\n\n\treturn geminiResp, nil\n}\n\n// OpenAIResponseToGemini 将 OpenAI 响应转换为 Gemini 格式\nfunc OpenAIResponseToGemini(openaiResp map[string]interface{}) (*types.GeminiResponse, error) {\n\tgeminiResp := &types.GeminiResponse{\n\t\tCandidates: []types.GeminiCandidate{},\n\t}\n\n\t// 1. 转换 choices[0].message -> candidates[0].content\n\tchoices, ok := openaiResp[\"choices\"].([]interface{})\n\tif !ok || len(choices) == 0 {\n\t\treturn geminiResp, nil\n\t}\n\n\tchoice, ok := choices[0].(map[string]interface{})\n\tif !ok {\n\t\treturn geminiResp, nil\n\t}\n\n\tparts := []types.GeminiPart{}\n\tfinishReason := \"STOP\"\n\n\t// 处理 message\n\tif message, ok := choice[\"message\"].(map[string]interface{}); ok {\n\t\t// 文本内容\n\t\tif content, ok := message[\"content\"].(string); ok && content != \"\" {\n\t\t\tparts = append(parts, types.GeminiPart{\n\t\t\t\tText: content,\n\t\t\t})\n\t\t}\n\n\t\t// 工具调用\n\t\tif toolCalls, ok := message[\"tool_calls\"].([]interface{}); ok {\n\t\t\tfor _, tc := range toolCalls {\n\t\t\t\ttoolCall, ok := tc.(map[string]interface{})\n\t\t\t\tif !ok {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tfunction, ok := toolCall[\"function\"].(map[string]interface{})\n\t\t\t\tif !ok {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tname, _ := function[\"name\"].(string)\n\t\t\t\targsStr, _ := function[\"arguments\"].(string)\n\t\t\t\tvar args map[string]interface{}\n\t\t\t\tif argsStr != \"\" {\n\t\t\t\t\t_ = JSONUnmarshal([]byte(argsStr), &args)\n\t\t\t\t}\n\n\t\t\t\t// OpenAI 响应不包含 signature，统一使用 dummy signature\n\t\t\t\tparts = append(parts, types.GeminiPart{\n\t\t\t\t\tFunctionCall: &types.GeminiFunctionCall{\n\t\t\t\t\t\tName:             name,\n\t\t\t\t\t\tArgs:             args,\n\t\t\t\t\t\tThoughtSignature: types.DummyThoughtSignature,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\t// 转换 finish_reason\n\tif fr, ok := choice[\"finish_reason\"].(string); ok {\n\t\tfinishReason = openaiFinishReasonToGemini(fr)\n\t}\n\n\tcandidate := types.GeminiCandidate{\n\t\tContent: &types.GeminiContent{\n\t\t\tParts: parts,\n\t\t\tRole:  \"model\",\n\t\t},\n\t\tFinishReason: finishReason,\n\t\tIndex:        0,\n\t}\n\tgeminiResp.Candidates = append(geminiResp.Candidates, candidate)\n\n\t// 2. 转换 usage -> usageMetadata\n\tif usageRaw, ok := openaiResp[\"usage\"].(map[string]interface{}); ok {\n\t\tpromptTokens, _ := getIntFromMap(usageRaw, \"prompt_tokens\")\n\t\tcompletionTokens, _ := getIntFromMap(usageRaw, \"completion_tokens\")\n\n\t\tgeminiResp.UsageMetadata = &types.GeminiUsageMetadata{\n\t\t\tPromptTokenCount:     promptTokens,\n\t\t\tCandidatesTokenCount: completionTokens,\n\t\t\tTotalTokenCount:      promptTokens + completionTokens,\n\t\t}\n\t}\n\n\treturn geminiResp, nil\n}\n\n// ============== 辅助函数 ==============\n\n// geminiContentToClaudeMessage 将 Gemini Content 转换为 Claude Message\nfunc geminiContentToClaudeMessage(content *types.GeminiContent) (map[string]interface{}, error) {\n\tif content == nil || len(content.Parts) == 0 {\n\t\treturn nil, nil\n\t}\n\n\t// 角色转换: model -> assistant, user -> user\n\trole := content.Role\n\tif role == \"model\" {\n\t\trole = \"assistant\"\n\t}\n\tif role == \"\" {\n\t\trole = \"user\"\n\t}\n\n\tclaudeContent := []map[string]interface{}{}\n\n\tfor i, part := range content.Parts {\n\t\tif part.Text != \"\" {\n\t\t\tclaudeContent = append(claudeContent, map[string]interface{}{\n\t\t\t\t\"type\": \"text\",\n\t\t\t\t\"text\": part.Text,\n\t\t\t})\n\t\t}\n\n\t\tif part.InlineData != nil {\n\t\t\t// 图片转换\n\t\t\tclaudeContent = append(claudeContent, map[string]interface{}{\n\t\t\t\t\"type\": \"image\",\n\t\t\t\t\"source\": map[string]interface{}{\n\t\t\t\t\t\"type\":       \"base64\",\n\t\t\t\t\t\"media_type\": part.InlineData.MimeType,\n\t\t\t\t\t\"data\":       part.InlineData.Data,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\n\t\tif part.FunctionCall != nil {\n\t\t\t// 工具调用\n\t\t\tclaudeContent = append(claudeContent, map[string]interface{}{\n\t\t\t\t\"type\":  \"tool_use\",\n\t\t\t\t\"id\":    fmt.Sprintf(\"toolu_%d\", i),\n\t\t\t\t\"name\":  part.FunctionCall.Name,\n\t\t\t\t\"input\": part.FunctionCall.Args,\n\t\t\t})\n\t\t}\n\n\t\tif part.FunctionResponse != nil {\n\t\t\t// 工具结果 - Claude 需要单独的 tool_result 消息\n\t\t\t// 这里简化处理，将其作为 tool_result 内容块\n\t\t\tclaudeContent = append(claudeContent, map[string]interface{}{\n\t\t\t\t\"type\":        \"tool_result\",\n\t\t\t\t\"tool_use_id\": part.FunctionResponse.Name,\n\t\t\t\t\"content\":     part.FunctionResponse.Response,\n\t\t\t})\n\t\t}\n\t}\n\n\tif len(claudeContent) == 0 {\n\t\treturn nil, nil\n\t}\n\n\treturn map[string]interface{}{\n\t\t\"role\":    role,\n\t\t\"content\": claudeContent,\n\t}, nil\n}\n\n// geminiContentToOpenAIMessage 将 Gemini Content 转换为 OpenAI Message\nfunc geminiContentToOpenAIMessage(content *types.GeminiContent) (map[string]interface{}, error) {\n\tif content == nil || len(content.Parts) == 0 {\n\t\treturn nil, nil\n\t}\n\n\t// 角色转换: model -> assistant, user -> user\n\trole := content.Role\n\tif role == \"model\" {\n\t\trole = \"assistant\"\n\t}\n\tif role == \"\" {\n\t\trole = \"user\"\n\t}\n\n\t// 检查是否有工具调用\n\tvar toolCalls []map[string]interface{}\n\tvar textParts []string\n\tvar hasToolResponse bool\n\tvar toolResponseName string\n\tvar toolResponseContent interface{}\n\n\tfor i, part := range content.Parts {\n\t\tif part.Text != \"\" {\n\t\t\ttextParts = append(textParts, part.Text)\n\t\t}\n\n\t\tif part.FunctionCall != nil {\n\t\t\targsJSON, _ := JSONMarshal(part.FunctionCall.Args)\n\t\t\ttoolCalls = append(toolCalls, map[string]interface{}{\n\t\t\t\t\"id\":   fmt.Sprintf(\"call_%d\", i),\n\t\t\t\t\"type\": \"function\",\n\t\t\t\t\"function\": map[string]interface{}{\n\t\t\t\t\t\"name\":      part.FunctionCall.Name,\n\t\t\t\t\t\"arguments\": string(argsJSON),\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\n\t\tif part.FunctionResponse != nil {\n\t\t\thasToolResponse = true\n\t\t\ttoolResponseName = part.FunctionResponse.Name\n\t\t\ttoolResponseContent = part.FunctionResponse.Response\n\t\t}\n\t}\n\n\t// 如果是工具响应，返回 tool role 的消息\n\tif hasToolResponse {\n\t\tcontentStr := \"\"\n\t\tif str, ok := toolResponseContent.(string); ok {\n\t\t\tcontentStr = str\n\t\t} else {\n\t\t\tcontentBytes, _ := JSONMarshal(toolResponseContent)\n\t\t\tcontentStr = string(contentBytes)\n\t\t}\n\t\treturn map[string]interface{}{\n\t\t\t\"role\":         \"tool\",\n\t\t\t\"tool_call_id\": toolResponseName,\n\t\t\t\"content\":      contentStr,\n\t\t}, nil\n\t}\n\n\tmsg := map[string]interface{}{\n\t\t\"role\": role,\n\t}\n\n\t// 设置内容\n\tif len(toolCalls) > 0 {\n\t\t// 助手消息带工具调用\n\t\tif len(textParts) > 0 {\n\t\t\tmsg[\"content\"] = strings.Join(textParts, \"\\n\")\n\t\t} else {\n\t\t\tmsg[\"content\"] = nil\n\t\t}\n\t\tmsg[\"tool_calls\"] = toolCalls\n\t} else {\n\t\t// 普通消息\n\t\tmsg[\"content\"] = strings.Join(textParts, \"\\n\")\n\t}\n\n\treturn msg, nil\n}\n\n// extractTextFromGeminiParts 从 Gemini Parts 中提取文本\nfunc extractTextFromGeminiParts(parts []types.GeminiPart) string {\n\ttexts := []string{}\n\tfor _, part := range parts {\n\t\tif part.Text != \"\" {\n\t\t\ttexts = append(texts, part.Text)\n\t\t}\n\t}\n\treturn strings.Join(texts, \"\\n\")\n}\n\n// claudeStopReasonToGemini 将 Claude 停止原因转换为 Gemini 格式\nfunc claudeStopReasonToGemini(stopReason string) string {\n\tswitch stopReason {\n\tcase \"end_turn\", \"stop_sequence\":\n\t\treturn \"STOP\"\n\tcase \"max_tokens\":\n\t\treturn \"MAX_TOKENS\"\n\tcase \"tool_use\":\n\t\treturn \"STOP\" // Gemini 使用相同的 STOP 表示工具调用\n\tdefault:\n\t\treturn \"STOP\"\n\t}\n}\n\n// openaiFinishReasonToGemini 将 OpenAI 停止原因转换为 Gemini 格式\nfunc openaiFinishReasonToGemini(finishReason string) string {\n\tswitch finishReason {\n\tcase \"stop\":\n\t\treturn \"STOP\"\n\tcase \"length\":\n\t\treturn \"MAX_TOKENS\"\n\tcase \"tool_calls\":\n\t\treturn \"STOP\"\n\tcase \"content_filter\":\n\t\treturn \"SAFETY\"\n\tdefault:\n\t\treturn \"STOP\"\n\t}\n}\n\n// geminiFinishReasonToClaude 将 Gemini 停止原因转换为 Claude 格式\nfunc geminiFinishReasonToClaude(finishReason string) string {\n\tswitch finishReason {\n\tcase \"STOP\":\n\t\treturn \"end_turn\"\n\tcase \"MAX_TOKENS\":\n\t\treturn \"max_tokens\"\n\tcase \"SAFETY\", \"RECITATION\":\n\t\treturn \"end_turn\"\n\tdefault:\n\t\treturn \"end_turn\"\n\t}\n}\n\n// geminiFinishReasonToOpenAI 将 Gemini 停止原因转换为 OpenAI 格式\nfunc geminiFinishReasonToOpenAI(finishReason string) string {\n\tswitch finishReason {\n\tcase \"STOP\":\n\t\treturn \"stop\"\n\tcase \"MAX_TOKENS\":\n\t\treturn \"length\"\n\tcase \"SAFETY\":\n\t\treturn \"content_filter\"\n\tdefault:\n\t\treturn \"stop\"\n\t}\n}\n\n// JSONMarshal JSON 序列化包装函数\nfunc JSONMarshal(v interface{}) ([]byte, error) {\n\treturn json.Marshal(v)\n}\n\n// JSONUnmarshal JSON 反序列化包装函数\nfunc JSONUnmarshal(data []byte, v interface{}) error {\n\treturn json.Unmarshal(data, v)\n}\n"
  },
  {
    "path": "backend-go/internal/converters/gemini_converter_test.go",
    "content": "package converters\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\n// TestClaudeResponseToGemini_WithThoughtSignature 测试 Claude 响应转换时 thought_signature 的处理\nfunc TestClaudeResponseToGemini_WithThoughtSignature(t *testing.T) {\n\tt.Run(\"保留原有 signature\", func(t *testing.T) {\n\t\t// 测试场景 1: Claude 响应包含 signature\n\t\tclaudeResp := map[string]interface{}{\n\t\t\t\"content\": []interface{}{\n\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\"type\":      \"tool_use\",\n\t\t\t\t\t\"name\":      \"test_function\",\n\t\t\t\t\t\"input\":     map[string]interface{}{\"arg\": \"value\"},\n\t\t\t\t\t\"signature\": \"original_signature_from_claude\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tgeminiResp, err := ClaudeResponseToGemini(claudeResp)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, geminiResp)\n\t\tassert.Len(t, geminiResp.Candidates, 1)\n\t\tassert.NotNil(t, geminiResp.Candidates[0].Content)\n\t\tassert.Len(t, geminiResp.Candidates[0].Content.Parts, 1)\n\t\tassert.NotNil(t, geminiResp.Candidates[0].Content.Parts[0].FunctionCall)\n\t\tassert.Equal(t, \"original_signature_from_claude\",\n\t\t\tgeminiResp.Candidates[0].Content.Parts[0].FunctionCall.ThoughtSignature)\n\t})\n\n\tt.Run(\"使用 dummy signature\", func(t *testing.T) {\n\t\t// 测试场景 2: Claude 响应不包含 signature\n\t\tclaudeResp := map[string]interface{}{\n\t\t\t\"content\": []interface{}{\n\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\"type\":  \"tool_use\",\n\t\t\t\t\t\"name\":  \"test_function\",\n\t\t\t\t\t\"input\": map[string]interface{}{\"arg\": \"value\"},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tgeminiResp, err := ClaudeResponseToGemini(claudeResp)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, geminiResp)\n\t\tassert.Len(t, geminiResp.Candidates, 1)\n\t\tassert.NotNil(t, geminiResp.Candidates[0].Content)\n\t\tassert.Len(t, geminiResp.Candidates[0].Content.Parts, 1)\n\t\tassert.NotNil(t, geminiResp.Candidates[0].Content.Parts[0].FunctionCall)\n\t\tassert.Equal(t, \"skip_thought_signature_validator\",\n\t\t\tgeminiResp.Candidates[0].Content.Parts[0].FunctionCall.ThoughtSignature)\n\t})\n\n\tt.Run(\"空 signature 使用 dummy\", func(t *testing.T) {\n\t\t// 测试场景 3: Claude 响应包含空 signature\n\t\tclaudeResp := map[string]interface{}{\n\t\t\t\"content\": []interface{}{\n\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\"type\":      \"tool_use\",\n\t\t\t\t\t\"name\":      \"test_function\",\n\t\t\t\t\t\"input\":     map[string]interface{}{\"arg\": \"value\"},\n\t\t\t\t\t\"signature\": \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tgeminiResp, err := ClaudeResponseToGemini(claudeResp)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, \"skip_thought_signature_validator\",\n\t\t\tgeminiResp.Candidates[0].Content.Parts[0].FunctionCall.ThoughtSignature)\n\t})\n}\n\n// TestOpenAIResponseToGemini_WithThoughtSignature 测试 OpenAI 响应转换时 thought_signature 的处理\nfunc TestOpenAIResponseToGemini_WithThoughtSignature(t *testing.T) {\n\tt.Run(\"统一使用 dummy signature\", func(t *testing.T) {\n\t\topenaiResp := map[string]interface{}{\n\t\t\t\"choices\": []interface{}{\n\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\"message\": map[string]interface{}{\n\t\t\t\t\t\t\"tool_calls\": []interface{}{\n\t\t\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\t\t\"function\": map[string]interface{}{\n\t\t\t\t\t\t\t\t\t\"name\":      \"test_function\",\n\t\t\t\t\t\t\t\t\t\"arguments\": `{\"arg\":\"value\"}`,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tgeminiResp, err := OpenAIResponseToGemini(openaiResp)\n\t\tassert.NoError(t, err)\n\t\tassert.NotNil(t, geminiResp)\n\t\tassert.Len(t, geminiResp.Candidates, 1)\n\t\tassert.NotNil(t, geminiResp.Candidates[0].Content)\n\t\tassert.Len(t, geminiResp.Candidates[0].Content.Parts, 1)\n\t\tassert.NotNil(t, geminiResp.Candidates[0].Content.Parts[0].FunctionCall)\n\t\tassert.Equal(t, \"skip_thought_signature_validator\",\n\t\t\tgeminiResp.Candidates[0].Content.Parts[0].FunctionCall.ThoughtSignature)\n\t})\n\n\tt.Run(\"多个工具调用都包含 signature\", func(t *testing.T) {\n\t\topenaiResp := map[string]interface{}{\n\t\t\t\"choices\": []interface{}{\n\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\"message\": map[string]interface{}{\n\t\t\t\t\t\t\"tool_calls\": []interface{}{\n\t\t\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\t\t\"function\": map[string]interface{}{\n\t\t\t\t\t\t\t\t\t\"name\":      \"function1\",\n\t\t\t\t\t\t\t\t\t\"arguments\": `{\"arg1\":\"value1\"}`,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\t\t\"function\": map[string]interface{}{\n\t\t\t\t\t\t\t\t\t\"name\":      \"function2\",\n\t\t\t\t\t\t\t\t\t\"arguments\": `{\"arg2\":\"value2\"}`,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tgeminiResp, err := OpenAIResponseToGemini(openaiResp)\n\t\tassert.NoError(t, err)\n\t\tassert.Len(t, geminiResp.Candidates[0].Content.Parts, 2)\n\n\t\t// 验证所有工具调用都包含 dummy signature\n\t\tfor _, part := range geminiResp.Candidates[0].Content.Parts {\n\t\t\tassert.NotNil(t, part.FunctionCall)\n\t\t\tassert.Equal(t, \"skip_thought_signature_validator\", part.FunctionCall.ThoughtSignature)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "backend-go/internal/converters/openai_converter.go",
    "content": "package converters\n\nimport (\n\t\"github.com/BenedictKing/claude-proxy/internal/session\"\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n)\n\n// ============== OpenAI Chat Completions 转换器 ==============\n\n// OpenAIChatConverter 实现 Responses → OpenAI Chat Completions 转换\ntype OpenAIChatConverter struct{}\n\n// ToProviderRequest 将 Responses 请求转换为 OpenAI Chat Completions 格式\nfunc (c *OpenAIChatConverter) ToProviderRequest(sess *session.Session, req *types.ResponsesRequest) (interface{}, error) {\n\t// 转换 messages\n\tmessages, err := ResponsesToOpenAIChatMessages(sess, req.Input, req.Instructions)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 构建 OpenAI 请求\n\topenaiReq := map[string]interface{}{\n\t\t\"model\":    req.Model,\n\t\t\"messages\": messages,\n\t\t\"stream\":   req.Stream,\n\t}\n\n\t// 复制其他参数\n\tif req.MaxTokens > 0 {\n\t\topenaiReq[\"max_tokens\"] = req.MaxTokens\n\t}\n\tif req.Temperature > 0 {\n\t\topenaiReq[\"temperature\"] = req.Temperature\n\t}\n\tif req.TopP > 0 {\n\t\topenaiReq[\"top_p\"] = req.TopP\n\t}\n\tif req.FrequencyPenalty != 0 {\n\t\topenaiReq[\"frequency_penalty\"] = req.FrequencyPenalty\n\t}\n\tif req.PresencePenalty != 0 {\n\t\topenaiReq[\"presence_penalty\"] = req.PresencePenalty\n\t}\n\tif req.Stop != nil {\n\t\topenaiReq[\"stop\"] = req.Stop\n\t}\n\tif req.User != \"\" {\n\t\topenaiReq[\"user\"] = req.User\n\t}\n\tif req.StreamOptions != nil {\n\t\topenaiReq[\"stream_options\"] = req.StreamOptions\n\t}\n\n\treturn openaiReq, nil\n}\n\n// FromProviderResponse 将 OpenAI Chat 响应转换为 Responses 格式\nfunc (c *OpenAIChatConverter) FromProviderResponse(resp map[string]interface{}, sessionID string) (*types.ResponsesResponse, error) {\n\treturn OpenAIChatResponseToResponses(resp, sessionID)\n}\n\n// GetProviderName 获取上游服务名称\nfunc (c *OpenAIChatConverter) GetProviderName() string {\n\treturn \"OpenAI Chat Completions\"\n}\n\n// ============== OpenAI Completions 转换器 ==============\n\n// OpenAICompletionsConverter 实现 Responses → OpenAI Completions 转换\ntype OpenAICompletionsConverter struct{}\n\n// ToProviderRequest 将 Responses 请求转换为 OpenAI Completions 格式\nfunc (c *OpenAICompletionsConverter) ToProviderRequest(sess *session.Session, req *types.ResponsesRequest) (interface{}, error) {\n\t// 提取纯文本（Completions API 不支持 messages）\n\tprompt, err := ExtractTextFromResponses(sess, req.Input)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 如果有 instructions，添加到 prompt 前面\n\tif req.Instructions != \"\" {\n\t\tprompt = req.Instructions + \"\\n\\n\" + prompt\n\t}\n\n\t// 构建 OpenAI Completions 请求\n\tcompletionsReq := map[string]interface{}{\n\t\t\"model\":  req.Model,\n\t\t\"prompt\": prompt,\n\t\t\"stream\": req.Stream,\n\t}\n\n\t// 复制其他参数\n\tif req.MaxTokens > 0 {\n\t\tcompletionsReq[\"max_tokens\"] = req.MaxTokens\n\t}\n\tif req.Temperature > 0 {\n\t\tcompletionsReq[\"temperature\"] = req.Temperature\n\t}\n\tif req.TopP > 0 {\n\t\tcompletionsReq[\"top_p\"] = req.TopP\n\t}\n\tif req.FrequencyPenalty != 0 {\n\t\tcompletionsReq[\"frequency_penalty\"] = req.FrequencyPenalty\n\t}\n\tif req.PresencePenalty != 0 {\n\t\tcompletionsReq[\"presence_penalty\"] = req.PresencePenalty\n\t}\n\tif req.Stop != nil {\n\t\tcompletionsReq[\"stop\"] = req.Stop\n\t}\n\tif req.User != \"\" {\n\t\tcompletionsReq[\"user\"] = req.User\n\t}\n\n\treturn completionsReq, nil\n}\n\n// FromProviderResponse 将 OpenAI Completions 响应转换为 Responses 格式\nfunc (c *OpenAICompletionsConverter) FromProviderResponse(resp map[string]interface{}, sessionID string) (*types.ResponsesResponse, error) {\n\treturn OpenAICompletionsResponseToResponses(resp, sessionID)\n}\n\n// GetProviderName 获取上游服务名称\nfunc (c *OpenAICompletionsConverter) GetProviderName() string {\n\treturn \"OpenAI Completions\"\n}\n"
  },
  {
    "path": "backend-go/internal/converters/responses_converter.go",
    "content": "package converters\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/session\"\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n)\n\n// ============== Responses → Claude Messages ==============\n\n// ResponsesToClaudeMessages 将 Responses 格式转换为 Claude Messages 格式\n// instructions 参数会被转换为 Claude API 的 system 参数（不在 messages 中）\nfunc ResponsesToClaudeMessages(sess *session.Session, newInput interface{}, instructions string) ([]types.ClaudeMessage, string, error) {\n\tmessages := []types.ClaudeMessage{}\n\n\t// 1. 处理历史消息\n\tfor _, item := range sess.Messages {\n\t\tmsg, err := responsesItemToClaudeMessage(item)\n\t\tif err != nil {\n\t\t\treturn nil, \"\", fmt.Errorf(\"转换历史消息失败: %w\", err)\n\t\t}\n\t\tif msg != nil {\n\t\t\tmessages = append(messages, *msg)\n\t\t}\n\t}\n\n\t// 2. 处理新输入\n\tnewItems, err := parseResponsesInput(newInput)\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\tfor _, item := range newItems {\n\t\tmsg, err := responsesItemToClaudeMessage(item)\n\t\tif err != nil {\n\t\t\treturn nil, \"\", fmt.Errorf(\"转换新消息失败: %w\", err)\n\t\t}\n\t\tif msg != nil {\n\t\t\tmessages = append(messages, *msg)\n\t\t}\n\t}\n\n\treturn messages, instructions, nil\n}\n\n// responsesItemToClaudeMessage 单个 ResponsesItem 转换为 Claude Message\nfunc responsesItemToClaudeMessage(item types.ResponsesItem) (*types.ClaudeMessage, error) {\n\tswitch item.Type {\n\tcase \"message\":\n\t\t// 新格式：嵌套结构（type=message, role=user/assistant, content=[]ContentBlock）\n\t\trole := item.Role\n\t\tif role == \"\" {\n\t\t\trole = \"user\" // 默认为 user\n\t\t}\n\n\t\tcontentText := extractTextFromContent(item.Content)\n\t\tif contentText == \"\" {\n\t\t\treturn nil, nil // 空内容，跳过\n\t\t}\n\n\t\treturn &types.ClaudeMessage{\n\t\t\tRole: role,\n\t\t\tContent: []types.ClaudeContent{\n\t\t\t\t{\n\t\t\t\t\tType: \"text\",\n\t\t\t\t\tText: contentText,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\n\tcase \"text\":\n\t\t// 旧格式：简单 string（向后兼容）\n\t\tcontentStr := extractTextFromContent(item.Content)\n\t\tif contentStr == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"text 类型的 content 不能为空\")\n\t\t}\n\n\t\t// 使用 item.Role（如果存在），否则默认为 user\n\t\trole := \"user\"\n\t\tif item.Role != \"\" {\n\t\t\trole = item.Role\n\t\t}\n\n\t\treturn &types.ClaudeMessage{\n\t\t\tRole: role,\n\t\t\tContent: []types.ClaudeContent{\n\t\t\t\t{\n\t\t\t\t\tType: \"text\",\n\t\t\t\t\tText: contentStr,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\n\tcase \"tool_call\":\n\t\t// 工具调用（暂时简化处理）\n\t\treturn nil, nil\n\n\tcase \"tool_result\":\n\t\t// 工具结果（暂时简化处理）\n\t\treturn nil, nil\n\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"未知的 item type: %s\", item.Type)\n\t}\n}\n\n// ============== Claude Response → Responses ==============\n\n// ClaudeResponseToResponses 将 Claude 响应转换为 Responses 格式\nfunc ClaudeResponseToResponses(claudeResp map[string]interface{}, sessionID string) (*types.ResponsesResponse, error) {\n\t// 提取字段\n\tmodel, _ := claudeResp[\"model\"].(string)\n\tcontent, _ := claudeResp[\"content\"].([]interface{})\n\n\t// 转换 output\n\toutput := []types.ResponsesItem{}\n\tfor _, c := range content {\n\t\tcontentBlock, ok := c.(map[string]interface{})\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tblockType, _ := contentBlock[\"type\"].(string)\n\t\tif blockType == \"text\" {\n\t\t\ttext, _ := contentBlock[\"text\"].(string)\n\t\t\toutput = append(output, types.ResponsesItem{\n\t\t\t\tType:    \"text\",\n\t\t\t\tContent: text,\n\t\t\t})\n\t\t}\n\t}\n\n\t// 提取 usage（使用统一入口自动检测格式）\n\tusage := ExtractUsageMetrics(claudeResp[\"usage\"])\n\n\t// 生成 response ID\n\tresponseID := generateResponseID()\n\n\treturn &types.ResponsesResponse{\n\t\tID:         responseID,\n\t\tModel:      model,\n\t\tOutput:     output,\n\t\tStatus:     \"completed\",\n\t\tPreviousID: \"\", // 将在外部设置\n\t\tUsage:      usage,\n\t}, nil\n}\n\n// ============== Responses → OpenAI Chat ==============\n\n// ResponsesToOpenAIChatMessages 将 Responses 格式转换为 OpenAI Chat 格式\nfunc ResponsesToOpenAIChatMessages(sess *session.Session, newInput interface{}, instructions string) ([]map[string]interface{}, error) {\n\tmessages := []map[string]interface{}{}\n\n\t// 1. 处理 instructions（如果存在）\n\tif instructions != \"\" {\n\t\tmessages = append(messages, map[string]interface{}{\n\t\t\t\"role\":    \"system\",\n\t\t\t\"content\": instructions,\n\t\t})\n\t}\n\n\t// 2. 处理历史消息\n\tfor _, item := range sess.Messages {\n\t\tmsg := responsesItemToOpenAIMessage(item)\n\t\tif msg != nil {\n\t\t\tmessages = append(messages, msg)\n\t\t}\n\t}\n\n\t// 3. 处理新输入\n\tnewItems, err := parseResponsesInput(newInput)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, item := range newItems {\n\t\tmsg := responsesItemToOpenAIMessage(item)\n\t\tif msg != nil {\n\t\t\tmessages = append(messages, msg)\n\t\t}\n\t}\n\n\treturn messages, nil\n}\n\n// responsesItemToOpenAIMessage 单个 ResponsesItem 转换为 OpenAI Message\nfunc responsesItemToOpenAIMessage(item types.ResponsesItem) map[string]interface{} {\n\tswitch item.Type {\n\tcase \"message\":\n\t\t// 新格式：嵌套结构\n\t\trole := item.Role\n\t\tif role == \"\" {\n\t\t\trole = \"user\"\n\t\t}\n\n\t\tcontentText := extractTextFromContent(item.Content)\n\t\tif contentText == \"\" {\n\t\t\treturn nil\n\t\t}\n\n\t\treturn map[string]interface{}{\n\t\t\t\"role\":    role,\n\t\t\t\"content\": contentText,\n\t\t}\n\n\tcase \"text\":\n\t\t// 旧格式：简单 string\n\t\tcontentStr := extractTextFromContent(item.Content)\n\t\tif contentStr == \"\" {\n\t\t\treturn nil\n\t\t}\n\n\t\trole := \"user\"\n\t\tif item.Role != \"\" {\n\t\t\trole = item.Role\n\t\t}\n\n\t\treturn map[string]interface{}{\n\t\t\t\"role\":    role,\n\t\t\t\"content\": contentStr,\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// ============== OpenAI Chat Response → Responses ==============\n\n// OpenAIChatResponseToResponses 将 OpenAI Chat 响应转换为 Responses 格式\nfunc OpenAIChatResponseToResponses(openaiResp map[string]interface{}, sessionID string) (*types.ResponsesResponse, error) {\n\t// 提取字段\n\tmodel, _ := openaiResp[\"model\"].(string)\n\tchoices, _ := openaiResp[\"choices\"].([]interface{})\n\n\t// 提取第一个 choice 的 message\n\toutput := []types.ResponsesItem{}\n\tif len(choices) > 0 {\n\t\tchoice, ok := choices[0].(map[string]interface{})\n\t\tif ok {\n\t\t\tmessage, _ := choice[\"message\"].(map[string]interface{})\n\t\t\tcontent, _ := message[\"content\"].(string)\n\t\t\toutput = append(output, types.ResponsesItem{\n\t\t\t\tType:    \"text\",\n\t\t\t\tContent: content,\n\t\t\t})\n\t\t}\n\t}\n\n\t// 提取 usage（使用统一入口自动检测格式）\n\tusage := ExtractUsageMetrics(openaiResp[\"usage\"])\n\n\t// 生成 response ID\n\tresponseID := generateResponseID()\n\n\treturn &types.ResponsesResponse{\n\t\tID:         responseID,\n\t\tModel:      model,\n\t\tOutput:     output,\n\t\tStatus:     \"completed\",\n\t\tPreviousID: \"\",\n\t\tUsage:      usage,\n\t}, nil\n}\n\n// ============== 工具函数 ==============\n\n// extractTextFromContent 从 content 中提取文本内容\n// 支持三种格式：\n// 1. string - 直接返回\n// 2. []ContentBlock - 提取 input_text/output_text 类型的 text 字段\n// 3. []interface{} - 动态解析为 ContentBlock\nfunc extractTextFromContent(content interface{}) string {\n\t// 1. 如果是 string，直接返回\n\tif str, ok := content.(string); ok {\n\t\treturn str\n\t}\n\n\t// 2. 如果是 []ContentBlock（已解析类型）\n\tif blocks, ok := content.([]types.ContentBlock); ok {\n\t\ttexts := []string{}\n\t\tfor _, block := range blocks {\n\t\t\tif block.Type == \"input_text\" || block.Type == \"output_text\" {\n\t\t\t\ttexts = append(texts, block.Text)\n\t\t\t}\n\t\t}\n\t\treturn strings.Join(texts, \"\\n\")\n\t}\n\n\t// 3. 如果是 []interface{}（未解析类型）\n\tif arr, ok := content.([]interface{}); ok {\n\t\ttexts := []string{}\n\t\tfor _, c := range arr {\n\t\t\tif block, ok := c.(map[string]interface{}); ok {\n\t\t\t\tblockType, _ := block[\"type\"].(string)\n\t\t\t\tif blockType == \"input_text\" || blockType == \"output_text\" {\n\t\t\t\t\tif text, ok := block[\"text\"].(string); ok {\n\t\t\t\t\t\ttexts = append(texts, text)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn strings.Join(texts, \"\\n\")\n\t}\n\n\treturn \"\"\n}\n\n// parseResponsesInput 解析 input 字段（可能是 string 或 []ResponsesItem）\nfunc parseResponsesInput(input interface{}) ([]types.ResponsesItem, error) {\n\tswitch v := input.(type) {\n\tcase string:\n\t\t// 简单文本输入\n\t\treturn []types.ResponsesItem{\n\t\t\t{\n\t\t\t\tType:    \"text\",\n\t\t\t\tContent: v,\n\t\t\t},\n\t\t}, nil\n\n\tcase []interface{}:\n\t\t// 数组输入\n\t\titems := []types.ResponsesItem{}\n\t\tfor _, item := range v {\n\t\t\titemMap, ok := item.(map[string]interface{})\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\titemType, _ := itemMap[\"type\"].(string)\n\t\t\tcontent := itemMap[\"content\"]\n\n\t\t\titems = append(items, types.ResponsesItem{\n\t\t\t\tType:    itemType,\n\t\t\t\tContent: content,\n\t\t\t})\n\t\t}\n\t\treturn items, nil\n\n\tcase []types.ResponsesItem:\n\t\t// 已经是正确类型\n\t\treturn v, nil\n\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"不支持的 input 类型: %T\", input)\n\t}\n}\n\n// generateResponseID 生成响应ID\nfunc generateResponseID() string {\n\treturn fmt.Sprintf(\"resp_%d\", getCurrentTimestamp())\n}\n\n// getCurrentTimestamp 获取当前时间戳（毫秒）\nfunc getCurrentTimestamp() int64 {\n\treturn 0 // 占位符，实际应使用 time.Now().UnixNano() / 1e6\n}\n\n// ExtractTextFromResponses 从 Responses 消息中提取纯文本（用于 OpenAI Completions）\nfunc ExtractTextFromResponses(sess *session.Session, newInput interface{}) (string, error) {\n\ttexts := []string{}\n\n\t// 历史消息\n\tfor _, item := range sess.Messages {\n\t\tif item.Type == \"text\" {\n\t\t\tif text, ok := item.Content.(string); ok {\n\t\t\t\ttexts = append(texts, text)\n\t\t\t}\n\t\t}\n\t}\n\n\t// 新输入\n\tnewItems, err := parseResponsesInput(newInput)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tfor _, item := range newItems {\n\t\tif item.Type == \"text\" {\n\t\t\tif text, ok := item.Content.(string); ok {\n\t\t\t\ttexts = append(texts, text)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn strings.Join(texts, \"\\n\"), nil\n}\n\n// OpenAICompletionsResponseToResponses OpenAI Completions 响应转 Responses\nfunc OpenAICompletionsResponseToResponses(completionsResp map[string]interface{}, sessionID string) (*types.ResponsesResponse, error) {\n\tmodel, _ := completionsResp[\"model\"].(string)\n\tchoices, _ := completionsResp[\"choices\"].([]interface{})\n\n\toutput := []types.ResponsesItem{}\n\tif len(choices) > 0 {\n\t\tchoice, ok := choices[0].(map[string]interface{})\n\t\tif ok {\n\t\t\ttext, _ := choice[\"text\"].(string)\n\t\t\toutput = append(output, types.ResponsesItem{\n\t\t\t\tType:    \"text\",\n\t\t\t\tContent: text,\n\t\t\t})\n\t\t}\n\t}\n\n\t// 提取 usage（使用统一入口自动检测格式）\n\tusage := ExtractUsageMetrics(completionsResp[\"usage\"])\n\n\tresponseID := generateResponseID()\n\n\treturn &types.ResponsesResponse{\n\t\tID:         responseID,\n\t\tModel:      model,\n\t\tOutput:     output,\n\t\tStatus:     \"completed\",\n\t\tPreviousID: \"\",\n\t\tUsage:      usage,\n\t}, nil\n}\n\n// JSONToMap 将 JSON 字节转为 map\nfunc JSONToMap(data []byte) (map[string]interface{}, error) {\n\tvar result map[string]interface{}\n\terr := json.Unmarshal(data, &result)\n\treturn result, err\n}\n\n// getIntFromMap 从 map 中安全提取整数值\n// 支持 float64（JSON 反序列化）和 int/int64（内部构造）两种类型\nfunc getIntFromMap(m map[string]interface{}, key string) (int, bool) {\n\tv, exists := m[key]\n\tif !exists {\n\t\treturn 0, false\n\t}\n\tswitch val := v.(type) {\n\tcase float64:\n\t\treturn int(val), true\n\tcase int:\n\t\treturn val, true\n\tcase int64:\n\t\treturn int(val), true\n\tcase int32:\n\t\treturn int(val), true\n\tdefault:\n\t\treturn 0, false\n\t}\n}\n\n// parseResponsesUsage 解析 Responses API 的 usage 字段\n// 完整支持 OpenAI Responses API 的详细 usage 结构\nfunc parseResponsesUsage(usageRaw interface{}) types.ResponsesUsage {\n\tusage := types.ResponsesUsage{}\n\n\tusageMap, ok := usageRaw.(map[string]interface{})\n\tif !ok {\n\t\treturn usage\n\t}\n\n\t// 解析基础字段（兼容两种命名风格）\n\t// OpenAI Responses API: input_tokens / output_tokens\n\t// OpenAI Chat API: prompt_tokens / completion_tokens\n\tif v, ok := getIntFromMap(usageMap, \"input_tokens\"); ok {\n\t\tusage.InputTokens = v\n\t} else if v, ok := getIntFromMap(usageMap, \"prompt_tokens\"); ok {\n\t\tusage.InputTokens = v\n\t}\n\n\tif v, ok := getIntFromMap(usageMap, \"output_tokens\"); ok {\n\t\tusage.OutputTokens = v\n\t} else if v, ok := getIntFromMap(usageMap, \"completion_tokens\"); ok {\n\t\tusage.OutputTokens = v\n\t}\n\n\tif v, ok := getIntFromMap(usageMap, \"total_tokens\"); ok {\n\t\tusage.TotalTokens = v\n\t} else {\n\t\tusage.TotalTokens = usage.InputTokens + usage.OutputTokens\n\t}\n\n\t// 解析 input_tokens_details（兼容 prompt_tokens_details）\n\tinputDetailsRaw := usageMap[\"input_tokens_details\"]\n\tif inputDetailsRaw == nil {\n\t\tinputDetailsRaw = usageMap[\"prompt_tokens_details\"]\n\t}\n\tif detailsMap, ok := inputDetailsRaw.(map[string]interface{}); ok {\n\t\tusage.InputTokensDetails = &types.InputTokensDetails{}\n\t\tif v, ok := getIntFromMap(detailsMap, \"cached_tokens\"); ok {\n\t\t\tusage.InputTokensDetails.CachedTokens = v\n\t\t}\n\t}\n\n\t// 解析 output_tokens_details（兼容 completion_tokens_details）\n\toutputDetailsRaw := usageMap[\"output_tokens_details\"]\n\tif outputDetailsRaw == nil {\n\t\toutputDetailsRaw = usageMap[\"completion_tokens_details\"]\n\t}\n\tif detailsMap, ok := outputDetailsRaw.(map[string]interface{}); ok {\n\t\tusage.OutputTokensDetails = &types.OutputTokensDetails{}\n\t\tif v, ok := getIntFromMap(detailsMap, \"reasoning_tokens\"); ok {\n\t\t\tusage.OutputTokensDetails.ReasoningTokens = v\n\t\t}\n\t}\n\n\treturn usage\n}\n\n// parseClaudeUsage 解析 Claude API 的 usage 字段\n// 完整支持 Claude 的缓存统计，包括 TTL 细分 (5m/1h)\n// 参考 claude-code-hub 的 extractUsageMetrics 实现\nfunc parseClaudeUsage(usageRaw interface{}) types.ResponsesUsage {\n\tusage := types.ResponsesUsage{}\n\n\tusageMap, ok := usageRaw.(map[string]interface{})\n\tif !ok {\n\t\treturn usage\n\t}\n\n\t// 基础字段\n\tif v, ok := getIntFromMap(usageMap, \"input_tokens\"); ok {\n\t\tusage.InputTokens = v\n\t}\n\tif v, ok := getIntFromMap(usageMap, \"output_tokens\"); ok {\n\t\tusage.OutputTokens = v\n\t}\n\tusage.TotalTokens = usage.InputTokens + usage.OutputTokens\n\n\t// Claude 缓存创建统计（区分 TTL）\n\tvar cacheCreation, cacheCreation5m, cacheCreation1h int\n\tvar has5m, has1h bool\n\n\t// 总缓存创建量\n\tif v, ok := getIntFromMap(usageMap, \"cache_creation_input_tokens\"); ok {\n\t\tcacheCreation = v\n\t\tusage.CacheCreationInputTokens = cacheCreation\n\t}\n\n\t// 5分钟 TTL 缓存创建\n\tif v, ok := getIntFromMap(usageMap, \"cache_creation_5m_input_tokens\"); ok {\n\t\tcacheCreation5m = v\n\t\tusage.CacheCreation5mInputTokens = cacheCreation5m\n\t\thas5m = cacheCreation5m > 0\n\t}\n\n\t// 1小时 TTL 缓存创建\n\tif v, ok := getIntFromMap(usageMap, \"cache_creation_1h_input_tokens\"); ok {\n\t\tcacheCreation1h = v\n\t\tusage.CacheCreation1hInputTokens = cacheCreation1h\n\t\thas1h = cacheCreation1h > 0\n\t}\n\n\t// 缓存读取\n\tvar cacheRead int\n\tif v, ok := getIntFromMap(usageMap, \"cache_read_input_tokens\"); ok {\n\t\tcacheRead = v\n\t\tusage.CacheReadInputTokens = cacheRead\n\t}\n\n\t// 设置缓存 TTL 标识\n\tif has5m && has1h {\n\t\tusage.CacheTTL = \"mixed\"\n\t} else if has1h {\n\t\tusage.CacheTTL = \"1h\"\n\t} else if has5m {\n\t\tusage.CacheTTL = \"5m\"\n\t}\n\n\t// 同时设置 InputTokensDetails（兼容 OpenAI 格式）\n\t// CachedTokens = cache_read（仅缓存读取，不包含缓存创建）\n\t// 注意：cache_creation 是新创建的缓存，不是\"已缓存的 token\"\n\tif cacheRead > 0 {\n\t\tusage.InputTokensDetails = &types.InputTokensDetails{\n\t\t\tCachedTokens: cacheRead,\n\t\t}\n\t}\n\n\treturn usage\n}\n\n// parseGeminiUsage 解析 Gemini API 的 usage 字段\n// Gemini 使用 promptTokenCount/candidatesTokenCount，需要特殊处理缓存去重\n// 参考 claude-code-hub: Gemini 的 promptTokenCount 已包含 cachedContentTokenCount，需要扣除避免重复计费\nfunc parseGeminiUsage(usageRaw interface{}) types.ResponsesUsage {\n\tusage := types.ResponsesUsage{}\n\n\tusageMap, ok := usageRaw.(map[string]interface{})\n\tif !ok {\n\t\treturn usage\n\t}\n\n\tvar promptTokens, cachedTokens, outputTokens int\n\n\t// Gemini 字段名\n\tif v, ok := getIntFromMap(usageMap, \"promptTokenCount\"); ok {\n\t\tpromptTokens = v\n\t}\n\tif v, ok := getIntFromMap(usageMap, \"cachedContentTokenCount\"); ok {\n\t\tcachedTokens = v\n\t}\n\tif v, ok := getIntFromMap(usageMap, \"candidatesTokenCount\"); ok {\n\t\toutputTokens = v\n\t}\n\n\t// 关键处理：Gemini 的 promptTokenCount 已包含 cachedContentTokenCount\n\t// 为避免重复计费，实际输入 token = promptTokenCount - cachedContentTokenCount\n\tactualInputTokens := promptTokens - cachedTokens\n\tif actualInputTokens < 0 {\n\t\tactualInputTokens = 0\n\t}\n\n\tusage.InputTokens = actualInputTokens\n\tusage.OutputTokens = outputTokens\n\tusage.TotalTokens = actualInputTokens + outputTokens\n\n\t// 缓存读取统计\n\tif cachedTokens > 0 {\n\t\tusage.CacheReadInputTokens = cachedTokens\n\t\tusage.InputTokensDetails = &types.InputTokensDetails{\n\t\t\tCachedTokens: cachedTokens,\n\t\t}\n\t}\n\n\treturn usage\n}\n\n// ExtractUsageMetrics 多格式 Token 提取统一入口\n// 自动检测并解析 Claude/Gemini/OpenAI 三种格式的 usage\n// 参考 claude-code-hub 的 extractUsageMetrics 实现\nfunc ExtractUsageMetrics(usageRaw interface{}) types.ResponsesUsage {\n\tusageMap, ok := usageRaw.(map[string]interface{})\n\tif !ok {\n\t\treturn types.ResponsesUsage{}\n\t}\n\n\t// 1. 检测 Claude 格式：有 cache_creation_input_tokens 或 cache_read_input_tokens\n\tif _, hasCacheCreation := usageMap[\"cache_creation_input_tokens\"]; hasCacheCreation {\n\t\treturn parseClaudeUsage(usageRaw)\n\t}\n\tif _, hasCacheRead := usageMap[\"cache_read_input_tokens\"]; hasCacheRead {\n\t\treturn parseClaudeUsage(usageRaw)\n\t}\n\n\t// 2. 检测 Gemini 格式：有 promptTokenCount\n\tif _, hasPromptTokenCount := usageMap[\"promptTokenCount\"]; hasPromptTokenCount {\n\t\treturn parseGeminiUsage(usageRaw)\n\t}\n\n\t// 3. 默认 OpenAI 格式\n\treturn parseResponsesUsage(usageRaw)\n}\n"
  },
  {
    "path": "backend-go/internal/converters/responses_passthrough.go",
    "content": "package converters\n\nimport (\n\t\"github.com/BenedictKing/claude-proxy/internal/session\"\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n)\n\n// ============== Responses 透传转换器 ==============\n\n// ResponsesPassthroughConverter 实现 Responses → Responses 透传\n// 用于上游服务本身就是 Responses API 的场景\ntype ResponsesPassthroughConverter struct{}\n\n// ToProviderRequest 透传 Responses 请求（不做转换）\nfunc (c *ResponsesPassthroughConverter) ToProviderRequest(sess *session.Session, req *types.ResponsesRequest) (interface{}, error) {\n\t// 直接返回原始请求\n\treturn map[string]interface{}{\n\t\t\"model\":                req.Model,\n\t\t\"instructions\":         req.Instructions,\n\t\t\"input\":                req.Input,\n\t\t\"previous_response_id\": req.PreviousResponseID,\n\t\t\"store\":                req.Store,\n\t\t\"max_tokens\":           req.MaxTokens,\n\t\t\"temperature\":          req.Temperature,\n\t\t\"top_p\":                req.TopP,\n\t\t\"frequency_penalty\":    req.FrequencyPenalty,\n\t\t\"presence_penalty\":     req.PresencePenalty,\n\t\t\"stream\":               req.Stream,\n\t\t\"stop\":                 req.Stop,\n\t\t\"user\":                 req.User,\n\t\t\"stream_options\":       req.StreamOptions,\n\t}, nil\n}\n\n// FromProviderResponse 透传 Responses 响应（不做转换）\nfunc (c *ResponsesPassthroughConverter) FromProviderResponse(resp map[string]interface{}, sessionID string) (*types.ResponsesResponse, error) {\n\t// 直接解析为 ResponsesResponse\n\t// 注意：这里假设上游返回的就是标准 Responses 格式\n\tid, _ := resp[\"id\"].(string)\n\tmodel, _ := resp[\"model\"].(string)\n\tstatus, _ := resp[\"status\"].(string)\n\tpreviousID, _ := resp[\"previous_id\"].(string)\n\n\t// 解析 output\n\toutput := []types.ResponsesItem{}\n\tif outputArr, ok := resp[\"output\"].([]interface{}); ok {\n\t\tfor _, item := range outputArr {\n\t\t\tif itemMap, ok := item.(map[string]interface{}); ok {\n\t\t\t\titemType, _ := itemMap[\"type\"].(string)\n\t\t\t\trole, _ := itemMap[\"role\"].(string)\n\t\t\t\tcontent := itemMap[\"content\"]\n\n\t\t\t\toutput = append(output, types.ResponsesItem{\n\t\t\t\t\tType:    itemType,\n\t\t\t\t\tRole:    role,\n\t\t\t\t\tContent: content,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\t// 解析 usage（使用统一入口自动检测格式：Claude/Gemini/OpenAI）\n\tusage := ExtractUsageMetrics(resp[\"usage\"])\n\n\treturn &types.ResponsesResponse{\n\t\tID:         id,\n\t\tModel:      model,\n\t\tOutput:     output,\n\t\tStatus:     status,\n\t\tPreviousID: previousID,\n\t\tUsage:      usage,\n\t}, nil\n}\n\n// GetProviderName 获取上游服务名称\nfunc (c *ResponsesPassthroughConverter) GetProviderName() string {\n\treturn \"Responses API (Passthrough)\"\n}\n"
  },
  {
    "path": "backend-go/internal/converters/responses_to_chat.go",
    "content": "package converters\n\nimport (\n\t\"github.com/tidwall/gjson\"\n\t\"github.com/tidwall/sjson\"\n)\n\n// ConvertResponsesToOpenAIChatRequest 将 OpenAI Responses 请求格式转换为 OpenAI Chat Completions 格式\n// 转换内容包括:\n// 1. model 和 stream 配置\n// 2. instructions → system message\n// 3. input 数组 → messages 数组\n// 4. tools 定义转换\n// 5. function_call 和 function_call_output 处理\n// 6. 生成参数映射 (max_tokens, reasoning 等)\n//\n// 参数:\n//   - modelName: 要使用的模型名称\n//   - inputRawJSON: Responses 格式的原始 JSON 请求\n//   - stream: 是否为流式请求\n//\n// 返回:\n//   - []byte: Chat Completions 格式的请求 JSON\nfunc ConvertResponsesToOpenAIChatRequest(modelName string, inputRawJSON []byte, stream bool) []byte {\n\t// 基础 Chat Completions 模板\n\tout := `{\"model\":\"\",\"messages\":[],\"stream\":false}`\n\n\troot := gjson.ParseBytes(inputRawJSON)\n\n\t// 设置 model\n\tout, _ = sjson.Set(out, \"model\", modelName)\n\n\t// 设置 stream\n\tout, _ = sjson.Set(out, \"stream\", stream)\n\n\t// 如果是流式请求，添加 stream_options 以获取 usage 信息\n\tif stream {\n\t\tout, _ = sjson.Set(out, \"stream_options.include_usage\", true)\n\t}\n\n\t// 映射生成参数\n\tif maxTokens := root.Get(\"max_output_tokens\"); maxTokens.Exists() {\n\t\tout, _ = sjson.Set(out, \"max_tokens\", maxTokens.Int())\n\t}\n\n\tif parallelToolCalls := root.Get(\"parallel_tool_calls\"); parallelToolCalls.Exists() {\n\t\tout, _ = sjson.Set(out, \"parallel_tool_calls\", parallelToolCalls.Bool())\n\t}\n\n\tif temperature := root.Get(\"temperature\"); temperature.Exists() {\n\t\tout, _ = sjson.Set(out, \"temperature\", temperature.Float())\n\t}\n\n\tif topP := root.Get(\"top_p\"); topP.Exists() {\n\t\tout, _ = sjson.Set(out, \"top_p\", topP.Float())\n\t}\n\n\tif user := root.Get(\"user\"); user.Exists() {\n\t\tout, _ = sjson.Set(out, \"user\", user.String())\n\t}\n\n\t// 转换 instructions → system message\n\tif instructions := root.Get(\"instructions\"); instructions.Exists() && instructions.String() != \"\" {\n\t\tsystemMessage := `{\"role\":\"system\",\"content\":\"\"}`\n\t\tsystemMessage, _ = sjson.Set(systemMessage, \"content\", instructions.String())\n\t\tout, _ = sjson.SetRaw(out, \"messages.-1\", systemMessage)\n\t}\n\n\t// 转换 input 数组 → messages\n\tif input := root.Get(\"input\"); input.Exists() {\n\t\tif input.IsArray() {\n\t\t\tout = convertInputArrayToMessages(input, out)\n\t\t} else if input.Type == gjson.String {\n\t\t\t// 简单字符串输入\n\t\t\tmsg := `{\"role\":\"user\",\"content\":\"\"}`\n\t\t\tmsg, _ = sjson.Set(msg, \"content\", input.String())\n\t\t\tout, _ = sjson.SetRaw(out, \"messages.-1\", msg)\n\t\t}\n\t}\n\n\t// 转换 tools\n\tif tools := root.Get(\"tools\"); tools.Exists() && tools.IsArray() {\n\t\tout = convertToolsToOpenAIFormat(tools, out)\n\t}\n\n\t// 转换 reasoning.effort → reasoning_effort\n\tif reasoningEffort := root.Get(\"reasoning.effort\"); reasoningEffort.Exists() {\n\t\teffort := reasoningEffort.String()\n\t\tswitch effort {\n\t\tcase \"none\":\n\t\t\tout, _ = sjson.Set(out, \"reasoning_effort\", \"none\")\n\t\tcase \"auto\":\n\t\t\tout, _ = sjson.Set(out, \"reasoning_effort\", \"auto\")\n\t\tcase \"minimal\":\n\t\t\tout, _ = sjson.Set(out, \"reasoning_effort\", \"low\")\n\t\tcase \"low\":\n\t\t\tout, _ = sjson.Set(out, \"reasoning_effort\", \"low\")\n\t\tcase \"medium\":\n\t\t\tout, _ = sjson.Set(out, \"reasoning_effort\", \"medium\")\n\t\tcase \"high\":\n\t\t\tout, _ = sjson.Set(out, \"reasoning_effort\", \"high\")\n\t\tcase \"xhigh\":\n\t\t\tout, _ = sjson.Set(out, \"reasoning_effort\", \"xhigh\")\n\t\tdefault:\n\t\t\tout, _ = sjson.Set(out, \"reasoning_effort\", \"auto\")\n\t\t}\n\t}\n\n\t// 转换 tool_choice\n\tif toolChoice := root.Get(\"tool_choice\"); toolChoice.Exists() {\n\t\tout, _ = sjson.Set(out, \"tool_choice\", toolChoice.String())\n\t}\n\n\treturn []byte(out)\n}\n\n// convertInputArrayToMessages 将 input 数组转换为 messages 数组\nfunc convertInputArrayToMessages(input gjson.Result, out string) string {\n\tinput.ForEach(func(_, item gjson.Result) bool {\n\t\titemType := item.Get(\"type\").String()\n\n\t\t// 如果没有 type 但有 role，则视为 message\n\t\tif itemType == \"\" && item.Get(\"role\").String() != \"\" {\n\t\t\titemType = \"message\"\n\t\t}\n\n\t\tswitch itemType {\n\t\tcase \"message\":\n\t\t\tout = convertMessageItem(item, out)\n\n\t\tcase \"function_call\":\n\t\t\tout = convertFunctionCallItem(item, out)\n\n\t\tcase \"function_call_output\":\n\t\t\tout = convertFunctionCallOutputItem(item, out)\n\t\t}\n\n\t\treturn true\n\t})\n\n\treturn out\n}\n\n// convertMessageItem 转换 message 类型的 item\nfunc convertMessageItem(item gjson.Result, out string) string {\n\trole := item.Get(\"role\").String()\n\tif role == \"\" {\n\t\trole = \"user\"\n\t}\n\n\tmessage := `{\"role\":\"\",\"content\":\"\"}`\n\tmessage, _ = sjson.Set(message, \"role\", role)\n\n\tcontent := item.Get(\"content\")\n\tif content.Exists() {\n\t\tif content.IsArray() {\n\t\t\t// content 是数组，需要提取文本\n\t\t\tvar messageContent string\n\t\t\tvar toolCalls []interface{}\n\n\t\t\tcontent.ForEach(func(_, contentItem gjson.Result) bool {\n\t\t\t\tcontentType := contentItem.Get(\"type\").String()\n\t\t\t\tif contentType == \"\" {\n\t\t\t\t\tcontentType = \"input_text\"\n\t\t\t\t}\n\n\t\t\t\tswitch contentType {\n\t\t\t\tcase \"input_text\", \"output_text\", \"text\":\n\t\t\t\t\ttext := contentItem.Get(\"text\").String()\n\t\t\t\t\tif messageContent != \"\" {\n\t\t\t\t\t\tmessageContent += \"\\n\" + text\n\t\t\t\t\t} else {\n\t\t\t\t\t\tmessageContent = text\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\n\t\t\tif messageContent != \"\" {\n\t\t\t\tmessage, _ = sjson.Set(message, \"content\", messageContent)\n\t\t\t}\n\n\t\t\tif len(toolCalls) > 0 {\n\t\t\t\tmessage, _ = sjson.Set(message, \"tool_calls\", toolCalls)\n\t\t\t}\n\t\t} else if content.Type == gjson.String {\n\t\t\t// content 是字符串\n\t\t\tmessage, _ = sjson.Set(message, \"content\", content.String())\n\t\t}\n\t}\n\n\tout, _ = sjson.SetRaw(out, \"messages.-1\", message)\n\treturn out\n}\n\n// convertFunctionCallItem 转换 function_call 类型的 item\nfunc convertFunctionCallItem(item gjson.Result, out string) string {\n\t// function_call → assistant message with tool_calls\n\tassistantMessage := `{\"role\":\"assistant\",\"tool_calls\":[]}`\n\n\ttoolCall := `{\"id\":\"\",\"type\":\"function\",\"function\":{\"name\":\"\",\"arguments\":\"\"}}`\n\n\tif callID := item.Get(\"call_id\"); callID.Exists() {\n\t\ttoolCall, _ = sjson.Set(toolCall, \"id\", callID.String())\n\t}\n\n\tif name := item.Get(\"name\"); name.Exists() {\n\t\ttoolCall, _ = sjson.Set(toolCall, \"function.name\", name.String())\n\t}\n\n\tif arguments := item.Get(\"arguments\"); arguments.Exists() {\n\t\ttoolCall, _ = sjson.Set(toolCall, \"function.arguments\", arguments.String())\n\t}\n\n\tassistantMessage, _ = sjson.SetRaw(assistantMessage, \"tool_calls.0\", toolCall)\n\tout, _ = sjson.SetRaw(out, \"messages.-1\", assistantMessage)\n\n\treturn out\n}\n\n// convertFunctionCallOutputItem 转换 function_call_output 类型的 item\nfunc convertFunctionCallOutputItem(item gjson.Result, out string) string {\n\t// function_call_output → tool message\n\ttoolMessage := `{\"role\":\"tool\",\"tool_call_id\":\"\",\"content\":\"\"}`\n\n\tif callID := item.Get(\"call_id\"); callID.Exists() {\n\t\ttoolMessage, _ = sjson.Set(toolMessage, \"tool_call_id\", callID.String())\n\t}\n\n\tif output := item.Get(\"output\"); output.Exists() {\n\t\ttoolMessage, _ = sjson.Set(toolMessage, \"content\", output.String())\n\t}\n\n\tout, _ = sjson.SetRaw(out, \"messages.-1\", toolMessage)\n\treturn out\n}\n\n// convertToolsToOpenAIFormat 将 Responses tools 转换为 OpenAI Chat Completions tools 格式\nfunc convertToolsToOpenAIFormat(tools gjson.Result, out string) string {\n\tvar chatCompletionsTools []interface{}\n\n\ttools.ForEach(func(_, tool gjson.Result) bool {\n\t\tchatTool := `{\"type\":\"function\",\"function\":{}}`\n\n\t\tfunction := `{\"name\":\"\",\"description\":\"\",\"parameters\":{}}`\n\n\t\tif name := tool.Get(\"name\"); name.Exists() {\n\t\t\tfunction, _ = sjson.Set(function, \"name\", name.String())\n\t\t}\n\n\t\tif description := tool.Get(\"description\"); description.Exists() {\n\t\t\tfunction, _ = sjson.Set(function, \"description\", description.String())\n\t\t}\n\n\t\tif parameters := tool.Get(\"parameters\"); parameters.Exists() {\n\t\t\tfunction, _ = sjson.SetRaw(function, \"parameters\", parameters.Raw)\n\t\t}\n\n\t\tchatTool, _ = sjson.SetRaw(chatTool, \"function\", function)\n\t\tchatCompletionsTools = append(chatCompletionsTools, gjson.Parse(chatTool).Value())\n\n\t\treturn true\n\t})\n\n\tif len(chatCompletionsTools) > 0 {\n\t\tout, _ = sjson.Set(out, \"tools\", chatCompletionsTools)\n\t}\n\n\treturn out\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/channel_metrics_handler.go",
    "content": "package handlers\n\nimport (\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/metrics\"\n\t\"github.com/BenedictKing/claude-proxy/internal/scheduler\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// GetChannelMetricsWithConfig 获取渠道指标（需要配置管理器来获取 baseURL 和 keys）\nfunc GetChannelMetricsWithConfig(metricsManager *metrics.MetricsManager, cfgManager *config.ConfigManager, isResponses bool) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tcfg := cfgManager.GetConfig()\n\t\tvar upstreams []config.UpstreamConfig\n\t\tif isResponses {\n\t\t\tupstreams = cfg.ResponsesUpstream\n\t\t} else {\n\t\t\tupstreams = cfg.Upstream\n\t\t}\n\n\t\tresult := make([]gin.H, 0, len(upstreams))\n\t\tfor i, upstream := range upstreams {\n\t\t\t// 使用多 URL 聚合方法获取渠道指标（支持 failover 多端点场景）\n\t\t\tresp := metricsManager.ToResponseMultiURL(i, upstream.GetAllBaseURLs(), upstream.APIKeys, 0, upstream.HistoricalAPIKeys)\n\n\t\t\titem := gin.H{\n\t\t\t\t\"channelIndex\":        i,\n\t\t\t\t\"channelName\":         upstream.Name,\n\t\t\t\t\"requestCount\":        resp.RequestCount,\n\t\t\t\t\"successCount\":        resp.SuccessCount,\n\t\t\t\t\"failureCount\":        resp.FailureCount,\n\t\t\t\t\"successRate\":         resp.SuccessRate,\n\t\t\t\t\"errorRate\":           resp.ErrorRate,\n\t\t\t\t\"consecutiveFailures\": resp.ConsecutiveFailures,\n\t\t\t\t\"latency\":             resp.Latency,\n\t\t\t\t\"keyMetrics\":          resp.KeyMetrics,  // 各 Key 的详细指标\n\t\t\t\t\"timeWindows\":         resp.TimeWindows, // 分时段统计 (15m, 1h, 6h, 24h)\n\t\t\t}\n\n\t\t\tif resp.LastSuccessAt != nil {\n\t\t\t\titem[\"lastSuccessAt\"] = *resp.LastSuccessAt\n\t\t\t}\n\t\t\tif resp.LastFailureAt != nil {\n\t\t\t\titem[\"lastFailureAt\"] = *resp.LastFailureAt\n\t\t\t}\n\t\t\tif resp.CircuitBrokenAt != nil {\n\t\t\t\titem[\"circuitBrokenAt\"] = *resp.CircuitBrokenAt\n\t\t\t}\n\n\t\t\tresult = append(result, item)\n\t\t}\n\n\t\tc.JSON(200, result)\n\t}\n}\n\n// GetAllKeyMetrics 获取所有 Key 的原始指标\nfunc GetAllKeyMetrics(metricsManager *metrics.MetricsManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tallMetrics := metricsManager.GetAllKeyMetrics()\n\n\t\tresult := make([]gin.H, 0, len(allMetrics))\n\t\tfor _, m := range allMetrics {\n\t\t\tif m == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tsuccessRate := float64(100)\n\t\t\tif m.RequestCount > 0 {\n\t\t\t\tsuccessRate = float64(m.SuccessCount) / float64(m.RequestCount) * 100\n\t\t\t}\n\n\t\t\titem := gin.H{\n\t\t\t\t\"metricsKey\":          m.MetricsKey,\n\t\t\t\t\"baseUrl\":             m.BaseURL,\n\t\t\t\t\"keyMask\":             m.KeyMask,\n\t\t\t\t\"requestCount\":        m.RequestCount,\n\t\t\t\t\"successCount\":        m.SuccessCount,\n\t\t\t\t\"failureCount\":        m.FailureCount,\n\t\t\t\t\"successRate\":         successRate,\n\t\t\t\t\"consecutiveFailures\": m.ConsecutiveFailures,\n\t\t\t}\n\n\t\t\tif m.LastSuccessAt != nil {\n\t\t\t\titem[\"lastSuccessAt\"] = m.LastSuccessAt.Format(\"2006-01-02T15:04:05Z07:00\")\n\t\t\t}\n\t\t\tif m.LastFailureAt != nil {\n\t\t\t\titem[\"lastFailureAt\"] = m.LastFailureAt.Format(\"2006-01-02T15:04:05Z07:00\")\n\t\t\t}\n\t\t\tif m.CircuitBrokenAt != nil {\n\t\t\t\titem[\"circuitBrokenAt\"] = m.CircuitBrokenAt.Format(\"2006-01-02T15:04:05Z07:00\")\n\t\t\t}\n\n\t\t\tresult = append(result, item)\n\t\t}\n\n\t\tc.JSON(200, result)\n\t}\n}\n\n// GetChannelMetrics 获取渠道指标（兼容旧 API，返回空数据）\n// Deprecated: 使用 GetChannelMetricsWithConfig 代替\nfunc GetChannelMetrics(metricsManager *metrics.MetricsManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// 返回所有 Key 的指标\n\t\tallMetrics := metricsManager.GetAllKeyMetrics()\n\n\t\tresult := make([]gin.H, 0, len(allMetrics))\n\t\tfor _, m := range allMetrics {\n\t\t\tif m == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tsuccessRate := float64(100)\n\t\t\tif m.RequestCount > 0 {\n\t\t\t\tsuccessRate = float64(m.SuccessCount) / float64(m.RequestCount) * 100\n\t\t\t}\n\n\t\t\titem := gin.H{\n\t\t\t\t\"metricsKey\":          m.MetricsKey,\n\t\t\t\t\"baseUrl\":             m.BaseURL,\n\t\t\t\t\"keyMask\":             m.KeyMask,\n\t\t\t\t\"requestCount\":        m.RequestCount,\n\t\t\t\t\"successCount\":        m.SuccessCount,\n\t\t\t\t\"failureCount\":        m.FailureCount,\n\t\t\t\t\"successRate\":         successRate,\n\t\t\t\t\"consecutiveFailures\": m.ConsecutiveFailures,\n\t\t\t}\n\n\t\t\tif m.LastSuccessAt != nil {\n\t\t\t\titem[\"lastSuccessAt\"] = m.LastSuccessAt.Format(\"2006-01-02T15:04:05Z07:00\")\n\t\t\t}\n\t\t\tif m.LastFailureAt != nil {\n\t\t\t\titem[\"lastFailureAt\"] = m.LastFailureAt.Format(\"2006-01-02T15:04:05Z07:00\")\n\t\t\t}\n\t\t\tif m.CircuitBrokenAt != nil {\n\t\t\t\titem[\"circuitBrokenAt\"] = m.CircuitBrokenAt.Format(\"2006-01-02T15:04:05Z07:00\")\n\t\t\t}\n\n\t\t\tresult = append(result, item)\n\t\t}\n\n\t\tc.JSON(200, result)\n\t}\n}\n\n// GetResponsesChannelMetrics 获取 Responses 渠道指标\n// Deprecated: 使用 GetChannelMetricsWithConfig 代替\nfunc GetResponsesChannelMetrics(metricsManager *metrics.MetricsManager) gin.HandlerFunc {\n\treturn GetChannelMetrics(metricsManager)\n}\n\n// ResumeChannel 恢复熔断渠道（重置熔断状态，保留历史统计）\n// isResponses 参数指定是 Messages 渠道还是 Responses 渠道\nfunc ResumeChannel(sch *scheduler.ChannelScheduler, isResponses bool) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid channel ID\"})\n\t\t\treturn\n\t\t}\n\n\t\t// 重置渠道所有 Key 的熔断状态（保留历史统计）\n\t\tkind := scheduler.ChannelKindMessages\n\t\tif isResponses {\n\t\t\tkind = scheduler.ChannelKindResponses\n\t\t}\n\t\tsch.ResetChannelMetrics(id, kind)\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"success\": true,\n\t\t\t\"message\": \"渠道已恢复，熔断状态已重置（历史统计保留）\",\n\t\t})\n\t}\n}\n\n// GetSchedulerStats 获取调度器统计信息\nfunc GetSchedulerStats(sch *scheduler.ChannelScheduler) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// 获取 isResponses 参数\n\t\tisResponses := strings.ToLower(c.Query(\"type\")) == \"responses\"\n\t\tkind := scheduler.ChannelKindMessages\n\t\tif isResponses {\n\t\t\tkind = scheduler.ChannelKindResponses\n\t\t}\n\n\t\t// 根据类型选择对应的指标管理器\n\t\tvar metricsManager *metrics.MetricsManager\n\t\tif isResponses {\n\t\t\tmetricsManager = sch.GetResponsesMetricsManager()\n\t\t} else {\n\t\t\tmetricsManager = sch.GetMessagesMetricsManager()\n\t\t}\n\n\t\tstats := gin.H{\n\t\t\t\"multiChannelMode\":    sch.IsMultiChannelMode(kind),\n\t\t\t\"activeChannelCount\":  sch.GetActiveChannelCount(kind),\n\t\t\t\"traceAffinityCount\":  sch.GetTraceAffinityManager().Size(),\n\t\t\t\"traceAffinityTTL\":    sch.GetTraceAffinityManager().GetTTL().String(),\n\t\t\t\"failureThreshold\":    metricsManager.GetFailureThreshold() * 100,\n\t\t\t\"windowSize\":          metricsManager.GetWindowSize(),\n\t\t\t\"circuitRecoveryTime\": metricsManager.GetCircuitRecoveryTime().String(),\n\t\t}\n\n\t\tc.JSON(200, stats)\n\t}\n}\n\n// SetChannelPromotion 设置渠道促销期\n// 促销期内的渠道会被优先选择，忽略 trace 亲和性\nfunc SetChannelPromotion(cfgManager ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"无效的渠道 ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tvar req struct {\n\t\t\tDuration int `json:\"duration\"` // 促销期时长（秒），0 表示清除\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"无效的请求参数\"})\n\t\t\treturn\n\t\t}\n\n\t\t// 调用配置管理器设置促销期\n\t\tduration := time.Duration(req.Duration) * time.Second\n\t\tif err := cfgManager.SetChannelPromotion(id, duration); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\n\t\tif req.Duration <= 0 {\n\t\t\tc.JSON(200, gin.H{\n\t\t\t\t\"success\": true,\n\t\t\t\t\"message\": \"渠道促销期已清除\",\n\t\t\t})\n\t\t} else {\n\t\t\tc.JSON(200, gin.H{\n\t\t\t\t\"success\":  true,\n\t\t\t\t\"message\":  \"渠道促销期已设置\",\n\t\t\t\t\"duration\": req.Duration,\n\t\t\t})\n\t\t}\n\t}\n}\n\n// SetResponsesChannelPromotion 设置 Responses 渠道促销期\nfunc SetResponsesChannelPromotion(cfgManager ResponsesConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"无效的渠道 ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tvar req struct {\n\t\t\tDuration int `json:\"duration\"` // 促销期时长（秒），0 表示清除\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"无效的请求参数\"})\n\t\t\treturn\n\t\t}\n\n\t\tduration := time.Duration(req.Duration) * time.Second\n\t\tif err := cfgManager.SetResponsesChannelPromotion(id, duration); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\n\t\tif req.Duration <= 0 {\n\t\t\tc.JSON(200, gin.H{\n\t\t\t\t\"success\": true,\n\t\t\t\t\"message\": \"Responses 渠道促销期已清除\",\n\t\t\t})\n\t\t} else {\n\t\t\tc.JSON(200, gin.H{\n\t\t\t\t\"success\":  true,\n\t\t\t\t\"message\":  \"Responses 渠道促销期已设置\",\n\t\t\t\t\"duration\": req.Duration,\n\t\t\t})\n\t\t}\n\t}\n}\n\n// ConfigManager 促销期配置管理接口\ntype ConfigManager interface {\n\tSetChannelPromotion(index int, duration time.Duration) error\n}\n\n// ResponsesConfigManager Responses 渠道促销期配置管理接口\ntype ResponsesConfigManager interface {\n\tSetResponsesChannelPromotion(index int, duration time.Duration) error\n}\n\n// MetricsHistoryResponse 历史指标响应\ntype MetricsHistoryResponse struct {\n\tChannelIndex int                        `json:\"channelIndex\"`\n\tChannelName  string                     `json:\"channelName\"`\n\tDataPoints   []metrics.HistoryDataPoint `json:\"dataPoints\"`\n}\n\n// GetChannelMetricsHistory 获取渠道指标历史数据（用于时间序列图表）\n// Query params:\n//   - duration: 时间范围 (1h, 6h, 24h)，默认 24h\n//   - interval: 时间间隔 (5m, 15m, 1h)，默认根据 duration 自动选择\nfunc GetChannelMetricsHistory(metricsManager *metrics.MetricsManager, cfgManager *config.ConfigManager, isResponses bool) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// 解析 duration 参数\n\t\tdurationStr := c.DefaultQuery(\"duration\", \"24h\")\n\t\tduration, err := time.ParseDuration(durationStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid duration parameter\"})\n\t\t\treturn\n\t\t}\n\n\t\t// 限制最大查询范围为 24 小时\n\t\tif duration > 24*time.Hour {\n\t\t\tduration = 24 * time.Hour\n\t\t}\n\n\t\t// 解析或自动选择 interval\n\t\tintervalStr := c.Query(\"interval\")\n\t\tvar interval time.Duration\n\t\tif intervalStr != \"\" {\n\t\t\tinterval, err = time.ParseDuration(intervalStr)\n\t\t\tif err != nil {\n\t\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid interval parameter\"})\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// 限制 interval 最小值为 1 分钟，防止生成过多 bucket\n\t\t\tif interval < time.Minute {\n\t\t\t\tinterval = time.Minute\n\t\t\t}\n\t\t} else {\n\t\t\t// 根据 duration 自动选择合适的聚合粒度\n\t\t\t// 目标：每个时间段约 60-100 个数据点，保持图表清晰\n\t\t\t// 1h = 60 points (1m interval)\n\t\t\t// 6h = 72 points (5m interval)\n\t\t\t// 24h = 96 points (15m interval)\n\t\t\tswitch {\n\t\t\tcase duration <= time.Hour:\n\t\t\t\tinterval = time.Minute\n\t\t\tcase duration <= 6*time.Hour:\n\t\t\t\tinterval = 5 * time.Minute\n\t\t\tdefault:\n\t\t\t\tinterval = 15 * time.Minute\n\t\t\t}\n\t\t}\n\n\t\tcfg := cfgManager.GetConfig()\n\t\tvar upstreams []config.UpstreamConfig\n\t\tif isResponses {\n\t\t\tupstreams = cfg.ResponsesUpstream\n\t\t} else {\n\t\t\tupstreams = cfg.Upstream\n\t\t}\n\n\t\tresult := make([]MetricsHistoryResponse, 0, len(upstreams))\n\t\tfor i, upstream := range upstreams {\n\t\t\t// 使用多 URL 聚合方法获取历史数据（支持 failover 多端点场景）\n\t\t\tdataPoints := metricsManager.GetHistoricalStatsMultiURL(upstream.GetAllBaseURLs(), upstream.APIKeys, duration, interval)\n\n\t\t\tresult = append(result, MetricsHistoryResponse{\n\t\t\t\tChannelIndex: i,\n\t\t\t\tChannelName:  upstream.Name,\n\t\t\t\tDataPoints:   dataPoints,\n\t\t\t})\n\t\t}\n\n\t\tc.JSON(200, result)\n\t}\n}\n\n// ChannelKeyMetricsHistoryResponse Key 级别历史指标响应\ntype ChannelKeyMetricsHistoryResponse struct {\n\tChannelIndex int                       `json:\"channelIndex\"`\n\tChannelName  string                    `json:\"channelName\"`\n\tKeys         []KeyMetricsHistoryResult `json:\"keys\"`\n}\n\n// KeyMetricsHistoryResult 单个 Key 的历史数据\ntype KeyMetricsHistoryResult struct {\n\tKeyMask    string                        `json:\"keyMask\"`\n\tColor      string                        `json:\"color\"`\n\tDataPoints []metrics.KeyHistoryDataPoint `json:\"dataPoints\"`\n}\n\n// Key 颜色配置（与前端一致）\nvar keyColors = []string{\n\t\"#3b82f6\", // Blue - Primary\n\t\"#f97316\", // Orange - Backup 1\n\t\"#10b981\", // Emerald - Backup 2\n\t\"#8b5cf6\", // Violet - Fallback\n\t\"#ec4899\", // Pink - Canary\n}\n\n// GetChannelKeyMetricsHistory 获取渠道下各 Key 的历史数据（用于 Key 趋势图表）\n// GET /api/channels/:id/keys/metrics/history?duration=6h\nfunc GetChannelKeyMetricsHistory(metricsManager *metrics.MetricsManager, cfgManager *config.ConfigManager, isResponses bool) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// 解析 duration 参数\n\t\tdurationStr := c.DefaultQuery(\"duration\", \"6h\")\n\n\t\tvar duration time.Duration\n\t\tvar err error\n\n\t\t// 特殊处理 \"today\" 参数\n\t\tif durationStr == \"today\" {\n\t\t\tduration = metrics.CalculateTodayDuration()\n\t\t\t// 如果刚过零点，duration 可能非常小，设置最小值\n\t\t\tif duration < time.Minute {\n\t\t\t\tduration = time.Minute\n\t\t\t}\n\t\t} else {\n\t\t\tduration, err = time.ParseDuration(durationStr)\n\t\t\tif err != nil {\n\t\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid duration parameter. Use: 1h, 6h, 24h, or today\"})\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// 限制最大查询范围为 24 小时\n\t\tif duration > 24*time.Hour {\n\t\t\tduration = 24 * time.Hour\n\t\t}\n\n\t\t// 解析或自动选择 interval\n\t\tintervalStr := c.Query(\"interval\")\n\t\tvar interval time.Duration\n\t\tif intervalStr != \"\" {\n\t\t\tinterval, err = time.ParseDuration(intervalStr)\n\t\t\tif err != nil {\n\t\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid interval parameter\"})\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// 限制 interval 最小值为 1 分钟，防止生成过多 bucket\n\t\t\tif interval < time.Minute {\n\t\t\t\tinterval = time.Minute\n\t\t\t}\n\t\t} else {\n\t\t\t// 根据 duration 自动选择合适的聚合粒度\n\t\t\t// 目标：每个时间段约 60-100 个数据点，保持图表清晰\n\t\t\t// 1h = 60 points (1m interval)\n\t\t\t// 6h = 72 points (5m interval)\n\t\t\t// 24h = 96 points (15m interval)\n\t\t\tswitch {\n\t\t\tcase duration <= time.Hour:\n\t\t\t\tinterval = time.Minute\n\t\t\tcase duration <= 6*time.Hour:\n\t\t\t\tinterval = 5 * time.Minute\n\t\t\tdefault:\n\t\t\t\tinterval = 15 * time.Minute\n\t\t\t}\n\t\t}\n\n\t\t// 解析 channel ID\n\t\tchannelIDStr := c.Param(\"id\")\n\t\tchannelID, err := strconv.Atoi(channelIDStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid channel ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tcfg := cfgManager.GetConfig()\n\t\tvar upstreams []config.UpstreamConfig\n\t\tif isResponses {\n\t\t\tupstreams = cfg.ResponsesUpstream\n\t\t} else {\n\t\t\tupstreams = cfg.Upstream\n\t\t}\n\n\t\t// 检查 channel ID 是否有效\n\t\tif channelID < 0 || channelID >= len(upstreams) {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Channel not found\"})\n\t\t\treturn\n\t\t}\n\n\t\tupstream := upstreams[channelID]\n\n\t\t// 获取所有 Key 的使用信息并筛选（最多显示 10 个）\n\t\tconst maxDisplayKeys = 10\n\t\t// 使用多 URL 聚合方法获取 Key 使用信息（支持 failover 多端点场景）\n\t\tallKeyInfos := metricsManager.GetChannelKeyUsageInfoMultiURL(upstream.GetAllBaseURLs(), upstream.APIKeys)\n\t\tdisplayKeys := metrics.SelectTopKeys(allKeyInfos, maxDisplayKeys)\n\n\t\t// 构建响应\n\t\tresult := ChannelKeyMetricsHistoryResponse{\n\t\t\tChannelIndex: channelID,\n\t\t\tChannelName:  upstream.Name,\n\t\t\tKeys:         make([]KeyMetricsHistoryResult, 0, len(displayKeys)),\n\t\t}\n\n\t\t// 为筛选后的 Key 获取历史数据\n\t\tfor i, keyInfo := range displayKeys {\n\t\t\t// 使用多 URL 聚合方法获取单个 Key 的历史数据（支持 failover 多端点场景）\n\t\t\tdataPoints := metricsManager.GetKeyHistoricalStatsMultiURL(upstream.GetAllBaseURLs(), keyInfo.APIKey, duration, interval)\n\n\t\t\t// 获取 Key 的颜色\n\t\t\tcolor := keyColors[i%len(keyColors)]\n\n\t\t\t// 获取 Key 的脱敏显示（只取前 8 个字符）\n\t\t\tkeyMask := truncateKeyMask(keyInfo.KeyMask, 8)\n\n\t\t\tresult.Keys = append(result.Keys, KeyMetricsHistoryResult{\n\t\t\t\tKeyMask:    keyMask,\n\t\t\t\tColor:      color,\n\t\t\t\tDataPoints: dataPoints,\n\t\t\t})\n\t\t}\n\n\t\tc.JSON(200, result)\n\t}\n}\n\n// truncateKeyMask 截取 keyMask 的前 N 个字符\nfunc truncateKeyMask(keyMask string, maxLen int) string {\n\tif len(keyMask) <= maxLen {\n\t\treturn keyMask\n\t}\n\treturn keyMask[:maxLen]\n}\n\n// GetChannelDashboard 获取渠道仪表盘数据（合并 channels + metrics + stats）\n// GET /api/channels/dashboard?type=messages|responses\n// 将原本需要 3 个请求的数据合并为 1 个请求，减少网络开销\nfunc GetChannelDashboard(cfgManager *config.ConfigManager, sch *scheduler.ChannelScheduler) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// 获取 type 参数，默认为 messages\n\t\tisResponses := strings.ToLower(c.Query(\"type\")) == \"responses\"\n\t\tkind := scheduler.ChannelKindMessages\n\t\tif isResponses {\n\t\t\tkind = scheduler.ChannelKindResponses\n\t\t}\n\n\t\tcfg := cfgManager.GetConfig()\n\t\tvar upstreams []config.UpstreamConfig\n\t\tvar loadBalance string\n\t\tvar metricsManager *metrics.MetricsManager\n\n\t\tif isResponses {\n\t\t\tupstreams = cfg.ResponsesUpstream\n\t\t\tloadBalance = cfg.ResponsesLoadBalance\n\t\t\tmetricsManager = sch.GetResponsesMetricsManager()\n\t\t} else {\n\t\t\tupstreams = cfg.Upstream\n\t\t\tloadBalance = cfg.LoadBalance\n\t\t\tmetricsManager = sch.GetMessagesMetricsManager()\n\t\t}\n\n\t\t// 1. 构建 channels 数据\n\t\tchannels := make([]gin.H, len(upstreams))\n\t\tfor i, up := range upstreams {\n\t\t\tstatus := config.GetChannelStatus(&up)\n\t\t\tpriority := config.GetChannelPriority(&up, i)\n\n\t\t\tchannels[i] = gin.H{\n\t\t\t\t\"index\":              i,\n\t\t\t\t\"name\":               up.Name,\n\t\t\t\t\"serviceType\":        up.ServiceType,\n\t\t\t\t\"baseUrl\":            up.BaseURL,\n\t\t\t\t\"baseUrls\":           up.BaseURLs,\n\t\t\t\t\"apiKeys\":            up.APIKeys,\n\t\t\t\t\"description\":        up.Description,\n\t\t\t\t\"website\":            up.Website,\n\t\t\t\t\"insecureSkipVerify\": up.InsecureSkipVerify,\n\t\t\t\t\"modelMapping\":       up.ModelMapping,\n\t\t\t\t\"latency\":            nil,\n\t\t\t\t\"status\":             status,\n\t\t\t\t\"priority\":           priority,\n\t\t\t\t\"promotionUntil\":     up.PromotionUntil,\n\t\t\t\t\"lowQuality\":         up.LowQuality,\n\t\t\t}\n\t\t}\n\n\t\t// 2. 构建 metrics 数据\n\t\tmetricsResult := make([]gin.H, 0, len(upstreams))\n\t\tfor i, upstream := range upstreams {\n\t\t\tresp := metricsManager.ToResponseMultiURL(i, upstream.GetAllBaseURLs(), upstream.APIKeys, 0, upstream.HistoricalAPIKeys)\n\n\t\t\titem := gin.H{\n\t\t\t\t\"channelIndex\":        i,\n\t\t\t\t\"channelName\":         upstream.Name,\n\t\t\t\t\"requestCount\":        resp.RequestCount,\n\t\t\t\t\"successCount\":        resp.SuccessCount,\n\t\t\t\t\"failureCount\":        resp.FailureCount,\n\t\t\t\t\"successRate\":         resp.SuccessRate,\n\t\t\t\t\"errorRate\":           resp.ErrorRate,\n\t\t\t\t\"consecutiveFailures\": resp.ConsecutiveFailures,\n\t\t\t\t\"latency\":             resp.Latency,\n\t\t\t\t\"keyMetrics\":          resp.KeyMetrics,\n\t\t\t\t\"timeWindows\":         resp.TimeWindows,\n\t\t\t}\n\n\t\t\tif resp.LastSuccessAt != nil {\n\t\t\t\titem[\"lastSuccessAt\"] = *resp.LastSuccessAt\n\t\t\t}\n\t\t\tif resp.LastFailureAt != nil {\n\t\t\t\titem[\"lastFailureAt\"] = *resp.LastFailureAt\n\t\t\t}\n\t\t\tif resp.CircuitBrokenAt != nil {\n\t\t\t\titem[\"circuitBrokenAt\"] = *resp.CircuitBrokenAt\n\t\t\t}\n\n\t\t\tmetricsResult = append(metricsResult, item)\n\t\t}\n\n\t\t// 3. 构建 stats 数据\n\t\tstats := gin.H{\n\t\t\t\"multiChannelMode\":    sch.IsMultiChannelMode(kind),\n\t\t\t\"activeChannelCount\":  sch.GetActiveChannelCount(kind),\n\t\t\t\"traceAffinityCount\":  sch.GetTraceAffinityManager().Size(),\n\t\t\t\"traceAffinityTTL\":    sch.GetTraceAffinityManager().GetTTL().String(),\n\t\t\t\"failureThreshold\":    metricsManager.GetFailureThreshold() * 100,\n\t\t\t\"windowSize\":          metricsManager.GetWindowSize(),\n\t\t\t\"circuitRecoveryTime\": metricsManager.GetCircuitRecoveryTime().String(),\n\t\t}\n\n\t\t// 4. 构建 recentActivity 数据（最近 15 分钟分段活跃度）\n\t\trecentActivity := make([]*metrics.ChannelRecentActivity, len(upstreams))\n\t\tfor i, upstream := range upstreams {\n\t\t\trecentActivity[i] = metricsManager.GetRecentActivityMultiURL(i, upstream.GetAllBaseURLs(), upstream.APIKeys)\n\t\t}\n\n\t\t// 返回合并数据\n\t\tc.JSON(200, gin.H{\n\t\t\t\"channels\":       channels,\n\t\t\t\"loadBalance\":    loadBalance,\n\t\t\t\"metrics\":        metricsResult,\n\t\t\t\"stats\":          stats,\n\t\t\t\"recentActivity\": recentActivity,\n\t\t})\n\t}\n}\n\n// GetGeminiChannelMetricsHistory 获取 Gemini 渠道指标历史数据（用于时间序列图表）\n// Query params:\n//   - duration: 时间范围 (1h, 6h, 24h)，默认 24h\n//   - interval: 时间间隔 (5m, 15m, 1h)，默认根据 duration 自动选择\nfunc GetGeminiChannelMetricsHistory(metricsManager *metrics.MetricsManager, cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// 解析 duration 参数\n\t\tdurationStr := c.DefaultQuery(\"duration\", \"24h\")\n\t\tduration, err := time.ParseDuration(durationStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid duration parameter\"})\n\t\t\treturn\n\t\t}\n\n\t\t// 限制最大查询范围为 24 小时\n\t\tif duration > 24*time.Hour {\n\t\t\tduration = 24 * time.Hour\n\t\t}\n\n\t\t// 解析或自动选择 interval\n\t\tintervalStr := c.Query(\"interval\")\n\t\tvar interval time.Duration\n\t\tif intervalStr != \"\" {\n\t\t\tinterval, err = time.ParseDuration(intervalStr)\n\t\t\tif err != nil {\n\t\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid interval parameter\"})\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// 限制 interval 最小值为 1 分钟，防止生成过多 bucket\n\t\t\tif interval < time.Minute {\n\t\t\t\tinterval = time.Minute\n\t\t\t}\n\t\t} else {\n\t\t\t// 根据 duration 自动选择合适的聚合粒度\n\t\t\tswitch {\n\t\t\tcase duration <= time.Hour:\n\t\t\t\tinterval = time.Minute\n\t\t\tcase duration <= 6*time.Hour:\n\t\t\t\tinterval = 5 * time.Minute\n\t\t\tdefault:\n\t\t\t\tinterval = 15 * time.Minute\n\t\t\t}\n\t\t}\n\n\t\tcfg := cfgManager.GetConfig()\n\t\tupstreams := cfg.GeminiUpstream\n\n\t\tresult := make([]MetricsHistoryResponse, 0, len(upstreams))\n\t\tfor i, upstream := range upstreams {\n\t\t\t// 使用多 URL 聚合方法获取历史数据（支持 failover 多端点场景）\n\t\t\tdataPoints := metricsManager.GetHistoricalStatsMultiURL(upstream.GetAllBaseURLs(), upstream.APIKeys, duration, interval)\n\n\t\t\tresult = append(result, MetricsHistoryResponse{\n\t\t\t\tChannelIndex: i,\n\t\t\t\tChannelName:  upstream.Name,\n\t\t\t\tDataPoints:   dataPoints,\n\t\t\t})\n\t\t}\n\n\t\tc.JSON(200, result)\n\t}\n}\n\n// GetGeminiChannelKeyMetricsHistory 获取 Gemini 渠道下各 Key 的历史数据（用于 Key 趋势图表）\n// GET /api/gemini/channels/:id/keys/metrics/history?duration=6h\nfunc GetGeminiChannelKeyMetricsHistory(metricsManager *metrics.MetricsManager, cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// 解析 duration 参数\n\t\tdurationStr := c.DefaultQuery(\"duration\", \"6h\")\n\n\t\tvar duration time.Duration\n\t\tvar err error\n\n\t\t// 特殊处理 \"today\" 参数\n\t\tif durationStr == \"today\" {\n\t\t\tduration = metrics.CalculateTodayDuration()\n\t\t\t// 如果刚过零点，duration 可能非常小，设置最小值\n\t\t\tif duration < time.Minute {\n\t\t\t\tduration = time.Minute\n\t\t\t}\n\t\t} else {\n\t\t\tduration, err = time.ParseDuration(durationStr)\n\t\t\tif err != nil {\n\t\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid duration parameter. Use: 1h, 6h, 24h, or today\"})\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// 限制最大查询范围为 24 小时\n\t\tif duration > 24*time.Hour {\n\t\t\tduration = 24 * time.Hour\n\t\t}\n\n\t\t// 解析或自动选择 interval\n\t\tintervalStr := c.Query(\"interval\")\n\t\tvar interval time.Duration\n\t\tif intervalStr != \"\" {\n\t\t\tinterval, err = time.ParseDuration(intervalStr)\n\t\t\tif err != nil {\n\t\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid interval parameter\"})\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// 限制 interval 最小值为 1 分钟，防止生成过多 bucket\n\t\t\tif interval < time.Minute {\n\t\t\t\tinterval = time.Minute\n\t\t\t}\n\t\t} else {\n\t\t\t// 根据 duration 自动选择合适的聚合粒度\n\t\t\tswitch {\n\t\t\tcase duration <= time.Hour:\n\t\t\t\tinterval = time.Minute\n\t\t\tcase duration <= 6*time.Hour:\n\t\t\t\tinterval = 5 * time.Minute\n\t\t\tdefault:\n\t\t\t\tinterval = 15 * time.Minute\n\t\t\t}\n\t\t}\n\n\t\t// 解析 channel ID\n\t\tchannelIDStr := c.Param(\"id\")\n\t\tchannelID, err := strconv.Atoi(channelIDStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid channel ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tcfg := cfgManager.GetConfig()\n\t\tupstreams := cfg.GeminiUpstream\n\n\t\t// 检查 channel ID 是否有效\n\t\tif channelID < 0 || channelID >= len(upstreams) {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Channel not found\"})\n\t\t\treturn\n\t\t}\n\n\t\tupstream := upstreams[channelID]\n\n\t\t// 获取所有 Key 的使用信息并筛选（最多显示 10 个）\n\t\tconst maxDisplayKeys = 10\n\t\t// 使用多 URL 聚合方法获取 Key 使用信息（支持 failover 多端点场景）\n\t\tallKeyInfos := metricsManager.GetChannelKeyUsageInfoMultiURL(upstream.GetAllBaseURLs(), upstream.APIKeys)\n\t\tdisplayKeys := metrics.SelectTopKeys(allKeyInfos, maxDisplayKeys)\n\n\t\t// 构建响应\n\t\tresult := ChannelKeyMetricsHistoryResponse{\n\t\t\tChannelIndex: channelID,\n\t\t\tChannelName:  upstream.Name,\n\t\t\tKeys:         make([]KeyMetricsHistoryResult, 0, len(displayKeys)),\n\t\t}\n\n\t\t// 为筛选后的 Key 获取历史数据\n\t\tfor i, keyInfo := range displayKeys {\n\t\t\t// 使用多 URL 聚合方法获取单个 Key 的历史数据（支持 failover 多端点场景）\n\t\t\tdataPoints := metricsManager.GetKeyHistoricalStatsMultiURL(upstream.GetAllBaseURLs(), keyInfo.APIKey, duration, interval)\n\n\t\t\t// 获取 Key 的颜色\n\t\t\tcolor := keyColors[i%len(keyColors)]\n\n\t\t\t// 获取 Key 的脱敏显示（只取前 8 个字符）\n\t\t\tkeyMask := truncateKeyMask(keyInfo.KeyMask, 8)\n\n\t\t\tresult.Keys = append(result.Keys, KeyMetricsHistoryResult{\n\t\t\t\tKeyMask:    keyMask,\n\t\t\t\tColor:      color,\n\t\t\t\tDataPoints: dataPoints,\n\t\t\t})\n\t\t}\n\n\t\tc.JSON(200, result)\n\t}\n}\n\n// GetGeminiChannelMetrics 获取 Gemini 渠道指标\nfunc GetGeminiChannelMetrics(metricsManager *metrics.MetricsManager, cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tcfg := cfgManager.GetConfig()\n\t\tupstreams := cfg.GeminiUpstream\n\n\t\tresult := make([]gin.H, 0, len(upstreams))\n\t\tfor i, upstream := range upstreams {\n\t\t\t// 使用多 URL 聚合方法获取渠道指标（支持 failover 多端点场景）\n\t\t\tresp := metricsManager.ToResponseMultiURL(i, upstream.GetAllBaseURLs(), upstream.APIKeys, 0, upstream.HistoricalAPIKeys)\n\n\t\t\titem := gin.H{\n\t\t\t\t\"channelIndex\":        i,\n\t\t\t\t\"channelName\":         upstream.Name,\n\t\t\t\t\"requestCount\":        resp.RequestCount,\n\t\t\t\t\"successCount\":        resp.SuccessCount,\n\t\t\t\t\"failureCount\":        resp.FailureCount,\n\t\t\t\t\"successRate\":         resp.SuccessRate,\n\t\t\t\t\"errorRate\":           resp.ErrorRate,\n\t\t\t\t\"consecutiveFailures\": resp.ConsecutiveFailures,\n\t\t\t\t\"latency\":             resp.Latency,\n\t\t\t\t\"keyMetrics\":          resp.KeyMetrics,  // 各 Key 的详细指标\n\t\t\t\t\"timeWindows\":         resp.TimeWindows, // 分时段统计 (15m, 1h, 6h, 24h)\n\t\t\t}\n\n\t\t\tif resp.LastSuccessAt != nil {\n\t\t\t\titem[\"lastSuccessAt\"] = *resp.LastSuccessAt\n\t\t\t}\n\t\t\tif resp.LastFailureAt != nil {\n\t\t\t\titem[\"lastFailureAt\"] = *resp.LastFailureAt\n\t\t\t}\n\t\t\tif resp.CircuitBrokenAt != nil {\n\t\t\t\titem[\"circuitBrokenAt\"] = *resp.CircuitBrokenAt\n\t\t\t}\n\n\t\t\tresult = append(result, item)\n\t\t}\n\n\t\tc.JSON(200, result)\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/common/client_error_test.go",
    "content": "package common\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"testing\"\n)\n\nfunc TestIsClientSideError(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\terr      error\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname:     \"nil error\",\n\t\t\terr:      nil,\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"context.Canceled\",\n\t\t\terr:      context.Canceled,\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"wrapped context.Canceled\",\n\t\t\terr:      fmt.Errorf(\"request failed: %w\", context.Canceled),\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"deeply wrapped context.Canceled\",\n\t\t\terr:      fmt.Errorf(\"outer: %w\", fmt.Errorf(\"inner: %w\", context.Canceled)),\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"context.DeadlineExceeded - not client cancel\",\n\t\t\terr:      context.DeadlineExceeded,\n\t\t\texpected: false, // 可能是服务端超时\n\t\t},\n\t\t{\n\t\t\tname:     \"broken pipe - connection issue, should failover\",\n\t\t\terr:      errors.New(\"write tcp: broken pipe\"),\n\t\t\texpected: false, // 连接故障，应继续 failover\n\t\t},\n\t\t{\n\t\t\tname:     \"connection reset - connection issue, should failover\",\n\t\t\terr:      errors.New(\"read tcp: connection reset by peer\"),\n\t\t\texpected: false, // 连接故障，应继续 failover\n\t\t},\n\t\t{\n\t\t\tname:     \"EOF - upstream issue\",\n\t\t\terr:      errors.New(\"unexpected EOF\"),\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"normal error\",\n\t\t\terr:      errors.New(\"upstream error: 500\"),\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"network timeout\",\n\t\t\terr:      errors.New(\"dial tcp: i/o timeout\"),\n\t\t\texpected: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := isClientSideError(tt.err)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"isClientSideError(%v) = %v, expected %v\", tt.err, result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/common/failover.go",
    "content": "// Package common 提供 handlers 模块的公共功能\npackage common\n\nimport (\n\t\"encoding/json\"\n\t\"log\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// FailoverError 封装故障转移错误信息\ntype FailoverError struct {\n\tStatus int\n\tBody   []byte\n}\n\n// ShouldRetryWithNextKey 判断是否应该使用下一个密钥重试\n// 返回: (shouldFailover bool, isQuotaRelated bool)\n//\n// apiType: 接口类型（Messages/Responses/Gemini），用于日志标签前缀\n// fuzzyMode: 启用时，所有非 2xx 错误都触发 failover（模糊处理错误类型）\n//\n// HTTP 状态码分类策略（非 fuzzy 模式）：\n//   - 4xx 客户端错误：部分应触发 failover（密钥/配额问题）\n//   - 5xx 服务端错误：应触发 failover（上游临时故障）\n//   - 2xx/3xx：不应触发 failover（成功或重定向）\n//\n// isQuotaRelated 标记用于调度器优先级调整：\n//   - true: 额度/配额相关，降低密钥优先级\n//   - false: 临时错误，不影响优先级\nfunc ShouldRetryWithNextKey(statusCode int, bodyBytes []byte, fuzzyMode bool, apiType string) (bool, bool) {\n\tlog.Printf(\"[%s-Failover-Entry] ShouldRetryWithNextKey 入口: statusCode=%d, bodyLen=%d, fuzzyMode=%v\",\n\t\tapiType, statusCode, len(bodyBytes), fuzzyMode)\n\tif fuzzyMode {\n\t\treturn shouldRetryWithNextKeyFuzzy(statusCode, bodyBytes, apiType)\n\t}\n\treturn shouldRetryWithNextKeyNormal(statusCode, bodyBytes, apiType)\n}\n\n// shouldRetryWithNextKeyFuzzy Fuzzy 模式：所有非 2xx 错误都尝试 failover\n// 同时检查消息体中的配额相关关键词，确保 403 + \"预扣费额度\" 等情况能正确识别\n// 但对于内容审核等不可重试错误，即使在 Fuzzy 模式下也不应重试\nfunc shouldRetryWithNextKeyFuzzy(statusCode int, bodyBytes []byte, apiType string) (bool, bool) {\n\tlog.Printf(\"[%s-Failover-Fuzzy] 进入 Fuzzy 模式处理: statusCode=%d, bodyLen=%d\", apiType, statusCode, len(bodyBytes))\n\tif statusCode >= 200 && statusCode < 300 {\n\t\treturn false, false\n\t}\n\n\t// 检查是否为不可重试错误（内容审核等）\n\tif len(bodyBytes) > 0 {\n\t\tif isNonRetryableError(bodyBytes) {\n\t\t\tlog.Printf(\"[%s-Failover-Fuzzy] 检测到不可重试错误，不进行 failover\", apiType)\n\t\t\treturn false, false\n\t\t}\n\t}\n\n\t// 状态码直接标记为配额相关\n\tif statusCode == 402 || statusCode == 429 {\n\t\tlog.Printf(\"[%s-Failover-Fuzzy] 状态码 %d 直接标记为配额相关\", apiType, statusCode)\n\t\treturn true, true\n\t}\n\n\t// 对于其他状态码，检查消息体是否包含配额相关关键词\n\t// 这样 403 + \"预扣费额度\" 消息 → isQuotaRelated=true\n\tif len(bodyBytes) > 0 {\n\t\t_, msgQuota := classifyByErrorMessage(bodyBytes, apiType)\n\t\tif msgQuota {\n\t\t\tlog.Printf(\"[%s-Failover-Fuzzy] 消息体包含配额相关关键词，标记为配额相关\", apiType)\n\t\t\treturn true, true\n\t\t}\n\t}\n\n\tlog.Printf(\"[%s-Failover-Fuzzy] Fuzzy 模式结果: shouldFailover=true, isQuotaRelated=false\", apiType)\n\treturn true, false\n}\n\n// shouldRetryWithNextKeyNormal 原有的精确错误分类逻辑\nfunc shouldRetryWithNextKeyNormal(statusCode int, bodyBytes []byte, apiType string) (bool, bool) {\n\t// 先检查是否为不可重试错误（内容审核等），这类错误无论状态码如何都不应重试\n\tif len(bodyBytes) > 0 && isNonRetryableError(bodyBytes) {\n\t\tlog.Printf(\"[%s-Failover-Debug] 检测到不可重试错误，不进行 failover\", apiType)\n\t\treturn false, false\n\t}\n\n\tshouldFailover, isQuotaRelated := classifyByStatusCode(statusCode)\n\n\tlog.Printf(\"[%s-Failover-Debug] shouldRetryWithNextKeyNormal: statusCode=%d, bodyLen=%d, shouldFailover=%v, isQuotaRelated=%v\",\n\t\tapiType, statusCode, len(bodyBytes), shouldFailover, isQuotaRelated)\n\n\tif shouldFailover {\n\t\t// 如果状态码已标记为 quota 相关，直接返回\n\t\tif isQuotaRelated {\n\t\t\treturn true, true\n\t\t}\n\t\t// 否则，仍检查消息体是否包含 quota 相关关键词\n\t\t// 这样 403 + \"预扣费额度\" 消息 → isQuotaRelated=true\n\t\tlog.Printf(\"[%s-Failover-Debug] 调用 classifyByErrorMessage, body=%s\", apiType, string(bodyBytes))\n\t\t_, msgQuota := classifyByErrorMessage(bodyBytes, apiType)\n\t\tlog.Printf(\"[%s-Failover-Debug] classifyByErrorMessage 返回: msgQuota=%v\", apiType, msgQuota)\n\t\tif msgQuota {\n\t\t\treturn true, true\n\t\t}\n\t\treturn true, false\n\t}\n\n\t// statusCode 不触发 failover 时，完全依赖消息体判断\n\treturn classifyByErrorMessage(bodyBytes, apiType)\n}\n\n// classifyByStatusCode 基于 HTTP 状态码分类\nfunc classifyByStatusCode(statusCode int) (bool, bool) {\n\tswitch {\n\t// 认证/授权错误 (应 failover，非配额相关)\n\tcase statusCode == 401:\n\t\treturn true, false\n\tcase statusCode == 403:\n\t\treturn true, false\n\n\t// 配额/计费错误 (应 failover，配额相关)\n\tcase statusCode == 402:\n\t\treturn true, true\n\tcase statusCode == 429:\n\t\treturn true, true\n\n\t// 超时错误 (应 failover，非配额相关)\n\tcase statusCode == 408:\n\t\treturn true, false\n\n\t// 需要检查消息体的状态码 (交给第二层判断)\n\tcase statusCode == 400:\n\t\treturn false, false\n\n\t// 请求错误 (不应 failover，客户端问题)\n\tcase statusCode == 404, statusCode == 405, statusCode == 406,\n\t\tstatusCode == 409, statusCode == 410, statusCode == 411,\n\t\tstatusCode == 412, statusCode == 413, statusCode == 414,\n\t\tstatusCode == 415, statusCode == 416, statusCode == 417,\n\t\tstatusCode == 422, statusCode == 423, statusCode == 424,\n\t\tstatusCode == 426, statusCode == 428, statusCode == 431,\n\t\tstatusCode == 451:\n\t\treturn false, false\n\n\t// 服务端错误 (应 failover，非配额相关)\n\tcase statusCode >= 500:\n\t\treturn true, false\n\n\t// 其他 4xx (保守处理，不 failover)\n\tcase statusCode >= 400 && statusCode < 500:\n\t\treturn false, false\n\n\t// 成功/重定向 (不应 failover)\n\tdefault:\n\t\treturn false, false\n\t}\n}\n\n// classifyByErrorMessage 基于错误消息内容分类\nfunc classifyByErrorMessage(bodyBytes []byte, apiType string) (bool, bool) {\n\tvar errResp map[string]interface{}\n\tif err := json.Unmarshal(bodyBytes, &errResp); err != nil {\n\t\tlog.Printf(\"[%s-Failover-Debug] JSON解析失败: %v, body长度=%d\", apiType, err, len(bodyBytes))\n\t\treturn false, false\n\t}\n\n\terrObj, ok := errResp[\"error\"].(map[string]interface{})\n\tif !ok {\n\t\tlog.Printf(\"[%s-Failover-Debug] 未找到error对象, keys=%v\", apiType, getMapKeys(errResp))\n\t\treturn false, false\n\t}\n\n\t// 检查 error.code 字段，某些错误码不应重试（内容审核、无效请求等）\n\tif errCode, ok := errObj[\"code\"].(string); ok {\n\t\tif isNonRetryableErrorCode(errCode) {\n\t\t\tlog.Printf(\"[%s-Failover-Debug] 检测到不可重试错误码: %s\", apiType, errCode)\n\t\t\treturn false, false\n\t\t}\n\t}\n\n\t// 尝试多个可能的消息字段: message, upstream_error, detail\n\tmessageFields := []string{\"message\", \"upstream_error\", \"detail\"}\n\tfor _, field := range messageFields {\n\t\tif msg, ok := errObj[field].(string); ok {\n\t\t\tlog.Printf(\"[%s-Failover-Debug] 提取到消息 (字段: %s): %s\", apiType, field, msg)\n\t\t\tif failover, quota := classifyMessage(msg); failover {\n\t\t\t\tlog.Printf(\"[%s-Failover-Debug] 消息分类结果: failover=%v, quota=%v\", apiType, failover, quota)\n\t\t\t\treturn true, quota\n\t\t\t}\n\t\t}\n\t}\n\n\t// 如果 upstream_error 是嵌套对象，尝试提取其中的消息\n\tif upstreamErr, ok := errObj[\"upstream_error\"].(map[string]interface{}); ok {\n\t\tif msg, ok := upstreamErr[\"message\"].(string); ok {\n\t\t\tlog.Printf(\"[%s-Failover-Debug] 提取到嵌套 upstream_error.message: %s\", apiType, msg)\n\t\t\tif failover, quota := classifyMessage(msg); failover {\n\t\t\t\tlog.Printf(\"[%s-Failover-Debug] 消息分类结果: failover=%v, quota=%v\", apiType, failover, quota)\n\t\t\t\treturn true, quota\n\t\t\t}\n\t\t}\n\t}\n\n\t// 检查 type 字段\n\tif errType, ok := errObj[\"type\"].(string); ok {\n\t\tif failover, quota := classifyErrorType(errType); failover {\n\t\t\treturn true, quota\n\t\t}\n\t}\n\n\tlog.Printf(\"[%s-Failover-Debug] 未匹配任何关键词, errObj keys=%v\", apiType, getMapKeys(errObj))\n\treturn false, false\n}\n\n// classifyMessage 基于错误消息内容分类\nfunc classifyMessage(msg string) (bool, bool) {\n\tmsgLower := strings.ToLower(msg)\n\n\t// 配额/余额相关关键词 (failover + quota)\n\tquotaKeywords := []string{\n\t\t\"insufficient\", \"quota\", \"credit\", \"balance\",\n\t\t\"rate limit\", \"limit exceeded\", \"exceeded\",\n\t\t\"billing\", \"payment\", \"subscription\",\n\t\t\"积分不足\", \"余额不足\", \"请求数限制\", \"额度\", \"预扣费\",\n\t}\n\tfor _, keyword := range quotaKeywords {\n\t\tif strings.Contains(msgLower, keyword) {\n\t\t\treturn true, true\n\t\t}\n\t}\n\n\t// 认证/授权相关关键词 (failover + 非 quota)\n\tauthKeywords := []string{\n\t\t\"invalid\", \"unauthorized\", \"authentication\",\n\t\t\"api key\", \"apikey\", \"token\", \"expired\",\n\t\t\"permission\", \"forbidden\", \"denied\",\n\t\t\"密钥无效\", \"认证失败\", \"权限不足\",\n\t}\n\tfor _, keyword := range authKeywords {\n\t\tif strings.Contains(msgLower, keyword) {\n\t\t\treturn true, false\n\t\t}\n\t}\n\n\t// 临时错误关键词 (failover + 非 quota)\n\ttransientKeywords := []string{\n\t\t\"timeout\", \"timed out\", \"temporarily\",\n\t\t\"overloaded\", \"unavailable\", \"retry\",\n\t\t\"server error\", \"internal error\",\n\t\t\"超时\", \"暂时\", \"重试\",\n\t}\n\tfor _, keyword := range transientKeywords {\n\t\tif strings.Contains(msgLower, keyword) {\n\t\t\treturn true, false\n\t\t}\n\t}\n\n\treturn false, false\n}\n\n// classifyErrorType 基于错误类型分类\nfunc classifyErrorType(errType string) (bool, bool) {\n\ttypeLower := strings.ToLower(errType)\n\n\t// 配额相关的错误类型 (failover + quota)\n\tquotaTypes := []string{\n\t\t\"over_quota\", \"quota_exceeded\", \"rate_limit\",\n\t\t\"billing\", \"insufficient\", \"payment\",\n\t}\n\tfor _, t := range quotaTypes {\n\t\tif strings.Contains(typeLower, t) {\n\t\t\treturn true, true\n\t\t}\n\t}\n\n\t// 认证相关的错误类型 (failover + 非 quota)\n\tauthTypes := []string{\n\t\t\"authentication\", \"authorization\", \"permission\",\n\t\t\"invalid_api_key\", \"invalid_token\", \"expired\",\n\t}\n\tfor _, t := range authTypes {\n\t\tif strings.Contains(typeLower, t) {\n\t\t\treturn true, false\n\t\t}\n\t}\n\n\t// 服务端错误类型 (failover + 非 quota)\n\tserverTypes := []string{\n\t\t\"server_error\", \"internal_error\", \"service_unavailable\",\n\t\t\"timeout\", \"overloaded\",\n\t}\n\tfor _, t := range serverTypes {\n\t\tif strings.Contains(typeLower, t) {\n\t\t\treturn true, false\n\t\t}\n\t}\n\n\treturn false, false\n}\n\n// HandleAllChannelsFailed 处理所有渠道都失败的情况\n// fuzzyMode: 是否启用模糊模式（返回通用错误）\n// lastFailoverError: 最后一个故障转移错误\n// lastError: 最后一个错误\n// apiType: API 类型（用于错误消息）\nfunc HandleAllChannelsFailed(c *gin.Context, fuzzyMode bool, lastFailoverError *FailoverError, lastError error, apiType string) {\n\t// Fuzzy 模式下返回通用错误，不透传上游详情\n\tif fuzzyMode {\n\t\tc.JSON(503, gin.H{\n\t\t\t\"type\": \"error\",\n\t\t\t\"error\": gin.H{\n\t\t\t\t\"type\":    \"service_unavailable\",\n\t\t\t\t\"message\": \"All upstream channels are currently unavailable\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\n\t// 非 Fuzzy 模式：透传最后一个错误的详情\n\tif lastFailoverError != nil {\n\t\tstatus := lastFailoverError.Status\n\t\tif status == 0 {\n\t\t\tstatus = 503\n\t\t}\n\t\tvar errBody map[string]interface{}\n\t\tif err := json.Unmarshal(lastFailoverError.Body, &errBody); err == nil {\n\t\t\tc.JSON(status, errBody)\n\t\t} else {\n\t\t\tc.JSON(status, gin.H{\"error\": string(lastFailoverError.Body)})\n\t\t}\n\t} else {\n\t\terrMsg := \"所有渠道都不可用\"\n\t\tif lastError != nil {\n\t\t\terrMsg = lastError.Error()\n\t\t}\n\t\tc.JSON(503, gin.H{\n\t\t\t\"error\":   \"所有\" + apiType + \"渠道都不可用\",\n\t\t\t\"details\": errMsg,\n\t\t})\n\t}\n}\n\n// HandleAllKeysFailed 处理所有密钥都失败的情况（单渠道模式）\nfunc HandleAllKeysFailed(c *gin.Context, fuzzyMode bool, lastFailoverError *FailoverError, lastError error, apiType string) {\n\t// Fuzzy 模式下返回通用错误\n\tif fuzzyMode {\n\t\tc.JSON(503, gin.H{\n\t\t\t\"type\": \"error\",\n\t\t\t\"error\": gin.H{\n\t\t\t\t\"type\":    \"service_unavailable\",\n\t\t\t\t\"message\": \"All upstream channels are currently unavailable\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\n\t// 非 Fuzzy 模式：透传最后一个错误的详情\n\tif lastFailoverError != nil {\n\t\tstatus := lastFailoverError.Status\n\t\tif status == 0 {\n\t\t\tstatus = 500\n\t\t}\n\t\tvar errBody map[string]interface{}\n\t\tif err := json.Unmarshal(lastFailoverError.Body, &errBody); err == nil {\n\t\t\tc.JSON(status, errBody)\n\t\t} else {\n\t\t\tc.JSON(status, gin.H{\"error\": string(lastFailoverError.Body)})\n\t\t}\n\t} else {\n\t\terrMsg := \"未知错误\"\n\t\tif lastError != nil {\n\t\t\terrMsg = lastError.Error()\n\t\t}\n\t\tc.JSON(500, gin.H{\n\t\t\t\"error\":   \"所有上游\" + apiType + \"API密钥都不可用\",\n\t\t\t\"details\": errMsg,\n\t\t})\n\t}\n}\n\n// getMapKeys 获取 map 的所有 key（用于调试日志）\nfunc getMapKeys(m map[string]interface{}) []string {\n\tkeys := make([]string, 0, len(m))\n\tfor k := range m {\n\t\tkeys = append(keys, k)\n\t}\n\treturn keys\n}\n\n// isNonRetryableErrorCode 判断错误码是否不应重试\n// 这些错误与请求内容相关，换 Key 重试不会改变结果\nfunc isNonRetryableErrorCode(code string) bool {\n\tnonRetryableCodes := []string{\n\t\t// 内容审核相关\n\t\t\"sensitive_words_detected\",\n\t\t\"content_policy_violation\",\n\t\t\"content_filter\",\n\t\t\"content_blocked\",\n\t\t\"moderation_blocked\",\n\t\t// 请求内容无效\n\t\t\"invalid_request\",\n\t\t\"invalid_request_error\",\n\t\t\"bad_request\",\n\t}\n\tcodeLower := strings.ToLower(code)\n\tfor _, c := range nonRetryableCodes {\n\t\tif codeLower == c {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// isNonRetryableError 检查响应体是否包含不可重试的错误码\nfunc isNonRetryableError(bodyBytes []byte) bool {\n\tvar errResp map[string]interface{}\n\tif err := json.Unmarshal(bodyBytes, &errResp); err != nil {\n\t\treturn false\n\t}\n\terrObj, ok := errResp[\"error\"].(map[string]interface{})\n\tif !ok {\n\t\treturn false\n\t}\n\tif errCode, ok := errObj[\"code\"].(string); ok {\n\t\treturn isNonRetryableErrorCode(errCode)\n\t}\n\treturn false\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/common/failover_test.go",
    "content": "package common\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n)\n\n// TestClassifyByStatusCode 测试基于状态码的分类\nfunc TestClassifyByStatusCode(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tstatusCode   int\n\t\twantFailover bool\n\t\twantQuota    bool\n\t}{\n\t\t// 认证/授权错误\n\t\t{\"401 Unauthorized\", 401, true, false},\n\t\t{\"403 Forbidden\", 403, true, false},\n\n\t\t// 配额/计费错误\n\t\t{\"402 Payment Required\", 402, true, true},\n\t\t{\"429 Too Many Requests\", 429, true, true},\n\n\t\t// 超时错误\n\t\t{\"408 Request Timeout\", 408, true, false},\n\n\t\t// 服务端错误\n\t\t{\"500 Internal Server Error\", 500, true, false},\n\t\t{\"502 Bad Gateway\", 502, true, false},\n\t\t{\"503 Service Unavailable\", 503, true, false},\n\t\t{\"504 Gateway Timeout\", 504, true, false},\n\n\t\t// 不应 failover 的客户端错误\n\t\t{\"400 Bad Request\", 400, false, false},\n\t\t{\"404 Not Found\", 404, false, false},\n\t\t{\"405 Method Not Allowed\", 405, false, false},\n\t\t{\"413 Payload Too Large\", 413, false, false},\n\t\t{\"422 Unprocessable Entity\", 422, false, false},\n\n\t\t// 成功状态码\n\t\t{\"200 OK\", 200, false, false},\n\t\t{\"201 Created\", 201, false, false},\n\t\t{\"204 No Content\", 204, false, false},\n\n\t\t// 重定向\n\t\t{\"301 Moved Permanently\", 301, false, false},\n\t\t{\"302 Found\", 302, false, false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgotFailover, gotQuota := classifyByStatusCode(tt.statusCode)\n\t\t\tif gotFailover != tt.wantFailover {\n\t\t\t\tt.Errorf(\"classifyByStatusCode(%d) failover = %v, want %v\", tt.statusCode, gotFailover, tt.wantFailover)\n\t\t\t}\n\t\t\tif gotQuota != tt.wantQuota {\n\t\t\t\tt.Errorf(\"classifyByStatusCode(%d) quota = %v, want %v\", tt.statusCode, gotQuota, tt.wantQuota)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestClassifyMessage 测试基于错误消息的分类\nfunc TestClassifyMessage(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tmessage      string\n\t\twantFailover bool\n\t\twantQuota    bool\n\t}{\n\t\t// 配额相关\n\t\t{\"insufficient credits\", \"You have insufficient credits\", true, true},\n\t\t{\"quota exceeded\", \"API quota exceeded for this month\", true, true},\n\t\t{\"rate limit\", \"Rate limit exceeded, please retry later\", true, true},\n\t\t{\"balance\", \"Account balance is zero\", true, true},\n\t\t{\"billing\", \"Billing issue detected\", true, true},\n\t\t{\"中文-积分不足\", \"您的积分不足，请充值\", true, true},\n\t\t{\"中文-余额不足\", \"账户余额不足\", true, true},\n\t\t{\"中文-请求数限制\", \"已达到请求数限制\", true, true},\n\n\t\t// 认证相关\n\t\t{\"invalid api key\", \"Invalid API key provided\", true, false},\n\t\t{\"unauthorized\", \"Unauthorized access\", true, false},\n\t\t{\"token expired\", \"Your token has expired\", true, false},\n\t\t{\"permission denied\", \"Permission denied for this resource\", true, false},\n\t\t{\"中文-密钥无效\", \"密钥无效，请检查\", true, false},\n\n\t\t// 临时错误\n\t\t{\"timeout\", \"Request timeout, please retry\", true, false},\n\t\t{\"server overloaded\", \"Server is overloaded\", true, false},\n\t\t{\"temporarily unavailable\", \"Service temporarily unavailable\", true, false},\n\t\t{\"中文-超时\", \"请求超时\", true, false},\n\n\t\t// 不应 failover\n\t\t{\"normal error\", \"Something went wrong\", false, false},\n\t\t{\"validation error\", \"Field 'name' is required\", false, false},\n\t\t{\"empty message\", \"\", false, false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgotFailover, gotQuota := classifyMessage(tt.message)\n\t\t\tif gotFailover != tt.wantFailover {\n\t\t\t\tt.Errorf(\"classifyMessage(%q) failover = %v, want %v\", tt.message, gotFailover, tt.wantFailover)\n\t\t\t}\n\t\t\tif gotQuota != tt.wantQuota {\n\t\t\t\tt.Errorf(\"classifyMessage(%q) quota = %v, want %v\", tt.message, gotQuota, tt.wantQuota)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestClassifyErrorType 测试基于错误类型的分类\nfunc TestClassifyErrorType(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\terrType      string\n\t\twantFailover bool\n\t\twantQuota    bool\n\t}{\n\t\t// 配额相关\n\t\t{\"over_quota\", \"over_quota\", true, true},\n\t\t{\"quota_exceeded\", \"quota_exceeded\", true, true},\n\t\t{\"rate_limit_exceeded\", \"rate_limit_exceeded\", true, true},\n\t\t{\"billing_error\", \"billing_error\", true, true},\n\t\t{\"insufficient_funds\", \"insufficient_funds\", true, true},\n\n\t\t// 认证相关\n\t\t{\"authentication_error\", \"authentication_error\", true, false},\n\t\t{\"invalid_api_key\", \"invalid_api_key\", true, false},\n\t\t{\"permission_denied\", \"permission_denied\", true, false},\n\n\t\t// 服务端错误\n\t\t{\"server_error\", \"server_error\", true, false},\n\t\t{\"internal_error\", \"internal_error\", true, false},\n\t\t{\"service_unavailable\", \"service_unavailable\", true, false},\n\n\t\t// 不应 failover\n\t\t{\"invalid_request\", \"invalid_request\", false, false},\n\t\t{\"validation_error\", \"validation_error\", false, false},\n\t\t{\"unknown_error\", \"unknown_error\", false, false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgotFailover, gotQuota := classifyErrorType(tt.errType)\n\t\t\tif gotFailover != tt.wantFailover {\n\t\t\t\tt.Errorf(\"classifyErrorType(%q) failover = %v, want %v\", tt.errType, gotFailover, tt.wantFailover)\n\t\t\t}\n\t\t\tif gotQuota != tt.wantQuota {\n\t\t\t\tt.Errorf(\"classifyErrorType(%q) quota = %v, want %v\", tt.errType, gotQuota, tt.wantQuota)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestClassifyByErrorMessage 测试基于响应体的分类\nfunc TestClassifyByErrorMessage(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tbody         map[string]interface{}\n\t\twantFailover bool\n\t\twantQuota    bool\n\t}{\n\t\t{\n\t\t\tname: \"quota error in message\",\n\t\t\tbody: map[string]interface{}{\n\t\t\t\t\"error\": map[string]interface{}{\n\t\t\t\t\t\"message\": \"You have exceeded your quota\",\n\t\t\t\t\t\"type\":    \"error\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantFailover: true,\n\t\t\twantQuota:    true,\n\t\t},\n\t\t{\n\t\t\tname: \"auth error in message\",\n\t\t\tbody: map[string]interface{}{\n\t\t\t\t\"error\": map[string]interface{}{\n\t\t\t\t\t\"message\": \"Invalid API key\",\n\t\t\t\t\t\"type\":    \"error\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantFailover: true,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t{\n\t\t\tname: \"quota error in type\",\n\t\t\tbody: map[string]interface{}{\n\t\t\t\t\"error\": map[string]interface{}{\n\t\t\t\t\t\"message\": \"Error occurred\",\n\t\t\t\t\t\"type\":    \"over_quota\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantFailover: true,\n\t\t\twantQuota:    true,\n\t\t},\n\t\t{\n\t\t\tname: \"server error in type\",\n\t\t\tbody: map[string]interface{}{\n\t\t\t\t\"error\": map[string]interface{}{\n\t\t\t\t\t\"message\": \"Error occurred\",\n\t\t\t\t\t\"type\":    \"server_error\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantFailover: true,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t{\n\t\t\tname: \"no failover keywords\",\n\t\t\tbody: map[string]interface{}{\n\t\t\t\t\"error\": map[string]interface{}{\n\t\t\t\t\t\"message\": \"Bad request format\",\n\t\t\t\t\t\"type\":    \"invalid_request\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantFailover: false,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t{\n\t\t\tname:         \"empty body\",\n\t\t\tbody:         map[string]interface{}{},\n\t\t\twantFailover: false,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t{\n\t\t\tname: \"no error field\",\n\t\t\tbody: map[string]interface{}{\n\t\t\t\t\"status\": \"error\",\n\t\t\t},\n\t\t\twantFailover: false,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t// upstream_error 字段支持（Responses API 错误格式）\n\t\t{\n\t\t\tname: \"upstream_error string field - auth error\",\n\t\t\tbody: map[string]interface{}{\n\t\t\t\t\"error\": map[string]interface{}{\n\t\t\t\t\t\"type\":           \"upstream_error\",\n\t\t\t\t\t\"upstream_error\": \"Invalid API key provided\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantFailover: true,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t{\n\t\t\tname: \"upstream_error string field - quota error\",\n\t\t\tbody: map[string]interface{}{\n\t\t\t\t\"error\": map[string]interface{}{\n\t\t\t\t\t\"type\":           \"upstream_error\",\n\t\t\t\t\t\"upstream_error\": \"Rate limit exceeded, please retry later\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantFailover: true,\n\t\t\twantQuota:    true,\n\t\t},\n\t\t{\n\t\t\tname: \"upstream_error nested object with message\",\n\t\t\tbody: map[string]interface{}{\n\t\t\t\t\"error\": map[string]interface{}{\n\t\t\t\t\t\"type\": \"upstream_error\",\n\t\t\t\t\t\"upstream_error\": map[string]interface{}{\n\t\t\t\t\t\t\"message\": \"Insufficient credits\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twantFailover: true,\n\t\t\twantQuota:    true,\n\t\t},\n\t\t{\n\t\t\tname: \"detail field - auth error\",\n\t\t\tbody: map[string]interface{}{\n\t\t\t\t\"error\": map[string]interface{}{\n\t\t\t\t\t\"type\":   \"error\",\n\t\t\t\t\t\"detail\": \"Token expired, please refresh\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantFailover: true,\n\t\t\twantQuota:    false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tbodyBytes, _ := json.Marshal(tt.body)\n\t\t\tgotFailover, gotQuota := classifyByErrorMessage(bodyBytes, \"Messages\")\n\t\t\tif gotFailover != tt.wantFailover {\n\t\t\t\tt.Errorf(\"classifyByErrorMessage() failover = %v, want %v\", gotFailover, tt.wantFailover)\n\t\t\t}\n\t\t\tif gotQuota != tt.wantQuota {\n\t\t\t\tt.Errorf(\"classifyByErrorMessage() quota = %v, want %v\", gotQuota, tt.wantQuota)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestClassifyByErrorMessage_InvalidJSON 测试无效 JSON 的处理\nfunc TestClassifyByErrorMessage_InvalidJSON(t *testing.T) {\n\tinvalidBodies := [][]byte{\n\t\t[]byte(\"not json\"),\n\t\t[]byte(\"{invalid}\"),\n\t\t[]byte(\"\"),\n\t\tnil,\n\t}\n\n\tfor _, body := range invalidBodies {\n\t\tgotFailover, gotQuota := classifyByErrorMessage(body, \"Messages\")\n\t\tif gotFailover || gotQuota {\n\t\t\tt.Errorf(\"classifyByErrorMessage(%q) should return (false, false) for invalid JSON\", string(body))\n\t\t}\n\t}\n}\n\n// TestShouldRetryWithNextKey_403WithPredeductQuotaError 测试 403 + 预扣费额度失败的场景\n// 这是生产环境实际发生的错误格式\nfunc TestShouldRetryWithNextKey_403WithPredeductQuotaError(t *testing.T) {\n\t// 使用生产环境的精确 JSON 格式\n\tbody := []byte(`{\"error\":{\"type\":\"new_api_error\",\"message\":\"预扣费额度失败, 用户剩余额度: ¥0.053950, 需要预扣费额度: ¥0.191160, 下次重置时间: 2025-01-01 00:00:00\"},\"type\":\"error\"}`)\n\n\tgotFailover, gotQuota := ShouldRetryWithNextKey(403, body, false, \"Messages\")\n\n\tif !gotFailover {\n\t\tt.Errorf(\"ShouldRetryWithNextKey(403, prededuct_error, false) failover = %v, want true\", gotFailover)\n\t}\n\tif !gotQuota {\n\t\tt.Errorf(\"ShouldRetryWithNextKey(403, prededuct_error, false) quota = %v, want true\", gotQuota)\n\t}\n}\n\n// TestClassifyMessage_ChineseQuotaKeywords 测试中文额度关键词\nfunc TestClassifyMessage_ChineseQuotaKeywords(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tmessage      string\n\t\twantFailover bool\n\t\twantQuota    bool\n\t}{\n\t\t{\"预扣费额度失败\", \"预扣费额度失败, 用户剩余额度: ¥0.053950\", true, true},\n\t\t{\"额度不足\", \"账户额度不足\", true, true},\n\t\t{\"预扣费失败\", \"预扣费失败，请充值\", true, true},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgotFailover, gotQuota := classifyMessage(tt.message)\n\t\t\tif gotFailover != tt.wantFailover {\n\t\t\t\tt.Errorf(\"classifyMessage(%q) failover = %v, want %v\", tt.message, gotFailover, tt.wantFailover)\n\t\t\t}\n\t\t\tif gotQuota != tt.wantQuota {\n\t\t\t\tt.Errorf(\"classifyMessage(%q) quota = %v, want %v\", tt.message, gotQuota, tt.wantQuota)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestShouldRetryWithNextKey 测试完整的重试判断逻辑\nfunc TestShouldRetryWithNextKey(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tstatusCode   int\n\t\tbody         map[string]interface{}\n\t\twantFailover bool\n\t\twantQuota    bool\n\t}{\n\t\t// 403 + 中文配额相关消息\n\t\t{\n\t\t\tname:       \"403 with chinese quota message\",\n\t\t\tstatusCode: 403,\n\t\t\tbody: map[string]interface{}{\n\t\t\t\t\"error\": map[string]interface{}{\n\t\t\t\t\t\"type\":    \"new_api_error\",\n\t\t\t\t\t\"message\": \"预扣费额度失败, 用户剩余额度: ¥0.053950\",\n\t\t\t\t},\n\t\t\t\t\"type\": \"error\",\n\t\t\t},\n\t\t\twantFailover: true,\n\t\t\twantQuota:    true,\n\t\t},\n\t\t// 状态码优先\n\t\t{\n\t\t\tname:         \"401 always failover\",\n\t\t\tstatusCode:   401,\n\t\t\tbody:         map[string]interface{}{},\n\t\t\twantFailover: true,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t{\n\t\t\tname:         \"402 always failover with quota\",\n\t\t\tstatusCode:   402,\n\t\t\tbody:         map[string]interface{}{},\n\t\t\twantFailover: true,\n\t\t\twantQuota:    true,\n\t\t},\n\t\t{\n\t\t\tname:         \"408 always failover\",\n\t\t\tstatusCode:   408,\n\t\t\tbody:         map[string]interface{}{},\n\t\t\twantFailover: true,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t{\n\t\t\tname:         \"500 always failover\",\n\t\t\tstatusCode:   500,\n\t\t\tbody:         map[string]interface{}{},\n\t\t\twantFailover: true,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t// 400 需要检查消息体\n\t\t{\n\t\t\tname:       \"400 with quota message\",\n\t\t\tstatusCode: 400,\n\t\t\tbody: map[string]interface{}{\n\t\t\t\t\"error\": map[string]interface{}{\n\t\t\t\t\t\"message\": \"Quota exceeded\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantFailover: true,\n\t\t\twantQuota:    true,\n\t\t},\n\t\t{\n\t\t\tname:       \"400 with auth message\",\n\t\t\tstatusCode: 400,\n\t\t\tbody: map[string]interface{}{\n\t\t\t\t\"error\": map[string]interface{}{\n\t\t\t\t\t\"message\": \"Invalid API key\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantFailover: true,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t{\n\t\t\tname:       \"400 without failover keywords\",\n\t\t\tstatusCode: 400,\n\t\t\tbody: map[string]interface{}{\n\t\t\t\t\"error\": map[string]interface{}{\n\t\t\t\t\t\"message\": \"Bad request\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantFailover: false,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t// 404 不应 failover\n\t\t{\n\t\t\tname:         \"404 never failover\",\n\t\t\tstatusCode:   404,\n\t\t\tbody:         map[string]interface{}{},\n\t\t\twantFailover: false,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t// 200 不应 failover\n\t\t{\n\t\t\tname:         \"200 never failover\",\n\t\t\tstatusCode:   200,\n\t\t\tbody:         map[string]interface{}{},\n\t\t\twantFailover: false,\n\t\t\twantQuota:    false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tbodyBytes, _ := json.Marshal(tt.body)\n\t\t\t// 测试非 Fuzzy 模式（精确错误分类）\n\t\t\tgotFailover, gotQuota := ShouldRetryWithNextKey(tt.statusCode, bodyBytes, false, \"Messages\")\n\t\t\tif gotFailover != tt.wantFailover {\n\t\t\t\tt.Errorf(\"shouldRetryWithNextKey(%d, ..., false) failover = %v, want %v\", tt.statusCode, gotFailover, tt.wantFailover)\n\t\t\t}\n\t\t\tif gotQuota != tt.wantQuota {\n\t\t\t\tt.Errorf(\"shouldRetryWithNextKey(%d, ..., false) quota = %v, want %v\", tt.statusCode, gotQuota, tt.wantQuota)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestShouldRetryWithNextKeyFuzzyMode 测试 Fuzzy 模式下的错误分类\n// Fuzzy 模式：所有非 2xx 错误都触发 failover\nfunc TestShouldRetryWithNextKeyFuzzyMode(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tstatusCode   int\n\t\twantFailover bool\n\t\twantQuota    bool\n\t}{\n\t\t// 2xx 成功响应不 failover\n\t\t{\n\t\t\tname:         \"200 OK - no failover\",\n\t\t\tstatusCode:   200,\n\t\t\twantFailover: false,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t{\n\t\t\tname:         \"201 Created - no failover\",\n\t\t\tstatusCode:   201,\n\t\t\twantFailover: false,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t// 3xx 重定向在 Fuzzy 模式下触发 failover\n\t\t{\n\t\t\tname:         \"301 Redirect - failover in fuzzy mode\",\n\t\t\tstatusCode:   301,\n\t\t\twantFailover: true,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t{\n\t\t\tname:         \"302 Found - failover in fuzzy mode\",\n\t\t\tstatusCode:   302,\n\t\t\twantFailover: true,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t// 4xx 客户端错误在 Fuzzy 模式下都触发 failover\n\t\t{\n\t\t\tname:         \"400 Bad Request - failover in fuzzy mode\",\n\t\t\tstatusCode:   400,\n\t\t\twantFailover: true,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t{\n\t\t\tname:         \"401 Unauthorized - failover in fuzzy mode\",\n\t\t\tstatusCode:   401,\n\t\t\twantFailover: true,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t{\n\t\t\tname:         \"402 Payment Required - failover with quota\",\n\t\t\tstatusCode:   402,\n\t\t\twantFailover: true,\n\t\t\twantQuota:    true, // 配额相关\n\t\t},\n\t\t{\n\t\t\tname:         \"403 Forbidden - failover in fuzzy mode\",\n\t\t\tstatusCode:   403,\n\t\t\twantFailover: true,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t{\n\t\t\tname:         \"404 Not Found - failover in fuzzy mode\",\n\t\t\tstatusCode:   404,\n\t\t\twantFailover: true,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t{\n\t\t\tname:         \"422 Unprocessable Entity - failover in fuzzy mode\",\n\t\t\tstatusCode:   422,\n\t\t\twantFailover: true,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t{\n\t\t\tname:         \"429 Too Many Requests - failover with quota\",\n\t\t\tstatusCode:   429,\n\t\t\twantFailover: true,\n\t\t\twantQuota:    true, // 配额相关\n\t\t},\n\t\t// 5xx 服务端错误在 Fuzzy 模式下触发 failover\n\t\t{\n\t\t\tname:         \"500 Internal Server Error - failover in fuzzy mode\",\n\t\t\tstatusCode:   500,\n\t\t\twantFailover: true,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t{\n\t\t\tname:         \"502 Bad Gateway - failover in fuzzy mode\",\n\t\t\tstatusCode:   502,\n\t\t\twantFailover: true,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t{\n\t\t\tname:         \"503 Service Unavailable - failover in fuzzy mode\",\n\t\t\tstatusCode:   503,\n\t\t\twantFailover: true,\n\t\t\twantQuota:    false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// 测试 Fuzzy 模式（所有非 2xx 都 failover）\n\t\t\tgotFailover, gotQuota := ShouldRetryWithNextKey(tt.statusCode, nil, true, \"Messages\")\n\t\t\tif gotFailover != tt.wantFailover {\n\t\t\t\tt.Errorf(\"shouldRetryWithNextKey(%d, nil, true) failover = %v, want %v\", tt.statusCode, gotFailover, tt.wantFailover)\n\t\t\t}\n\t\t\tif gotQuota != tt.wantQuota {\n\t\t\t\tt.Errorf(\"shouldRetryWithNextKey(%d, nil, true) quota = %v, want %v\", tt.statusCode, gotQuota, tt.wantQuota)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestShouldRetryWithNextKey_FuzzyMode_403WithQuotaMessage 测试 Fuzzy 模式下 403 + 预扣费消息\n// 验证修复：Fuzzy 模式下也会检查消息体中的配额相关关键词\nfunc TestShouldRetryWithNextKey_FuzzyMode_403WithQuotaMessage(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tstatusCode   int\n\t\tbody         []byte\n\t\twantFailover bool\n\t\twantQuota    bool\n\t}{\n\t\t{\n\t\t\tname:         \"403 with prededuct quota error in fuzzy mode\",\n\t\t\tstatusCode:   403,\n\t\t\tbody:         []byte(`{\"error\":{\"type\":\"new_api_error\",\"message\":\"预扣费额度失败, 用户剩余额度: ¥0.053950, 需要预扣费额度: ¥0.191160\"},\"type\":\"error\"}`),\n\t\t\twantFailover: true,\n\t\t\twantQuota:    true,\n\t\t},\n\t\t{\n\t\t\tname:         \"403 with insufficient balance in fuzzy mode\",\n\t\t\tstatusCode:   403,\n\t\t\tbody:         []byte(`{\"error\":{\"message\":\"余额不足，请充值\"}}`),\n\t\t\twantFailover: true,\n\t\t\twantQuota:    true,\n\t\t},\n\t\t{\n\t\t\tname:         \"403 without quota keywords in fuzzy mode\",\n\t\t\tstatusCode:   403,\n\t\t\tbody:         []byte(`{\"error\":{\"message\":\"Access denied\"}}`),\n\t\t\twantFailover: true,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t{\n\t\t\tname:         \"403 with empty body in fuzzy mode\",\n\t\t\tstatusCode:   403,\n\t\t\tbody:         nil,\n\t\t\twantFailover: true,\n\t\t\twantQuota:    false,\n\t\t},\n\t\t{\n\t\t\tname:         \"500 with quota message in fuzzy mode\",\n\t\t\tstatusCode:   500,\n\t\t\tbody:         []byte(`{\"error\":{\"message\":\"Quota exceeded\"}}`),\n\t\t\twantFailover: true,\n\t\t\twantQuota:    true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgotFailover, gotQuota := ShouldRetryWithNextKey(tt.statusCode, tt.body, true, \"Messages\")\n\t\t\tif gotFailover != tt.wantFailover {\n\t\t\t\tt.Errorf(\"ShouldRetryWithNextKey(%d, body, true) failover = %v, want %v\", tt.statusCode, gotFailover, tt.wantFailover)\n\t\t\t}\n\t\t\tif gotQuota != tt.wantQuota {\n\t\t\t\tt.Errorf(\"ShouldRetryWithNextKey(%d, body, true) quota = %v, want %v\", tt.statusCode, gotQuota, tt.wantQuota)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestIsNonRetryableErrorCode 测试不可重试错误码判断\nfunc TestIsNonRetryableErrorCode(t *testing.T) {\n\ttests := []struct {\n\t\tcode string\n\t\twant bool\n\t}{\n\t\t// 内容审核相关 - 不应重试\n\t\t{\"sensitive_words_detected\", true},\n\t\t{\"content_policy_violation\", true},\n\t\t{\"content_filter\", true},\n\t\t{\"content_blocked\", true},\n\t\t{\"moderation_blocked\", true},\n\t\t// 请求内容无效 - 不应重试\n\t\t{\"invalid_request\", true},\n\t\t{\"invalid_request_error\", true},\n\t\t{\"bad_request\", true},\n\t\t// 大小写不敏感\n\t\t{\"SENSITIVE_WORDS_DETECTED\", true},\n\t\t{\"Content_Policy_Violation\", true},\n\t\t// 其他错误码 - 应该重试\n\t\t{\"server_error\", false},\n\t\t{\"rate_limit\", false},\n\t\t{\"authentication_error\", false},\n\t\t{\"unknown_error\", false},\n\t\t{\"\", false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tname := tt.code\n\t\tif name == \"\" {\n\t\t\tname = \"empty\"\n\t\t}\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\tgot := isNonRetryableErrorCode(tt.code)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"isNonRetryableErrorCode(%q) = %v, want %v\", tt.code, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestShouldRetryWithNextKey_SensitiveWordsDetected 测试敏感词检测错误不应重试\n// 这是修复的核心场景：500 + sensitive_words_detected 不应触发无限重试\nfunc TestShouldRetryWithNextKey_SensitiveWordsDetected(t *testing.T) {\n\t// 模拟生产环境的敏感词检测错误\n\tbody := []byte(`{\"error\":{\"message\":\"sensitive words detected\",\"type\":\"new_api_error\",\"param\":\"\",\"code\":\"sensitive_words_detected\"}}`)\n\n\ttests := []struct {\n\t\tname         string\n\t\tstatusCode   int\n\t\tfuzzyMode    bool\n\t\twantFailover bool\n\t\twantQuota    bool\n\t}{\n\t\t{\n\t\t\tname:         \"500 with sensitive_words_detected - normal mode\",\n\t\t\tstatusCode:   500,\n\t\t\tfuzzyMode:    false,\n\t\t\twantFailover: false, // 不应重试\n\t\t\twantQuota:    false,\n\t\t},\n\t\t{\n\t\t\tname:         \"500 with sensitive_words_detected - fuzzy mode\",\n\t\t\tstatusCode:   500,\n\t\t\tfuzzyMode:    true,\n\t\t\twantFailover: false, // 即使在 fuzzy 模式下也不应重试\n\t\t\twantQuota:    false,\n\t\t},\n\t\t{\n\t\t\tname:         \"400 with sensitive_words_detected - normal mode\",\n\t\t\tstatusCode:   400,\n\t\t\tfuzzyMode:    false,\n\t\t\twantFailover: false,\n\t\t\twantQuota:    false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgotFailover, gotQuota := ShouldRetryWithNextKey(tt.statusCode, body, tt.fuzzyMode, \"Messages\")\n\t\t\tif gotFailover != tt.wantFailover {\n\t\t\t\tt.Errorf(\"ShouldRetryWithNextKey(%d, sensitive_words_body, %v) failover = %v, want %v\",\n\t\t\t\t\ttt.statusCode, tt.fuzzyMode, gotFailover, tt.wantFailover)\n\t\t\t}\n\t\t\tif gotQuota != tt.wantQuota {\n\t\t\t\tt.Errorf(\"ShouldRetryWithNextKey(%d, sensitive_words_body, %v) quota = %v, want %v\",\n\t\t\t\t\ttt.statusCode, tt.fuzzyMode, gotQuota, tt.wantQuota)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/common/multi_channel_failover.go",
    "content": "package common\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/scheduler\"\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// MultiChannelAttemptResult 描述一次“选中渠道”的尝试结果（用于多渠道 failover 外壳复用）。\ntype MultiChannelAttemptResult struct {\n\tHandled           bool\n\tAttempted         bool\n\tSuccessKey        string\n\tSuccessBaseURLIdx int\n\tFailoverError     *FailoverError\n\tUsage             *types.Usage\n\tLastError         error\n}\n\n// TrySelectedChannelFunc 尝试一次选中的渠道，返回该渠道的尝试结果。\ntype TrySelectedChannelFunc func(selection *scheduler.SelectionResult) MultiChannelAttemptResult\n\n// OnMultiChannelHandledFunc 在请求被“处理完成”时回调（成功或非 failover 错误都会触发）。\ntype OnMultiChannelHandledFunc func(selection *scheduler.SelectionResult, result MultiChannelAttemptResult)\n\n// HandleAllFailedFunc 处理“所有渠道都失败”的返回逻辑（不同入口可能有不同错误格式）。\ntype HandleAllFailedFunc func(c *gin.Context, failoverErr *FailoverError, lastError error)\n\n// HandleMultiChannelFailover 处理多渠道 failover 外壳逻辑（选渠道 + 聚合错误 + Trace 亲和）。\n// 具体“渠道内 Key/BaseURL 轮转”由 trySelectedChannel 实现（通常调用 TryUpstreamWithAllKeys）。\nfunc HandleMultiChannelFailover(\n\tc *gin.Context,\n\tenvCfg *config.EnvConfig,\n\tchannelScheduler *scheduler.ChannelScheduler,\n\tkind scheduler.ChannelKind,\n\tapiType string,\n\tuserID string,\n\ttrySelectedChannel TrySelectedChannelFunc,\n\tonHandled OnMultiChannelHandledFunc,\n\thandleAllFailed HandleAllFailedFunc,\n) {\n\tif c == nil || envCfg == nil || channelScheduler == nil || trySelectedChannel == nil {\n\t\treturn\n\t}\n\tif handleAllFailed == nil {\n\t\thandleAllFailed = func(c *gin.Context, failoverErr *FailoverError, lastError error) {\n\t\t\tHandleAllChannelsFailed(c, false, failoverErr, lastError, apiType)\n\t\t}\n\t}\n\n\tfailedChannels := make(map[int]bool)\n\tvar lastError error\n\tvar lastFailoverError *FailoverError\n\n\tmaxChannelAttempts := channelScheduler.GetActiveChannelCount(kind)\n\n\tfor channelAttempt := 0; channelAttempt < maxChannelAttempts; channelAttempt++ {\n\t\t// 检查客户端是否已断开连接\n\t\tselect {\n\t\tcase <-c.Request.Context().Done():\n\t\t\tif envCfg.ShouldLog(\"info\") {\n\t\t\t\tlog.Printf(\"[%s-Cancel] 请求已取消，停止渠道 failover\", apiType)\n\t\t\t}\n\t\t\treturn\n\t\tdefault:\n\t\t\t// 继续正常流程\n\t\t}\n\n\t\tselection, err := channelScheduler.SelectChannel(c.Request.Context(), userID, failedChannels, kind)\n\t\tif err != nil {\n\t\t\tlastError = err\n\t\t\tbreak\n\t\t}\n\n\t\tupstream := selection.Upstream\n\t\tchannelIndex := selection.ChannelIndex\n\n\t\tif envCfg.ShouldLog(\"info\") && upstream != nil {\n\t\t\tlog.Printf(\"[%s-Select] 选择渠道: [%d] %s (原因: %s, 尝试 %d/%d)\",\n\t\t\t\tapiType, channelIndex, upstream.Name, selection.Reason, channelAttempt+1, maxChannelAttempts)\n\t\t}\n\n\t\tresult := trySelectedChannel(selection)\n\t\tif result.Handled {\n\t\t\tif onHandled != nil {\n\t\t\t\tonHandled(selection, result)\n\t\t\t}\n\t\t\t// 只有真正成功的请求才设置 Trace 亲和（客户端取消时 SuccessKey 为空）\n\t\t\tif result.SuccessKey != \"\" {\n\t\t\t\tchannelScheduler.SetTraceAffinity(userID, channelIndex)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tfailedChannels[channelIndex] = true\n\n\t\tif result.FailoverError != nil {\n\t\t\tlastFailoverError = result.FailoverError\n\t\t\tif upstream != nil {\n\t\t\t\tlastError = fmt.Errorf(\"渠道 [%d] %s 失败\", channelIndex, upstream.Name)\n\t\t\t} else {\n\t\t\t\tlastError = fmt.Errorf(\"渠道 [%d] 失败\", channelIndex)\n\t\t\t}\n\t\t}\n\n\t\tif result.Attempted && upstream != nil {\n\t\t\tlog.Printf(\"[%s-Failover] 警告: 渠道 [%d] %s 所有密钥都失败，尝试下一个渠道\", apiType, channelIndex, upstream.Name)\n\t\t}\n\t}\n\n\tlog.Printf(\"[%s-Error] 所有渠道都失败了\", apiType)\n\thandleAllFailed(c, lastFailoverError, lastError)\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/common/request.go",
    "content": "// Package common 提供 handlers 模块的公共功能\npackage common\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/httpclient\"\n\t\"github.com/BenedictKing/claude-proxy/internal/metrics\"\n\t\"github.com/BenedictKing/claude-proxy/internal/utils\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// ReadRequestBody 读取并验证请求体大小\n// 返回: (bodyBytes, error)\n// 如果请求体过大，会自动返回 413 错误并排空剩余数据\nfunc ReadRequestBody(c *gin.Context, maxBodySize int64) ([]byte, error) {\n\tlimitedReader := io.LimitReader(c.Request.Body, maxBodySize+1)\n\tbodyBytes, err := io.ReadAll(limitedReader)\n\tif err != nil {\n\t\tc.JSON(400, gin.H{\"error\": \"Failed to read request body\"})\n\t\treturn nil, err\n\t}\n\n\tif int64(len(bodyBytes)) > maxBodySize {\n\t\t// 排空剩余请求体，避免 keep-alive 连接污染\n\t\tio.Copy(io.Discard, c.Request.Body)\n\t\tc.JSON(413, gin.H{\"error\": fmt.Sprintf(\"Request body too large, maximum size is %d MB\", maxBodySize/1024/1024)})\n\t\treturn nil, fmt.Errorf(\"request body too large\")\n\t}\n\n\t// 恢复请求体供后续使用\n\tc.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))\n\treturn bodyBytes, nil\n}\n\n// RestoreRequestBody 恢复请求体供后续使用\nfunc RestoreRequestBody(c *gin.Context, bodyBytes []byte) {\n\tc.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))\n}\n\n// SendRequest 发送 HTTP 请求到上游\n// isStream: 是否为流式请求（流式请求使用无超时客户端）\n// apiType: 接口类型（Messages/Responses/Gemini），用于日志标签前缀\nfunc SendRequest(req *http.Request, upstream *config.UpstreamConfig, envCfg *config.EnvConfig, isStream bool, apiType string) (*http.Response, error) {\n\tclientManager := httpclient.GetManager()\n\n\tvar client *http.Client\n\tif isStream {\n\t\tclient = clientManager.GetStreamClient(upstream.InsecureSkipVerify)\n\t} else {\n\t\ttimeout := time.Duration(envCfg.RequestTimeout) * time.Millisecond\n\t\tclient = clientManager.GetStandardClient(timeout, upstream.InsecureSkipVerify)\n\t}\n\n\tif upstream.InsecureSkipVerify && envCfg.EnableRequestLogs {\n\t\tlog.Printf(\"[%s-Request-TLS] 警告: 正在跳过对 %s 的TLS证书验证\", apiType, req.URL.String())\n\t}\n\n\tif envCfg.EnableRequestLogs {\n\t\tlog.Printf(\"[%s-Request-URL] 实际请求URL: %s\", apiType, req.URL.String())\n\t\tlog.Printf(\"[%s-Request-Method] 请求方法: %s\", apiType, req.Method)\n\t\tif envCfg.IsDevelopment() {\n\t\t\tlogRequestDetails(req, envCfg, apiType)\n\t\t}\n\t}\n\n\treturn client.Do(req)\n}\n\n// logRequestDetails 记录请求详情（仅开发模式）\n// apiType: 接口类型（Messages/Responses/Gemini），用于日志标签前缀\nfunc logRequestDetails(req *http.Request, envCfg *config.EnvConfig, apiType string) {\n\t// 对请求头做敏感信息脱敏\n\treqHeaders := make(map[string]string)\n\tfor key, values := range req.Header {\n\t\tif len(values) > 0 {\n\t\t\treqHeaders[key] = values[0]\n\t\t}\n\t}\n\tmaskedReqHeaders := utils.MaskSensitiveHeaders(reqHeaders)\n\tvar reqHeadersJSON []byte\n\tif envCfg.RawLogOutput {\n\t\treqHeadersJSON, _ = json.Marshal(maskedReqHeaders)\n\t} else {\n\t\treqHeadersJSON, _ = json.MarshalIndent(maskedReqHeaders, \"\", \"  \")\n\t}\n\tlog.Printf(\"[%s-Request-Headers] 实际请求头:\\n%s\", apiType, string(reqHeadersJSON))\n\n\tif req.Body != nil {\n\t\tbodyBytes, err := io.ReadAll(req.Body)\n\t\tif err == nil {\n\t\t\treq.Body = io.NopCloser(bytes.NewReader(bodyBytes))\n\t\t\tvar formattedBody string\n\t\t\tif envCfg.RawLogOutput {\n\t\t\t\tformattedBody = utils.FormatJSONBytesRaw(bodyBytes)\n\t\t\t} else {\n\t\t\t\tformattedBody = utils.FormatJSONBytesForLog(bodyBytes, 500)\n\t\t\t}\n\t\t\tlog.Printf(\"[%s-Request-Body] 实际请求体:\\n%s\", apiType, formattedBody)\n\t\t}\n\t}\n}\n\n// LogOriginalRequest 记录原始请求信息\nfunc LogOriginalRequest(c *gin.Context, bodyBytes []byte, envCfg *config.EnvConfig, apiType string) {\n\tif !envCfg.EnableRequestLogs {\n\t\treturn\n\t}\n\n\tlog.Printf(\"[Request-Receive] 收到%s请求: %s %s\", apiType, c.Request.Method, c.Request.URL.Path)\n\n\tif envCfg.IsDevelopment() {\n\t\tvar formattedBody string\n\t\tif envCfg.RawLogOutput {\n\t\t\tformattedBody = utils.FormatJSONBytesRaw(bodyBytes)\n\t\t} else {\n\t\t\tformattedBody = utils.FormatJSONBytesForLog(bodyBytes, 500)\n\t\t}\n\t\tlog.Printf(\"[Request-OriginalBody] 原始请求体:\\n%s\", formattedBody)\n\n\t\tsanitizedHeaders := make(map[string]string)\n\t\tfor key, values := range c.Request.Header {\n\t\t\tif len(values) > 0 {\n\t\t\t\tsanitizedHeaders[key] = values[0]\n\t\t\t}\n\t\t}\n\t\tmaskedHeaders := utils.MaskSensitiveHeaders(sanitizedHeaders)\n\t\tvar headersJSON []byte\n\t\tif envCfg.RawLogOutput {\n\t\t\theadersJSON, _ = json.Marshal(maskedHeaders)\n\t\t} else {\n\t\t\theadersJSON, _ = json.MarshalIndent(maskedHeaders, \"\", \"  \")\n\t\t}\n\t\tlog.Printf(\"[Request-OriginalHeaders] 原始请求头:\\n%s\", string(headersJSON))\n\t}\n}\n\n// AreAllKeysSuspended 检查渠道的所有 Key 是否都处于熔断状态\n// 用于判断是否需要启用强制探测模式\nfunc AreAllKeysSuspended(metricsManager *metrics.MetricsManager, baseURL string, apiKeys []string) bool {\n\tif len(apiKeys) == 0 {\n\t\treturn false\n\t}\n\n\tfor _, apiKey := range apiKeys {\n\t\tif !metricsManager.ShouldSuspendKey(baseURL, apiKey) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// RemoveEmptySignatures 移除请求体中 messages[*].content[*].signature 的空值\n// 用于预防 Claude API 返回 400 错误\n// 仅处理已知路径：messages 数组中各消息的 content 数组中的 signature 字段\n// enableLog: 是否输出日志（由 envCfg.EnableRequestLogs 控制）\n// apiType: 接口类型（Messages/Responses/Gemini），用于日志标签前缀\nfunc RemoveEmptySignatures(bodyBytes []byte, enableLog bool, apiType string) ([]byte, bool) {\n\tdecoder := json.NewDecoder(bytes.NewReader(bodyBytes))\n\tdecoder.UseNumber() // 保留数字精度\n\n\tvar data map[string]interface{}\n\tif err := decoder.Decode(&data); err != nil {\n\t\treturn bodyBytes, false\n\t}\n\n\tmodified, removedCount := removeEmptySignaturesInMessages(data)\n\tif !modified {\n\t\treturn bodyBytes, false\n\t}\n\n\tif enableLog && removedCount > 0 {\n\t\tlog.Printf(\"[%s-Preprocess] 已移除 %d 个空 signature 字段\", apiType, removedCount)\n\t}\n\n\t// 使用 Encoder 并禁用 HTML 转义，保持原始格式\n\tnewBytes, err := utils.MarshalJSONNoEscape(data)\n\tif err != nil {\n\t\treturn bodyBytes, false\n\t}\n\treturn newBytes, true\n}\n\n// removeEmptySignaturesInMessages 仅处理 messages[*].content[*].signature 路径\n// 返回 (是否有修改, 移除的字段数)\nfunc removeEmptySignaturesInMessages(data map[string]interface{}) (bool, int) {\n\tmodified := false\n\tremovedCount := 0\n\n\tmessages, ok := data[\"messages\"].([]interface{})\n\tif !ok {\n\t\treturn false, 0\n\t}\n\n\tfor _, msg := range messages {\n\t\tmsgMap, ok := msg.(map[string]interface{})\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcontent, ok := msgMap[\"content\"].([]interface{})\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tfor _, block := range content {\n\t\t\tblockMap, ok := block.(map[string]interface{})\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif sig, exists := blockMap[\"signature\"]; exists {\n\t\t\t\tif sig == nil {\n\t\t\t\t\tdelete(blockMap, \"signature\")\n\t\t\t\t\tmodified = true\n\t\t\t\t\tremovedCount++\n\t\t\t\t} else if str, isStr := sig.(string); isStr && str == \"\" {\n\t\t\t\t\tdelete(blockMap, \"signature\")\n\t\t\t\t\tmodified = true\n\t\t\t\t\tremovedCount++\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn modified, removedCount\n}\n\n// ExtractUserID 从请求体中提取 user_id（用于 Messages API）\nfunc ExtractUserID(bodyBytes []byte) string {\n\tvar req struct {\n\t\tMetadata struct {\n\t\t\tUserID string `json:\"user_id\"`\n\t\t} `json:\"metadata\"`\n\t}\n\tif err := json.Unmarshal(bodyBytes, &req); err == nil {\n\t\treturn req.Metadata.UserID\n\t}\n\treturn \"\"\n}\n\n// ExtractConversationID 从请求中提取对话标识（用于 Responses API）\n// 优先级: Conversation_id Header > Session_id Header > X-Gemini-Api-Privileged-User-Id > prompt_cache_key > metadata.user_id\nfunc ExtractConversationID(c *gin.Context, bodyBytes []byte) string {\n\t// 1. HTTP Header: Conversation_id\n\tif convID := c.GetHeader(\"Conversation_id\"); convID != \"\" {\n\t\treturn convID\n\t}\n\n\t// 2. HTTP Header: Session_id\n\tif sessID := c.GetHeader(\"Session_id\"); sessID != \"\" {\n\t\treturn sessID\n\t}\n\n\t// 3. HTTP Header: X-Gemini-Api-Privileged-User-Id (Gemini 专用)\n\tif geminiUserID := c.GetHeader(\"X-Gemini-Api-Privileged-User-Id\"); geminiUserID != \"\" {\n\t\treturn geminiUserID\n\t}\n\n\t// 4. Request Body: prompt_cache_key 或 metadata.user_id\n\tvar req struct {\n\t\tPromptCacheKey string `json:\"prompt_cache_key\"`\n\t\tMetadata       struct {\n\t\t\tUserID string `json:\"user_id\"`\n\t\t} `json:\"metadata\"`\n\t}\n\tif err := json.Unmarshal(bodyBytes, &req); err == nil {\n\t\tif req.PromptCacheKey != \"\" {\n\t\t\treturn req.PromptCacheKey\n\t\t}\n\t\tif req.Metadata.UserID != \"\" {\n\t\t\treturn req.Metadata.UserID\n\t\t}\n\t}\n\n\treturn \"\"\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/common/stream.go",
    "content": "// Package common 提供 handlers 模块的公共功能\npackage common\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/providers\"\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n\t\"github.com/BenedictKing/claude-proxy/internal/utils\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/uuid\"\n)\n\n// StreamContext 流处理上下文\ntype StreamContext struct {\n\tLogBuffer        bytes.Buffer\n\tOutputTextBuffer bytes.Buffer\n\tSynthesizer      *utils.StreamSynthesizer\n\tLoggingEnabled   bool\n\tClientGone       bool\n\tHasUsage         bool\n\tNeedTokenPatch   bool\n\t// 累积的 token 统计\n\tCollectedUsage CollectedUsageData\n\t// 用于日志的\"续写前缀\"（不参与真实转发，只影响 Stream-Synth 输出可读性）\n\tLogPrefillText string\n\t// SSE 事件调试追踪\n\tEventCount        int            // 事件总数\n\tContentBlockCount int            // content block 计数\n\tContentBlockTypes map[int]string // 每个 block 的类型\n\t// 低质量渠道处理\n\tRequestModel string // 请求中的 model（用于一致性检查）\n\tLowQuality   bool   // 是否为低质量渠道\n\t// 隐式缓存推断\n\tMessageStartInputTokens int // message_start 事件中的 input_tokens（用于推断隐式缓存）\n}\n\n// CollectedUsageData 从流事件中收集的 usage 数据\ntype CollectedUsageData struct {\n\tInputTokens              int\n\tOutputTokens             int\n\tCacheCreationInputTokens int\n\tCacheReadInputTokens     int\n\t// 缓存 TTL 细分\n\tCacheCreation5mInputTokens int\n\tCacheCreation1hInputTokens int\n\tCacheTTL                   string // \"5m\" | \"1h\" | \"mixed\"\n}\n\n// NewStreamContext 创建流处理上下文\nfunc NewStreamContext(envCfg *config.EnvConfig) *StreamContext {\n\tctx := &StreamContext{\n\t\tLoggingEnabled:    envCfg.IsDevelopment() && envCfg.EnableResponseLogs,\n\t\tContentBlockTypes: make(map[int]string),\n\t}\n\tif ctx.LoggingEnabled {\n\t\tctx.Synthesizer = utils.NewStreamSynthesizer(\"claude\")\n\t}\n\treturn ctx\n}\n\n// seedSynthesizerFromRequest 将请求里预置的 assistant 文本拼接进合成器（仅用于日志可读性）\n//\n// Claude Code 的部分内部调用会在 messages 里预置一条 assistant 内容（例如 \"{\"），让模型只输出“续写”部分。\n// 这会导致我们仅基于 SSE delta 合成的日志缺失开头。这里用请求体做一次轻量补齐。\nfunc seedSynthesizerFromRequest(ctx *StreamContext, requestBody []byte) {\n\tif ctx == nil || ctx.Synthesizer == nil || len(requestBody) == 0 {\n\t\treturn\n\t}\n\n\tvar req struct {\n\t\tMessages []struct {\n\t\t\tRole    string `json:\"role\"`\n\t\t\tContent []struct {\n\t\t\t\tType string `json:\"type\"`\n\t\t\t\tText string `json:\"text\"`\n\t\t\t} `json:\"content\"`\n\t\t} `json:\"messages\"`\n\t}\n\tif err := json.Unmarshal(requestBody, &req); err != nil {\n\t\treturn\n\t}\n\n\t// 只取最后一条 assistant，避免把历史上下文都拼进日志\n\tfor i := len(req.Messages) - 1; i >= 0; i-- {\n\t\tmsg := req.Messages[i]\n\t\tif msg.Role != \"assistant\" {\n\t\t\tcontinue\n\t\t}\n\t\tvar b strings.Builder\n\t\tfor _, c := range msg.Content {\n\t\t\tif c.Type == \"text\" && c.Text != \"\" {\n\t\t\t\tb.WriteString(c.Text)\n\t\t\t}\n\t\t}\n\t\tprefill := b.String()\n\t\t// 防止把很长的预置内容刷进日志\n\t\tif len(prefill) > 0 && len(prefill) <= 256 {\n\t\t\tctx.LogPrefillText = prefill\n\t\t}\n\t\treturn\n\t}\n}\n\n// SetupStreamHeaders 设置流式响应头\nfunc SetupStreamHeaders(c *gin.Context, resp *http.Response) {\n\tutils.ForwardResponseHeaders(resp.Header, c.Writer)\n\tc.Header(\"Content-Type\", \"text/event-stream\")\n\tc.Header(\"Cache-Control\", \"no-cache\")\n\tc.Header(\"Connection\", \"keep-alive\")\n\tc.Header(\"X-Accel-Buffering\", \"no\")\n\tc.Status(200)\n}\n\n// ProcessStreamEvents 处理流事件循环\n// 返回值: error 表示流处理过程中是否发生错误（用于调用方决定是否记录失败指标）\nfunc ProcessStreamEvents(\n\tc *gin.Context,\n\tw gin.ResponseWriter,\n\tflusher http.Flusher,\n\teventChan <-chan string,\n\terrChan <-chan error,\n\tctx *StreamContext,\n\tenvCfg *config.EnvConfig,\n\tstartTime time.Time,\n\trequestBody []byte,\n) (*types.Usage, error) {\n\tfor {\n\t\tselect {\n\t\tcase event, ok := <-eventChan:\n\t\t\tif !ok {\n\t\t\t\tusage := logStreamCompletion(ctx, envCfg, startTime)\n\t\t\t\treturn usage, nil\n\t\t\t}\n\t\t\tProcessStreamEvent(c, w, flusher, event, ctx, envCfg, requestBody)\n\n\t\tcase err, ok := <-errChan:\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"[Messages-Stream] 错误: 流式传输错误: %v\", err)\n\t\t\t\tlogPartialResponse(ctx, envCfg)\n\n\t\t\t\t// 向客户端发送错误事件（如果连接仍然有效）\n\t\t\t\tif !ctx.ClientGone {\n\t\t\t\t\terrorEvent := BuildStreamErrorEvent(err)\n\t\t\t\t\tw.Write([]byte(errorEvent))\n\t\t\t\t\tflusher.Flush()\n\t\t\t\t}\n\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n}\n\n// ProcessStreamEvent 处理单个流事件\nfunc ProcessStreamEvent(\n\tc *gin.Context,\n\tw gin.ResponseWriter,\n\tflusher http.Flusher,\n\tevent string,\n\tctx *StreamContext,\n\tenvCfg *config.EnvConfig,\n\trequestBody []byte,\n) {\n\t// SSE 事件调试日志\n\tctx.EventCount++\n\tif envCfg.SSEDebugLevel == \"full\" || envCfg.SSEDebugLevel == \"summary\" {\n\t\teventType, blockIndex, blockType := extractSSEEventInfo(event)\n\t\tif eventType == \"content_block_start\" {\n\t\t\tctx.ContentBlockCount++\n\t\t\tif blockType != \"\" {\n\t\t\t\tctx.ContentBlockTypes[blockIndex] = blockType\n\t\t\t}\n\t\t}\n\t\tif envCfg.SSEDebugLevel == \"full\" {\n\t\t\tlog.Printf(\"[Messages-Stream-Event] #%d 类型=%s 长度=%d block_index=%d block_type=%s\",\n\t\t\t\tctx.EventCount, eventType, len(event), blockIndex, blockType)\n\t\t\t// 对于 content_block 相关事件，记录详细内容\n\t\t\tif strings.Contains(event, \"content_block\") {\n\t\t\t\tlog.Printf(\"[Messages-Stream-Event] 详情: %s\", truncateForLog(event, 500))\n\t\t\t}\n\t\t}\n\t}\n\n\t// 提取文本用于估算 token\n\tExtractTextFromEvent(event, &ctx.OutputTextBuffer)\n\n\t// 检测并收集 usage\n\thasUsage, needInputPatch, needOutputPatch, usageData := CheckEventUsageStatus(event, envCfg.EnableResponseLogs && envCfg.ShouldLog(\"debug\"))\n\tneedPatch := needInputPatch || needOutputPatch\n\t// 保存原始 usageData 用于后续 PatchMessageStartInputTokensIfNeeded\n\toriginalUsageData := usageData\n\tif hasUsage {\n\t\tif !ctx.HasUsage {\n\t\t\tctx.HasUsage = true\n\t\t\tctx.NeedTokenPatch = needPatch || ctx.LowQuality\n\t\t\tif envCfg.EnableResponseLogs && envCfg.ShouldLog(\"debug\") && needPatch && !IsMessageDeltaEvent(event) {\n\t\t\t\tlog.Printf(\"[Messages-Stream-Token] 检测到虚假值, 延迟到流结束修补\")\n\t\t\t}\n\t\t}\n\t\t// 对于 message_start 事件，不累积 input_tokens 到 CollectedUsage\n\t\t// 因为 message_start 的 input_tokens 是请求总 token，而非最终计费值\n\t\t// CollectedUsage.InputTokens 应该只记录 message_delta 的最终计费值\n\t\tif IsMessageStartEvent(event) && usageData.InputTokens > 0 {\n\t\t\tusageData.InputTokens = 0\n\t\t}\n\t\t// 累积收集 usage 数据\n\t\tupdateCollectedUsage(&ctx.CollectedUsage, usageData)\n\t}\n\n\t// 日志缓存\n\tif ctx.LoggingEnabled {\n\t\tctx.LogBuffer.WriteString(event)\n\t\tif ctx.Synthesizer != nil {\n\t\t\tfor _, line := range strings.Split(event, \"\\n\") {\n\t\t\t\tctx.Synthesizer.ProcessLine(line)\n\t\t\t}\n\t\t}\n\t}\n\n\t// 在 message_stop 前注入 usage（上游完全没有 usage 的情况）\n\tif !ctx.HasUsage && !ctx.ClientGone && IsMessageStopEvent(event) {\n\t\tusageEvent := BuildUsageEvent(requestBody, ctx.OutputTextBuffer.String())\n\t\tif envCfg.EnableResponseLogs && envCfg.ShouldLog(\"debug\") {\n\t\t\tlog.Printf(\"[Messages-Stream-Token] 上游无usage, 注入本地估算事件\")\n\t\t}\n\t\tw.Write([]byte(usageEvent))\n\t\tflusher.Flush()\n\t\tctx.HasUsage = true\n\t}\n\n\t// 修补 token\n\teventToSend := event\n\n\t// 处理 message_start 事件：补全空 id 和检查 model 一致性（可选）\n\tif IsMessageStartEvent(event) && ctx.RequestModel != \"\" {\n\t\teventToSend = PatchMessageStartEvent(eventToSend, ctx.RequestModel, envCfg.RewriteResponseModel, envCfg.EnableResponseLogs && envCfg.ShouldLog(\"debug\"))\n\t}\n\n\t// 处理 message_start 事件：尽早补全 input_tokens（部分客户端只读取首个 usage 来累计）\n\t// 注意：使用 originalUsageData 而非被清零后的 usageData，避免误判\n\tif hasUsage {\n\t\teventToSend = PatchMessageStartInputTokensIfNeeded(eventToSend, requestBody, needInputPatch, originalUsageData, envCfg.EnableResponseLogs && envCfg.ShouldLog(\"debug\"), ctx.LowQuality)\n\t}\n\n\t// 记录 message_start 中的 input_tokens（用于后续推断隐式缓存）\n\t// 注意：必须在 PatchMessageStartInputTokensIfNeeded 之后执行，因为原始值可能是 0 被修补成估算值\n\tif IsMessageStartEvent(event) && ctx.MessageStartInputTokens == 0 {\n\t\tif patchedInputTokens := ExtractInputTokensFromEvent(eventToSend); patchedInputTokens > 0 {\n\t\t\tctx.MessageStartInputTokens = patchedInputTokens\n\t\t}\n\t}\n\n\tif ctx.NeedTokenPatch && HasEventWithUsage(event) {\n\t\tif IsMessageDeltaEvent(event) || IsMessageStopEvent(event) {\n\t\t\thasCacheTokens := ctx.CollectedUsage.CacheCreationInputTokens > 0 ||\n\t\t\t\tctx.CollectedUsage.CacheReadInputTokens > 0 ||\n\t\t\t\tctx.CollectedUsage.CacheCreation5mInputTokens > 0 ||\n\t\t\t\tctx.CollectedUsage.CacheCreation1hInputTokens > 0\n\n\t\t\t// 在转发前执行隐式缓存推断，确保下游能收到推断的 cache_read_input_tokens\n\t\t\tif !hasCacheTokens {\n\t\t\t\tinferImplicitCacheRead(ctx, envCfg.EnableResponseLogs && envCfg.ShouldLog(\"debug\"))\n\t\t\t\t// 重新检查是否有缓存 token（可能刚被推断出来）\n\t\t\t\thasCacheTokens = ctx.CollectedUsage.CacheReadInputTokens > 0\n\t\t\t}\n\n\t\t\t// 检测隐式缓存信号：message_start 的 input_tokens 远大于最终值\n\t\t\t// 这种情况下不应该用本地估算值覆盖，因为低 input_tokens 是缓存命中的正常结果\n\t\t\thasImplicitCacheSignal := ctx.MessageStartInputTokens > 0 &&\n\t\t\t\tctx.CollectedUsage.InputTokens > 0 &&\n\t\t\t\tctx.MessageStartInputTokens > ctx.CollectedUsage.InputTokens\n\n\t\t\tinputTokens := ctx.CollectedUsage.InputTokens\n\t\t\testimatedInputTokens := utils.EstimateRequestTokens(requestBody)\n\t\t\t// 仅在无缓存信号（显式或隐式）且 input_tokens 异常小时才用估算值修补\n\t\t\tif !hasCacheTokens && !hasImplicitCacheSignal && inputTokens < 10 && estimatedInputTokens > inputTokens {\n\t\t\t\tinputTokens = estimatedInputTokens\n\t\t\t}\n\n\t\t\toutputTokens := ctx.CollectedUsage.OutputTokens\n\t\t\testimatedOutputTokens := utils.EstimateTokens(ctx.OutputTextBuffer.String())\n\t\t\tif outputTokens <= 1 && estimatedOutputTokens > outputTokens {\n\t\t\t\toutputTokens = estimatedOutputTokens\n\t\t\t}\n\n\t\t\tif inputTokens > ctx.CollectedUsage.InputTokens {\n\t\t\t\tctx.CollectedUsage.InputTokens = inputTokens\n\t\t\t}\n\t\t\tif outputTokens > ctx.CollectedUsage.OutputTokens {\n\t\t\t\tctx.CollectedUsage.OutputTokens = outputTokens\n\t\t\t}\n\n\t\t\t// 修补事件，包括推断的 cache_read_input_tokens\n\t\t\teventToSend = PatchTokensInEventWithCache(eventToSend, inputTokens, outputTokens, ctx.CollectedUsage.CacheReadInputTokens, hasCacheTokens, envCfg.EnableResponseLogs && envCfg.ShouldLog(\"debug\"), ctx.LowQuality)\n\t\t\tctx.NeedTokenPatch = false\n\t\t}\n\t}\n\n\t// 转发给客户端\n\tif !ctx.ClientGone {\n\t\tif _, err := w.Write([]byte(eventToSend)); err != nil {\n\t\t\tctx.ClientGone = true\n\t\t\tif !IsClientDisconnectError(err) {\n\t\t\t\tlog.Printf(\"[Messages-Stream] 警告: 写入错误: %v\", err)\n\t\t\t} else if envCfg.ShouldLog(\"info\") {\n\t\t\t\tlog.Printf(\"[Messages-Stream] 客户端中断连接 (正常行为)，继续接收上游数据...\")\n\t\t\t}\n\t\t} else {\n\t\t\tflusher.Flush()\n\t\t}\n\t}\n}\n\n// updateCollectedUsage 更新收集的 usage 数据\nfunc updateCollectedUsage(collected *CollectedUsageData, usageData CollectedUsageData) {\n\tif usageData.InputTokens > collected.InputTokens {\n\t\tcollected.InputTokens = usageData.InputTokens\n\t}\n\tif usageData.OutputTokens > collected.OutputTokens {\n\t\tcollected.OutputTokens = usageData.OutputTokens\n\t}\n\tif usageData.CacheCreationInputTokens > 0 {\n\t\tcollected.CacheCreationInputTokens = usageData.CacheCreationInputTokens\n\t}\n\tif usageData.CacheReadInputTokens > 0 {\n\t\tcollected.CacheReadInputTokens = usageData.CacheReadInputTokens\n\t}\n\tif usageData.CacheCreation5mInputTokens > 0 {\n\t\tcollected.CacheCreation5mInputTokens = usageData.CacheCreation5mInputTokens\n\t}\n\tif usageData.CacheCreation1hInputTokens > 0 {\n\t\tcollected.CacheCreation1hInputTokens = usageData.CacheCreation1hInputTokens\n\t}\n\tif usageData.CacheTTL != \"\" {\n\t\tcollected.CacheTTL = usageData.CacheTTL\n\t}\n}\n\n// inferImplicitCacheRead 推断隐式缓存读取\n//\n// 当 message_start 中的 input_tokens 与 message_delta 中的最终 input_tokens 存在显著差异时，\n// 差额可能是上游 prompt caching 命中但未明确返回 cache_read_input_tokens 的情况。\n// 触发条件：差额 > 10% 或差额 > 10000 tokens，且上游未返回 cache_read_input_tokens。\nfunc inferImplicitCacheRead(ctx *StreamContext, enableLog bool) {\n\t// 前置条件检查\n\tif ctx.MessageStartInputTokens == 0 || ctx.CollectedUsage.InputTokens == 0 {\n\t\treturn\n\t}\n\n\t// 上游已明确返回 cache_read，无需推断\n\tif ctx.CollectedUsage.CacheReadInputTokens > 0 {\n\t\treturn\n\t}\n\n\t// 计算差额\n\tdiff := ctx.MessageStartInputTokens - ctx.CollectedUsage.InputTokens\n\tif diff <= 0 {\n\t\treturn\n\t}\n\n\t// 计算差额比例\n\tratio := float64(diff) / float64(ctx.MessageStartInputTokens)\n\n\t// 触发条件：差额 > 10% 或差额 > 10000 tokens\n\tif ratio > 0.10 || diff > 10000 {\n\t\tctx.CollectedUsage.CacheReadInputTokens = diff\n\t\tif enableLog {\n\t\t\tlog.Printf(\"[Messages-Stream-Token] 推断隐式缓存: message_start=%d, final=%d, cache_read=%d (%.1f%%)\",\n\t\t\t\tctx.MessageStartInputTokens, ctx.CollectedUsage.InputTokens, diff, ratio*100)\n\t\t}\n\t}\n}\n\n// logStreamCompletion 记录流完成日志\nfunc logStreamCompletion(ctx *StreamContext, envCfg *config.EnvConfig, startTime time.Time) *types.Usage {\n\tif envCfg.EnableResponseLogs {\n\t\tlog.Printf(\"[Messages-Stream] 流式响应完成: %dms\", time.Since(startTime).Milliseconds())\n\t}\n\n\t// SSE 事件统计日志\n\tif envCfg.SSEDebugLevel == \"full\" || envCfg.SSEDebugLevel == \"summary\" {\n\t\tblockTypeSummary := make(map[string]int)\n\t\tfor _, bt := range ctx.ContentBlockTypes {\n\t\t\tblockTypeSummary[bt]++\n\t\t}\n\t\tlog.Printf(\"[Messages-Stream-Summary] 总事件数=%d, content_blocks=%d, 类型分布=%v\",\n\t\t\tctx.EventCount, ctx.ContentBlockCount, blockTypeSummary)\n\t}\n\n\tif envCfg.IsDevelopment() {\n\t\tlogSynthesizedContent(ctx)\n\t}\n\n\t// 推断隐式缓存读取\n\tinferImplicitCacheRead(ctx, envCfg.EnableResponseLogs && envCfg.ShouldLog(\"debug\"))\n\n\t// 将累积的 usage 数据转换为 *types.Usage\n\tvar usage *types.Usage\n\thasUsageData := ctx.CollectedUsage.InputTokens > 0 ||\n\t\tctx.CollectedUsage.OutputTokens > 0 ||\n\t\tctx.CollectedUsage.CacheCreationInputTokens > 0 ||\n\t\tctx.CollectedUsage.CacheReadInputTokens > 0 ||\n\t\tctx.CollectedUsage.CacheCreation5mInputTokens > 0 ||\n\t\tctx.CollectedUsage.CacheCreation1hInputTokens > 0\n\tif hasUsageData {\n\t\tusage = &types.Usage{\n\t\t\tInputTokens:                ctx.CollectedUsage.InputTokens,\n\t\t\tOutputTokens:               ctx.CollectedUsage.OutputTokens,\n\t\t\tCacheCreationInputTokens:   ctx.CollectedUsage.CacheCreationInputTokens,\n\t\t\tCacheReadInputTokens:       ctx.CollectedUsage.CacheReadInputTokens,\n\t\t\tCacheCreation5mInputTokens: ctx.CollectedUsage.CacheCreation5mInputTokens,\n\t\t\tCacheCreation1hInputTokens: ctx.CollectedUsage.CacheCreation1hInputTokens,\n\t\t\tCacheTTL:                   ctx.CollectedUsage.CacheTTL,\n\t\t}\n\t}\n\treturn usage\n}\n\n// logPartialResponse 记录部分响应日志\nfunc logPartialResponse(ctx *StreamContext, envCfg *config.EnvConfig) {\n\tif envCfg.EnableResponseLogs && envCfg.IsDevelopment() {\n\t\tlogSynthesizedContent(ctx)\n\t}\n}\n\n// logSynthesizedContent 记录合成内容\nfunc logSynthesizedContent(ctx *StreamContext) {\n\tif ctx.Synthesizer != nil {\n\t\tcontent := ctx.Synthesizer.GetSynthesizedContent()\n\t\tif content != \"\" && !ctx.Synthesizer.IsParseFailed() {\n\t\t\ttrimmed := strings.TrimSpace(content)\n\n\t\t\t// 仅在“明显是 JSON 续写”的情况下拼接预置前缀，避免出现 \"{OK\" 这类误导日志\n\t\t\tif ctx.LogPrefillText == \"{\" && !strings.HasPrefix(strings.TrimLeft(trimmed, \" \\t\\r\\n\"), \"{\") {\n\t\t\t\tleft := strings.TrimLeft(trimmed, \" \\t\\r\\n\")\n\t\t\t\tif strings.HasPrefix(left, \"\\\"\") {\n\t\t\t\t\ttrimmed = ctx.LogPrefillText + trimmed\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog.Printf(\"[Messages-Stream] 上游流式响应合成内容:\\n%s\", strings.TrimSpace(trimmed))\n\t\t\treturn\n\t\t}\n\t}\n\tif ctx.LogBuffer.Len() > 0 {\n\t\tlog.Printf(\"[Messages-Stream] 上游流式响应原始内容:\\n%s\", ctx.LogBuffer.String())\n\t}\n}\n\n// IsClientDisconnectError 判断是否为客户端断开连接错误\nfunc IsClientDisconnectError(err error) bool {\n\tmsg := err.Error()\n\treturn strings.Contains(msg, \"broken pipe\") || strings.Contains(msg, \"connection reset\")\n}\n\n// HandleStreamResponse 处理流式响应（Messages API）\nfunc HandleStreamResponse(\n\tc *gin.Context,\n\tresp *http.Response,\n\tprovider providers.Provider,\n\tenvCfg *config.EnvConfig,\n\tstartTime time.Time,\n\tupstream *config.UpstreamConfig,\n\trequestBody []byte,\n\trequestModel string,\n) (*types.Usage, error) {\n\tdefer resp.Body.Close()\n\n\teventChan, errChan, err := provider.HandleStreamResponse(resp.Body)\n\tif err != nil {\n\t\tc.JSON(500, gin.H{\"error\": \"Failed to handle stream response\"})\n\t\treturn nil, err\n\t}\n\n\tSetupStreamHeaders(c, resp)\n\n\tw := c.Writer\n\tflusher, ok := w.(http.Flusher)\n\tif !ok {\n\t\tlog.Printf(\"[Messages-Stream] 警告: ResponseWriter不支持Flush接口\")\n\t\treturn nil, fmt.Errorf(\"ResponseWriter不支持Flush接口\")\n\t}\n\tflusher.Flush()\n\n\tctx := NewStreamContext(envCfg)\n\tctx.RequestModel = requestModel\n\tctx.LowQuality = upstream.LowQuality\n\tseedSynthesizerFromRequest(ctx, requestBody)\n\treturn ProcessStreamEvents(c, w, flusher, eventChan, errChan, ctx, envCfg, startTime, requestBody)\n}\n\n// ========== Token 检测和修补相关函数 ==========\n\n// CheckEventUsageStatus 检测事件是否包含 usage 字段\nfunc CheckEventUsageStatus(event string, enableLog bool) (bool, bool, bool, CollectedUsageData) {\n\tfor _, line := range strings.Split(event, \"\\n\") {\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\t\tjsonStr := strings.TrimPrefix(line, \"data: \")\n\n\t\tvar data map[string]interface{}\n\t\tif err := json.Unmarshal([]byte(jsonStr), &data); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 检查顶层 usage 字段\n\t\tif hasUsage, needInputPatch, needOutputPatch := checkUsageFieldsWithPatch(data[\"usage\"]); hasUsage {\n\t\t\tvar usageData CollectedUsageData\n\t\t\tif usage, ok := data[\"usage\"].(map[string]interface{}); ok {\n\t\t\t\tif enableLog {\n\t\t\t\t\tlogUsageDetection(\"顶层usage\", usage, needInputPatch || needOutputPatch)\n\t\t\t\t}\n\t\t\t\tusageData = extractUsageFromMap(usage)\n\t\t\t}\n\t\t\treturn true, needInputPatch, needOutputPatch, usageData\n\t\t}\n\n\t\t// 检查 message.usage\n\t\tif msg, ok := data[\"message\"].(map[string]interface{}); ok {\n\t\t\tif hasUsage, needInputPatch, needOutputPatch := checkUsageFieldsWithPatch(msg[\"usage\"]); hasUsage {\n\t\t\t\tvar usageData CollectedUsageData\n\t\t\t\tif usage, ok := msg[\"usage\"].(map[string]interface{}); ok {\n\t\t\t\t\tif enableLog {\n\t\t\t\t\t\tlogUsageDetection(\"message.usage\", usage, needInputPatch || needOutputPatch)\n\t\t\t\t\t}\n\t\t\t\t\tusageData = extractUsageFromMap(usage)\n\t\t\t\t}\n\t\t\t\treturn true, needInputPatch, needOutputPatch, usageData\n\t\t\t}\n\t\t}\n\t}\n\treturn false, false, false, CollectedUsageData{}\n}\n\n// checkUsageFieldsWithPatch 检查 usage 对象是否包含 token 字段\nfunc checkUsageFieldsWithPatch(usage interface{}) (bool, bool, bool) {\n\tif u, ok := usage.(map[string]interface{}); ok {\n\t\tinputTokens, hasInput := u[\"input_tokens\"]\n\t\toutputTokens, hasOutput := u[\"output_tokens\"]\n\t\tif hasInput || hasOutput {\n\t\t\tneedInputPatch := false\n\t\t\tneedOutputPatch := false\n\n\t\t\tcacheCreation, _ := u[\"cache_creation_input_tokens\"].(float64)\n\t\t\tcacheRead, _ := u[\"cache_read_input_tokens\"].(float64)\n\t\t\thasCacheTokens := cacheCreation > 0 || cacheRead > 0\n\n\t\t\tif hasInput {\n\t\t\t\tif inputTokens == nil {\n\t\t\t\t\t// input_tokens 为 nil 时需要修补\n\t\t\t\t\tneedInputPatch = true\n\t\t\t\t} else if v, ok := inputTokens.(float64); ok && v <= 1 && !hasCacheTokens {\n\t\t\t\t\tneedInputPatch = true\n\t\t\t\t}\n\t\t\t}\n\t\t\tif hasOutput {\n\t\t\t\tif v, ok := outputTokens.(float64); ok && v <= 1 {\n\t\t\t\t\tneedOutputPatch = true\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn true, needInputPatch, needOutputPatch\n\t\t}\n\t}\n\treturn false, false, false\n}\n\n// extractUsageFromMap 从 usage map 中提取 token 数据\nfunc extractUsageFromMap(usage map[string]interface{}) CollectedUsageData {\n\tvar data CollectedUsageData\n\n\tif v, ok := usage[\"input_tokens\"].(float64); ok {\n\t\tdata.InputTokens = int(v)\n\t}\n\tif v, ok := usage[\"output_tokens\"].(float64); ok {\n\t\tdata.OutputTokens = int(v)\n\t}\n\tif v, ok := usage[\"cache_creation_input_tokens\"].(float64); ok {\n\t\tdata.CacheCreationInputTokens = int(v)\n\t}\n\tif v, ok := usage[\"cache_read_input_tokens\"].(float64); ok {\n\t\tdata.CacheReadInputTokens = int(v)\n\t}\n\n\tvar has5m, has1h bool\n\tif v, ok := usage[\"cache_creation_5m_input_tokens\"].(float64); ok {\n\t\tdata.CacheCreation5mInputTokens = int(v)\n\t\thas5m = data.CacheCreation5mInputTokens > 0\n\t}\n\tif v, ok := usage[\"cache_creation_1h_input_tokens\"].(float64); ok {\n\t\tdata.CacheCreation1hInputTokens = int(v)\n\t\thas1h = data.CacheCreation1hInputTokens > 0\n\t}\n\n\tif has5m && has1h {\n\t\tdata.CacheTTL = \"mixed\"\n\t} else if has1h {\n\t\tdata.CacheTTL = \"1h\"\n\t} else if has5m {\n\t\tdata.CacheTTL = \"5m\"\n\t}\n\n\treturn data\n}\n\n// logUsageDetection 统一格式输出 usage 检测日志\nfunc logUsageDetection(location string, usage map[string]interface{}, needPatch bool) {\n\tinputTokens := usage[\"input_tokens\"]\n\toutputTokens := usage[\"output_tokens\"]\n\tcacheCreation, _ := usage[\"cache_creation_input_tokens\"].(float64)\n\tcacheRead, _ := usage[\"cache_read_input_tokens\"].(float64)\n\n\tlog.Printf(\"[Messages-Stream-Token] %s: InputTokens=%v, OutputTokens=%v, CacheCreation=%.0f, CacheRead=%.0f, 需补全=%v\",\n\t\tlocation, inputTokens, outputTokens, cacheCreation, cacheRead, needPatch)\n}\n\n// HasEventWithUsage 检查事件是否包含 usage 字段\nfunc HasEventWithUsage(event string) bool {\n\tfor _, line := range strings.Split(event, \"\\n\") {\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\t\tjsonStr := strings.TrimPrefix(line, \"data: \")\n\n\t\tvar data map[string]interface{}\n\t\tif err := json.Unmarshal([]byte(jsonStr), &data); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tif _, ok := data[\"usage\"].(map[string]interface{}); ok {\n\t\t\treturn true\n\t\t}\n\n\t\tif msg, ok := data[\"message\"].(map[string]interface{}); ok {\n\t\t\tif _, ok := msg[\"usage\"].(map[string]interface{}); ok {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\n// PatchTokensInEvent 修补事件中的 token 字段\nfunc PatchTokensInEvent(event string, estimatedInputTokens, estimatedOutputTokens int, hasCacheTokens bool, enableLog bool, lowQuality bool) string {\n\tvar result strings.Builder\n\tlines := strings.Split(event, \"\\n\")\n\n\tfor _, line := range lines {\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tresult.WriteString(line)\n\t\t\tresult.WriteString(\"\\n\")\n\t\t\tcontinue\n\t\t}\n\n\t\tjsonStr := strings.TrimPrefix(line, \"data: \")\n\t\tvar data map[string]interface{}\n\t\tif err := json.Unmarshal([]byte(jsonStr), &data); err != nil {\n\t\t\tresult.WriteString(line)\n\t\t\tresult.WriteString(\"\\n\")\n\t\t\tcontinue\n\t\t}\n\n\t\t// 修补顶层 usage\n\t\tif usage, ok := data[\"usage\"].(map[string]interface{}); ok {\n\t\t\tpatchUsageFieldsWithLog(usage, estimatedInputTokens, estimatedOutputTokens, hasCacheTokens, enableLog, \"顶层usage\", lowQuality)\n\t\t}\n\n\t\t// 修补 message.usage\n\t\tif msg, ok := data[\"message\"].(map[string]interface{}); ok {\n\t\t\tif usage, ok := msg[\"usage\"].(map[string]interface{}); ok {\n\t\t\t\tpatchUsageFieldsWithLog(usage, estimatedInputTokens, estimatedOutputTokens, hasCacheTokens, enableLog, \"message.usage\", lowQuality)\n\t\t\t}\n\t\t}\n\n\t\tpatchedJSON, err := json.Marshal(data)\n\t\tif err != nil {\n\t\t\tresult.WriteString(line)\n\t\t\tresult.WriteString(\"\\n\")\n\t\t\tcontinue\n\t\t}\n\n\t\tresult.WriteString(\"data: \")\n\t\tresult.Write(patchedJSON)\n\t\tresult.WriteString(\"\\n\")\n\t}\n\n\treturn result.String()\n}\n\n// PatchTokensInEventWithCache 修补事件中的 token 字段，并写入推断的 cache_read_input_tokens\n// 当 inferredCacheRead > 0 且事件中没有 cache_read_input_tokens 时，将推断值写入\nfunc PatchTokensInEventWithCache(event string, estimatedInputTokens, estimatedOutputTokens, inferredCacheRead int, hasCacheTokens bool, enableLog bool, lowQuality bool) string {\n\tvar result strings.Builder\n\tlines := strings.Split(event, \"\\n\")\n\n\tfor _, line := range lines {\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tresult.WriteString(line)\n\t\t\tresult.WriteString(\"\\n\")\n\t\t\tcontinue\n\t\t}\n\n\t\tjsonStr := strings.TrimPrefix(line, \"data: \")\n\t\tvar data map[string]interface{}\n\t\tif err := json.Unmarshal([]byte(jsonStr), &data); err != nil {\n\t\t\tresult.WriteString(line)\n\t\t\tresult.WriteString(\"\\n\")\n\t\t\tcontinue\n\t\t}\n\n\t\t// 修补顶层 usage\n\t\tif usage, ok := data[\"usage\"].(map[string]interface{}); ok {\n\t\t\tpatchUsageFieldsWithLog(usage, estimatedInputTokens, estimatedOutputTokens, hasCacheTokens, enableLog, \"顶层usage\", lowQuality)\n\t\t\t// 写入推断的 cache_read_input_tokens（仅当字段不存在时）\n\t\t\tif inferredCacheRead > 0 {\n\t\t\t\tif _, exists := usage[\"cache_read_input_tokens\"]; !exists {\n\t\t\t\t\tusage[\"cache_read_input_tokens\"] = inferredCacheRead\n\t\t\t\t\tif enableLog {\n\t\t\t\t\t\tlog.Printf(\"[Messages-Stream-Token] 顶层usage: 写入推断的 cache_read_input_tokens=%d\", inferredCacheRead)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 修补 message.usage\n\t\tif msg, ok := data[\"message\"].(map[string]interface{}); ok {\n\t\t\tif usage, ok := msg[\"usage\"].(map[string]interface{}); ok {\n\t\t\t\tpatchUsageFieldsWithLog(usage, estimatedInputTokens, estimatedOutputTokens, hasCacheTokens, enableLog, \"message.usage\", lowQuality)\n\t\t\t\t// 写入推断的 cache_read_input_tokens（仅当字段不存在时）\n\t\t\t\tif inferredCacheRead > 0 {\n\t\t\t\t\tif _, exists := usage[\"cache_read_input_tokens\"]; !exists {\n\t\t\t\t\t\tusage[\"cache_read_input_tokens\"] = inferredCacheRead\n\t\t\t\t\t\tif enableLog {\n\t\t\t\t\t\t\tlog.Printf(\"[Messages-Stream-Token] message.usage: 写入推断的 cache_read_input_tokens=%d\", inferredCacheRead)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tpatchedJSON, err := json.Marshal(data)\n\t\tif err != nil {\n\t\t\tresult.WriteString(line)\n\t\t\tresult.WriteString(\"\\n\")\n\t\t\tcontinue\n\t\t}\n\n\t\tresult.WriteString(\"data: \")\n\t\tresult.Write(patchedJSON)\n\t\tresult.WriteString(\"\\n\")\n\t}\n\n\treturn result.String()\n}\n\n// PatchMessageStartInputTokensIfNeeded 在首个 message_start 事件中尽早补全 input_tokens。\n//\n// 部分客户端（例如终端工具）只读取首个 usage 来累计 prompt tokens；如果 message_start 的 input_tokens 为 0/极小值，\n// 即便后续顶层 usage 给出正确值，也可能导致累计失败。\nfunc PatchMessageStartInputTokensIfNeeded(event string, requestBody []byte, needInputPatch bool, usageData CollectedUsageData, enableLog bool, lowQuality bool) string {\n\tif !IsMessageStartEvent(event) {\n\t\treturn event\n\t}\n\tif !HasEventWithUsage(event) {\n\t\treturn event\n\t}\n\n\thasCacheTokens := usageData.CacheCreationInputTokens > 0 ||\n\t\tusageData.CacheReadInputTokens > 0 ||\n\t\tusageData.CacheCreation5mInputTokens > 0 ||\n\t\tusageData.CacheCreation1hInputTokens > 0\n\n\t// 仅在 input_tokens 明显异常时提前补齐；缓存命中场景不应强行补 input_tokens（除非上游返回 nil）\n\tif !needInputPatch && (hasCacheTokens || usageData.InputTokens >= 10) {\n\t\treturn event\n\t}\n\n\testimatedInputTokens := utils.EstimateRequestTokens(requestBody)\n\tif estimatedInputTokens <= 0 {\n\t\treturn event\n\t}\n\n\treturn PatchTokensInEvent(event, estimatedInputTokens, 0, hasCacheTokens, enableLog, lowQuality)\n}\n\n// patchUsageFieldsWithLog 修补 usage 对象中的 token 字段\n// lowQuality 模式：偏差 > 5% 时使用本地估算值\nfunc patchUsageFieldsWithLog(usage map[string]interface{}, estimatedInput, estimatedOutput int, hasCacheTokens bool, enableLog bool, location string, lowQuality bool) {\n\toriginalInput := usage[\"input_tokens\"]\n\toriginalOutput := usage[\"output_tokens\"]\n\tinputPatched := false\n\toutputPatched := false\n\n\tcacheCreation, _ := usage[\"cache_creation_input_tokens\"].(float64)\n\tcacheRead, _ := usage[\"cache_read_input_tokens\"].(float64)\n\tcacheCreation5m, _ := usage[\"cache_creation_5m_input_tokens\"].(float64)\n\tcacheCreation1h, _ := usage[\"cache_creation_1h_input_tokens\"].(float64)\n\tcacheTTL, _ := usage[\"cache_ttl\"].(string)\n\n\t// 低质量渠道模式：偏差 > 5% 时使用本地估算值\n\tif lowQuality {\n\t\tif v, ok := usage[\"input_tokens\"].(float64); ok && estimatedInput > 0 {\n\t\t\tcurrentInput := int(v)\n\t\t\tif currentInput > 0 {\n\t\t\t\tdeviation := float64(abs(currentInput-estimatedInput)) / float64(estimatedInput)\n\t\t\t\tif deviation > 0.05 {\n\t\t\t\t\tusage[\"input_tokens\"] = estimatedInput\n\t\t\t\t\tinputPatched = true\n\t\t\t\t\tif enableLog {\n\t\t\t\t\t\tlog.Printf(\"[Messages-Stream-Token-LowQuality] %s: input_tokens %d -> %d (偏差 %.1f%% > 5%%)\",\n\t\t\t\t\t\t\tlocation, currentInput, estimatedInput, deviation*100)\n\t\t\t\t\t}\n\t\t\t\t} else if enableLog {\n\t\t\t\t\tlog.Printf(\"[Messages-Stream-Token-LowQuality] %s: input_tokens %d ≈ %d (偏差 %.1f%% ≤ 5%%, 保留上游值)\",\n\t\t\t\t\t\tlocation, currentInput, estimatedInput, deviation*100)\n\t\t\t\t}\n\t\t\t}\n\t\t} else if enableLog && estimatedInput > 0 {\n\t\t\tlog.Printf(\"[Messages-Stream-Token-LowQuality] %s: input_tokens=%v (上游无效值, 本地估算=%d)\",\n\t\t\t\tlocation, usage[\"input_tokens\"], estimatedInput)\n\t\t}\n\t\tif v, ok := usage[\"output_tokens\"].(float64); ok && estimatedOutput > 0 {\n\t\t\tcurrentOutput := int(v)\n\t\t\tif currentOutput > 0 {\n\t\t\t\tdeviation := float64(abs(currentOutput-estimatedOutput)) / float64(estimatedOutput)\n\t\t\t\tif deviation > 0.05 {\n\t\t\t\t\tusage[\"output_tokens\"] = estimatedOutput\n\t\t\t\t\toutputPatched = true\n\t\t\t\t\tif enableLog {\n\t\t\t\t\t\tlog.Printf(\"[Messages-Stream-Token-LowQuality] %s: output_tokens %d -> %d (偏差 %.1f%% > 5%%)\",\n\t\t\t\t\t\t\tlocation, currentOutput, estimatedOutput, deviation*100)\n\t\t\t\t\t}\n\t\t\t\t} else if enableLog {\n\t\t\t\t\tlog.Printf(\"[Messages-Stream-Token-LowQuality] %s: output_tokens %d ≈ %d (偏差 %.1f%% ≤ 5%%, 保留上游值)\",\n\t\t\t\t\t\tlocation, currentOutput, estimatedOutput, deviation*100)\n\t\t\t\t}\n\t\t\t}\n\t\t} else if enableLog && estimatedOutput > 0 {\n\t\t\tlog.Printf(\"[Messages-Stream-Token-LowQuality] %s: output_tokens=%v (上游无效值, 本地估算=%d)\",\n\t\t\t\tlocation, usage[\"output_tokens\"], estimatedOutput)\n\t\t}\n\t}\n\n\t// 常规修补逻辑（非 lowQuality 模式或 lowQuality 模式下未修补的情况）\n\tif !inputPatched {\n\t\tif v, ok := usage[\"input_tokens\"].(float64); ok {\n\t\t\tcurrentInput := int(v)\n\t\t\tif !hasCacheTokens && ((currentInput <= 1) || (estimatedInput > currentInput && estimatedInput > 1)) {\n\t\t\t\tusage[\"input_tokens\"] = estimatedInput\n\t\t\t\tinputPatched = true\n\t\t\t}\n\t\t} else if usage[\"input_tokens\"] == nil && estimatedInput > 0 {\n\t\t\t// input_tokens 为 nil 时，用收集到的值修补\n\t\t\tusage[\"input_tokens\"] = estimatedInput\n\t\t\tinputPatched = true\n\t\t}\n\t}\n\n\tif !outputPatched {\n\t\tif v, ok := usage[\"output_tokens\"].(float64); ok {\n\t\t\tcurrentOutput := int(v)\n\t\t\tif currentOutput <= 1 || (estimatedOutput > currentOutput && estimatedOutput > 1) {\n\t\t\t\tusage[\"output_tokens\"] = estimatedOutput\n\t\t\t\toutputPatched = true\n\t\t\t}\n\t\t}\n\t}\n\n\tif enableLog {\n\t\tif inputPatched || outputPatched {\n\t\t\tlog.Printf(\"[Messages-Stream-Token-Patch] %s: InputTokens=%v -> %v, OutputTokens=%v -> %v\",\n\t\t\t\tlocation, originalInput, usage[\"input_tokens\"], originalOutput, usage[\"output_tokens\"])\n\t\t}\n\t\tlog.Printf(\"[Messages-Stream-Token] %s: InputTokens=%v, OutputTokens=%v, CacheCreationInputTokens=%.0f, CacheReadInputTokens=%.0f, CacheCreation5m=%.0f, CacheCreation1h=%.0f, CacheTTL=%s\",\n\t\t\tlocation, usage[\"input_tokens\"], usage[\"output_tokens\"], cacheCreation, cacheRead, cacheCreation5m, cacheCreation1h, cacheTTL)\n\t}\n}\n\n// abs 返回整数的绝对值\nfunc abs(x int) int {\n\tif x < 0 {\n\t\treturn -x\n\t}\n\treturn x\n}\n\n// BuildStreamErrorEvent 构建流错误 SSE 事件\nfunc BuildStreamErrorEvent(err error) string {\n\terrorEvent := map[string]interface{}{\n\t\t\"type\": \"error\",\n\t\t\"error\": map[string]interface{}{\n\t\t\t\"type\":    \"stream_error\",\n\t\t\t\"message\": fmt.Sprintf(\"Stream processing error: %v\", err),\n\t\t},\n\t}\n\teventJSON, _ := json.Marshal(errorEvent)\n\treturn fmt.Sprintf(\"event: error\\ndata: %s\\n\\n\", eventJSON)\n}\n\n// BuildUsageEvent 构建带 usage 的 message_delta SSE 事件\nfunc BuildUsageEvent(requestBody []byte, outputText string) string {\n\tinputTokens := utils.EstimateRequestTokens(requestBody)\n\toutputTokens := utils.EstimateTokens(outputText)\n\n\tevent := map[string]interface{}{\n\t\t\"type\": \"message_delta\",\n\t\t\"usage\": map[string]int{\n\t\t\t\"input_tokens\":  inputTokens,\n\t\t\t\"output_tokens\": outputTokens,\n\t\t},\n\t}\n\teventJSON, _ := json.Marshal(event)\n\treturn fmt.Sprintf(\"event: message_delta\\ndata: %s\\n\\n\", eventJSON)\n}\n\n// IsMessageStartEvent 检测是否为 message_start 事件\nfunc IsMessageStartEvent(event string) bool {\n\treturn strings.Contains(event, \"\\\"type\\\":\\\"message_start\\\"\") ||\n\t\tstrings.Contains(event, \"\\\"type\\\": \\\"message_start\\\"\")\n}\n\n// PatchMessageStartEvent 修补 message_start 事件中的 id 和 model 字段\nfunc PatchMessageStartEvent(event string, requestModel string, rewriteModel bool, enableLog bool) string {\n\tif !IsMessageStartEvent(event) {\n\t\treturn event\n\t}\n\n\tvar result strings.Builder\n\tlines := strings.Split(event, \"\\n\")\n\tpatched := false\n\n\tfor _, line := range lines {\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tresult.WriteString(line)\n\t\t\tresult.WriteString(\"\\n\")\n\t\t\tcontinue\n\t\t}\n\n\t\tjsonStr := strings.TrimPrefix(line, \"data: \")\n\t\tvar data map[string]interface{}\n\t\tif err := json.Unmarshal([]byte(jsonStr), &data); err != nil {\n\t\t\tresult.WriteString(line)\n\t\t\tresult.WriteString(\"\\n\")\n\t\t\tcontinue\n\t\t}\n\n\t\tmsg, ok := data[\"message\"].(map[string]interface{})\n\t\tif !ok {\n\t\t\tresult.WriteString(line)\n\t\t\tresult.WriteString(\"\\n\")\n\t\t\tcontinue\n\t\t}\n\n\t\t// 补全空 id\n\t\tif id, _ := msg[\"id\"].(string); id == \"\" {\n\t\t\tmsg[\"id\"] = fmt.Sprintf(\"msg_%s\", uuid.New().String())\n\t\t\tpatched = true\n\t\t\tif enableLog {\n\t\t\t\tlog.Printf(\"[Messages-Stream-Patch] 补全空 message.id: %s\", msg[\"id\"])\n\t\t\t}\n\t\t}\n\n\t\t// 检查 model 一致性（仅在配置启用时改写）\n\t\tif rewriteModel {\n\t\t\tif responseModel, _ := msg[\"model\"].(string); responseModel != \"\" && requestModel != \"\" && responseModel != requestModel {\n\t\t\t\tmsg[\"model\"] = requestModel\n\t\t\t\tpatched = true\n\t\t\t\tif enableLog {\n\t\t\t\t\tlog.Printf(\"[Messages-Stream-Patch] 改写 message.model: %s -> %s\", responseModel, requestModel)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif patched {\n\t\t\tpatchedJSON, err := json.Marshal(data)\n\t\t\tif err != nil {\n\t\t\t\tresult.WriteString(line)\n\t\t\t\tresult.WriteString(\"\\n\")\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tresult.WriteString(\"data: \")\n\t\t\tresult.Write(patchedJSON)\n\t\t\tresult.WriteString(\"\\n\")\n\t\t} else {\n\t\t\tresult.WriteString(line)\n\t\t\tresult.WriteString(\"\\n\")\n\t\t}\n\t}\n\n\treturn result.String()\n}\n\n// IsMessageStopEvent 检测是否为 message_stop 事件\nfunc IsMessageStopEvent(event string) bool {\n\tif strings.Contains(event, \"event: message_stop\") {\n\t\treturn true\n\t}\n\n\tfor _, line := range strings.Split(event, \"\\n\") {\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\t\tjsonStr := strings.TrimPrefix(line, \"data: \")\n\n\t\tvar data map[string]interface{}\n\t\tif err := json.Unmarshal([]byte(jsonStr), &data); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tif data[\"type\"] == \"message_stop\" {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// IsMessageDeltaEvent 检测是否为 message_delta 事件\nfunc IsMessageDeltaEvent(event string) bool {\n\tif strings.Contains(event, \"event: message_delta\") {\n\t\treturn true\n\t}\n\tfor _, line := range strings.Split(event, \"\\n\") {\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\t\tjsonStr := strings.TrimPrefix(line, \"data: \")\n\t\tvar data map[string]interface{}\n\t\tif err := json.Unmarshal([]byte(jsonStr), &data); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif data[\"type\"] == \"message_delta\" {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ExtractInputTokensFromEvent 从 SSE 事件中提取 input_tokens\n// 支持 message_start 事件的 message.usage.input_tokens 和顶层 usage.input_tokens\nfunc ExtractInputTokensFromEvent(event string) int {\n\tfor _, line := range strings.Split(event, \"\\n\") {\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\t\tjsonStr := strings.TrimPrefix(line, \"data: \")\n\n\t\tvar data map[string]interface{}\n\t\tif err := json.Unmarshal([]byte(jsonStr), &data); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 检查 message.usage.input_tokens (message_start 事件)\n\t\tif msg, ok := data[\"message\"].(map[string]interface{}); ok {\n\t\t\tif usage, ok := msg[\"usage\"].(map[string]interface{}); ok {\n\t\t\t\tif v, ok := usage[\"input_tokens\"].(float64); ok && v > 0 {\n\t\t\t\t\treturn int(v)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 检查顶层 usage.input_tokens (message_delta 事件)\n\t\tif usage, ok := data[\"usage\"].(map[string]interface{}); ok {\n\t\t\tif v, ok := usage[\"input_tokens\"].(float64); ok && v > 0 {\n\t\t\t\treturn int(v)\n\t\t\t}\n\t\t}\n\t}\n\treturn 0\n}\n\n// ExtractTextFromEvent 从 SSE 事件中提取文本内容\nfunc ExtractTextFromEvent(event string, buf *bytes.Buffer) {\n\tfor _, line := range strings.Split(event, \"\\n\") {\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\t\tjsonStr := strings.TrimPrefix(line, \"data: \")\n\n\t\tvar data map[string]interface{}\n\t\tif err := json.Unmarshal([]byte(jsonStr), &data); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Claude SSE: delta.text\n\t\tif delta, ok := data[\"delta\"].(map[string]interface{}); ok {\n\t\t\tif text, ok := delta[\"text\"].(string); ok {\n\t\t\t\tbuf.WriteString(text)\n\t\t\t}\n\t\t\tif partialJSON, ok := delta[\"partial_json\"].(string); ok {\n\t\t\t\tbuf.WriteString(partialJSON)\n\t\t\t}\n\t\t}\n\n\t\t// content_block_start 中的初始文本\n\t\tif cb, ok := data[\"content_block\"].(map[string]interface{}); ok {\n\t\t\tif text, ok := cb[\"text\"].(string); ok {\n\t\t\t\tbuf.WriteString(text)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// extractSSEEventInfo 从 SSE 事件中提取事件类型、block 索引和 block 类型\nfunc extractSSEEventInfo(event string) (eventType string, blockIndex int, blockType string) {\n\tfor _, line := range strings.Split(event, \"\\n\") {\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\t\tjsonStr := strings.TrimPrefix(line, \"data: \")\n\n\t\tvar data map[string]interface{}\n\t\tif err := json.Unmarshal([]byte(jsonStr), &data); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\teventType, _ = data[\"type\"].(string)\n\t\tif idx, ok := data[\"index\"].(float64); ok {\n\t\t\tblockIndex = int(idx)\n\t\t}\n\n\t\t// 从 content_block 中提取类型\n\t\tif cb, ok := data[\"content_block\"].(map[string]interface{}); ok {\n\t\t\tblockType, _ = cb[\"type\"].(string)\n\t\t}\n\n\t\treturn\n\t}\n\treturn\n}\n\n// truncateForLog 截断字符串用于日志输出\nfunc truncateForLog(s string, maxLen int) string {\n\tif len(s) <= maxLen {\n\t\treturn s\n\t}\n\treturn s[:maxLen] + \"...\"\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/common/stream_test.go",
    "content": "package common\n\nimport (\n\t\"encoding/json\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/utils\"\n)\n\nfunc TestPatchUsageFieldsWithLog_NilInputTokens(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tusage          map[string]interface{}\n\t\testimatedInput int\n\t\thasCacheTokens bool\n\t\twantPatched    bool\n\t\twantValue      int\n\t}{\n\t\t{\n\t\t\tname:           \"nil input_tokens without cache - should patch\",\n\t\t\tusage:          map[string]interface{}{\"input_tokens\": nil, \"output_tokens\": float64(100)},\n\t\t\testimatedInput: 10920,\n\t\t\thasCacheTokens: false,\n\t\t\twantPatched:    true,\n\t\t\twantValue:      10920,\n\t\t},\n\t\t{\n\t\t\tname:           \"nil input_tokens with cache - should also patch\",\n\t\t\tusage:          map[string]interface{}{\"input_tokens\": nil, \"output_tokens\": float64(100)},\n\t\t\testimatedInput: 10920,\n\t\t\thasCacheTokens: true,\n\t\t\twantPatched:    true,\n\t\t\twantValue:      10920,\n\t\t},\n\t\t{\n\t\t\tname:           \"valid input_tokens - should not patch\",\n\t\t\tusage:          map[string]interface{}{\"input_tokens\": float64(5000), \"output_tokens\": float64(100)},\n\t\t\testimatedInput: 10920,\n\t\t\thasCacheTokens: true,\n\t\t\twantPatched:    false,\n\t\t\twantValue:      5000,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tpatchUsageFieldsWithLog(tt.usage, tt.estimatedInput, 100, tt.hasCacheTokens, false, \"test\", false)\n\n\t\t\tif tt.wantPatched {\n\t\t\t\tif v, ok := tt.usage[\"input_tokens\"].(int); !ok || v != tt.wantValue {\n\t\t\t\t\tt.Errorf(\"expected input_tokens=%d, got %v\", tt.wantValue, tt.usage[\"input_tokens\"])\n\t\t\t\t}\n\t\t\t} else if tt.usage[\"input_tokens\"] == nil {\n\t\t\t\t// nil case - expected to remain nil\n\t\t\t} else if v, ok := tt.usage[\"input_tokens\"].(float64); ok && int(v) != tt.wantValue {\n\t\t\t\tt.Errorf(\"expected input_tokens=%d, got %v\", tt.wantValue, tt.usage[\"input_tokens\"])\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPatchMessageStartInputTokensIfNeeded(t *testing.T) {\n\trequestBody := []byte(`{\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"hello world hello world hello world\"}]}]}`)\n\testimated := utils.EstimateRequestTokens(requestBody)\n\tif estimated <= 0 {\n\t\tt.Fatalf(\"expected estimated input tokens > 0, got %d\", estimated)\n\t}\n\n\textractInputTokens := func(t *testing.T, event string) float64 {\n\t\tt.Helper()\n\t\tfor _, line := range strings.Split(event, \"\\n\") {\n\t\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvar data map[string]interface{}\n\t\t\tif err := json.Unmarshal([]byte(strings.TrimPrefix(line, \"data: \")), &data); err != nil {\n\t\t\t\tt.Fatalf(\"failed to unmarshal data: %v\", err)\n\t\t\t}\n\t\t\tmsg, ok := data[\"message\"].(map[string]interface{})\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"missing message field\")\n\t\t\t}\n\t\t\tusage, ok := msg[\"usage\"].(map[string]interface{})\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"missing message.usage field\")\n\t\t\t}\n\t\t\tv, ok := usage[\"input_tokens\"].(float64)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"missing input_tokens field\")\n\t\t\t}\n\t\t\treturn v\n\t\t}\n\t\tt.Fatalf(\"no data line found\")\n\t\treturn 0\n\t}\n\n\tt.Run(\"input_tokens=0 should patch in message_start\", func(t *testing.T) {\n\t\tevent := \"event: message_start\\ndata: {\\\"type\\\":\\\"message_start\\\",\\\"message\\\":{\\\"usage\\\":{\\\"input_tokens\\\":0,\\\"output_tokens\\\":0}}}\\n\\n\"\n\t\thasUsage, needInputPatch, _, usageData := CheckEventUsageStatus(event, false)\n\t\tif !hasUsage {\n\t\t\tt.Fatalf(\"expected hasUsage=true\")\n\t\t}\n\t\tif !needInputPatch {\n\t\t\tt.Fatalf(\"expected needInputPatch=true\")\n\t\t}\n\n\t\tpatched := PatchMessageStartInputTokensIfNeeded(event, requestBody, needInputPatch, usageData, false, false)\n\t\tgot := extractInputTokens(t, patched)\n\t\tif got != float64(estimated) {\n\t\t\tt.Fatalf(\"expected input_tokens=%d, got %v\", estimated, got)\n\t\t}\n\t})\n\n\tt.Run(\"input_tokens<10 should patch in message_start\", func(t *testing.T) {\n\t\tevent := \"event: message_start\\ndata: {\\\"type\\\":\\\"message_start\\\",\\\"message\\\":{\\\"usage\\\":{\\\"input_tokens\\\":5,\\\"output_tokens\\\":0}}}\\n\\n\"\n\t\thasUsage, needInputPatch, _, usageData := CheckEventUsageStatus(event, false)\n\t\tif !hasUsage {\n\t\t\tt.Fatalf(\"expected hasUsage=true\")\n\t\t}\n\t\tif needInputPatch {\n\t\t\tt.Fatalf(\"expected needInputPatch=false\")\n\t\t}\n\n\t\tpatched := PatchMessageStartInputTokensIfNeeded(event, requestBody, needInputPatch, usageData, false, false)\n\t\tgot := extractInputTokens(t, patched)\n\t\tif got != float64(estimated) {\n\t\t\tt.Fatalf(\"expected input_tokens=%d, got %v\", estimated, got)\n\t\t}\n\t})\n\n\tt.Run(\"cache hit should not patch input_tokens\", func(t *testing.T) {\n\t\tevent := \"event: message_start\\ndata: {\\\"type\\\":\\\"message_start\\\",\\\"message\\\":{\\\"usage\\\":{\\\"input_tokens\\\":0,\\\"output_tokens\\\":0,\\\"cache_read_input_tokens\\\":100}}}\\n\\n\"\n\t\thasUsage, needInputPatch, _, usageData := CheckEventUsageStatus(event, false)\n\t\tif !hasUsage {\n\t\t\tt.Fatalf(\"expected hasUsage=true\")\n\t\t}\n\t\tif needInputPatch {\n\t\t\tt.Fatalf(\"expected needInputPatch=false\")\n\t\t}\n\n\t\tpatched := PatchMessageStartInputTokensIfNeeded(event, requestBody, needInputPatch, usageData, false, false)\n\t\tgot := extractInputTokens(t, patched)\n\t\tif got != 0 {\n\t\t\tt.Fatalf(\"expected input_tokens=0, got %v\", got)\n\t\t}\n\t})\n\n\tt.Run(\"valid input_tokens should not patch\", func(t *testing.T) {\n\t\tevent := \"event: message_start\\ndata: {\\\"type\\\":\\\"message_start\\\",\\\"message\\\":{\\\"usage\\\":{\\\"input_tokens\\\":50,\\\"output_tokens\\\":0}}}\\n\\n\"\n\t\thasUsage, needInputPatch, _, usageData := CheckEventUsageStatus(event, false)\n\t\tif !hasUsage {\n\t\t\tt.Fatalf(\"expected hasUsage=true\")\n\t\t}\n\t\tif needInputPatch {\n\t\t\tt.Fatalf(\"expected needInputPatch=false\")\n\t\t}\n\n\t\tpatched := PatchMessageStartInputTokensIfNeeded(event, requestBody, needInputPatch, usageData, false, false)\n\t\tgot := extractInputTokens(t, patched)\n\t\tif got != 50 {\n\t\t\tt.Fatalf(\"expected input_tokens=50, got %v\", got)\n\t\t}\n\t})\n}\n\n// TestInferImplicitCacheRead 测试隐式缓存推断逻辑\nfunc TestInferImplicitCacheRead(t *testing.T) {\n\ttests := []struct {\n\t\tname                    string\n\t\tmessageStartInputTokens int\n\t\tcollectedInputTokens    int\n\t\texistingCacheRead       int\n\t\twantCacheRead           int\n\t}{\n\t\t{\n\t\t\tname:                    \"large diff ratio (>10%) should infer cache\",\n\t\t\tmessageStartInputTokens: 100000,\n\t\t\tcollectedInputTokens:    20000,\n\t\t\texistingCacheRead:       0,\n\t\t\twantCacheRead:           80000,\n\t\t},\n\t\t{\n\t\t\tname:                    \"large diff value (>10k) should infer cache\",\n\t\t\tmessageStartInputTokens: 50000,\n\t\t\tcollectedInputTokens:    38000,\n\t\t\texistingCacheRead:       0,\n\t\t\twantCacheRead:           12000,\n\t\t},\n\t\t{\n\t\t\tname:                    \"small diff should not infer cache\",\n\t\t\tmessageStartInputTokens: 10000,\n\t\t\tcollectedInputTokens:    9500,\n\t\t\texistingCacheRead:       0,\n\t\t\twantCacheRead:           0,\n\t\t},\n\t\t{\n\t\t\tname:                    \"existing cache_read should not be overwritten\",\n\t\t\tmessageStartInputTokens: 100000,\n\t\t\tcollectedInputTokens:    20000,\n\t\t\texistingCacheRead:       50000,\n\t\t\twantCacheRead:           50000,\n\t\t},\n\t\t{\n\t\t\tname:                    \"zero message_start should not infer\",\n\t\t\tmessageStartInputTokens: 0,\n\t\t\tcollectedInputTokens:    20000,\n\t\t\texistingCacheRead:       0,\n\t\t\twantCacheRead:           0,\n\t\t},\n\t\t{\n\t\t\tname:                    \"zero collected should not infer\",\n\t\t\tmessageStartInputTokens: 100000,\n\t\t\tcollectedInputTokens:    0,\n\t\t\texistingCacheRead:       0,\n\t\t\twantCacheRead:           0,\n\t\t},\n\t\t{\n\t\t\tname:                    \"negative diff should not infer\",\n\t\t\tmessageStartInputTokens: 10000,\n\t\t\tcollectedInputTokens:    15000,\n\t\t\texistingCacheRead:       0,\n\t\t\twantCacheRead:           0,\n\t\t},\n\t\t{\n\t\t\tname:                    \"exactly 10% diff should not infer\",\n\t\t\tmessageStartInputTokens: 10000,\n\t\t\tcollectedInputTokens:    9000,\n\t\t\texistingCacheRead:       0,\n\t\t\twantCacheRead:           0,\n\t\t},\n\t\t{\n\t\t\tname:                    \"just over 10% diff should infer\",\n\t\t\tmessageStartInputTokens: 10000,\n\t\t\tcollectedInputTokens:    8900,\n\t\t\texistingCacheRead:       0,\n\t\t\twantCacheRead:           1100,\n\t\t},\n\t\t{\n\t\t\tname:                    \"10k diff but ratio <10% should infer (diff > 10k takes precedence)\",\n\t\t\tmessageStartInputTokens: 150000,\n\t\t\tcollectedInputTokens:    139000,\n\t\t\texistingCacheRead:       0,\n\t\t\twantCacheRead:           11000,\n\t\t},\n\t\t{\n\t\t\tname:                    \"diff exactly 10k with ratio <10% should not infer\",\n\t\t\tmessageStartInputTokens: 150000,\n\t\t\tcollectedInputTokens:    140000,\n\t\t\texistingCacheRead:       0,\n\t\t\twantCacheRead:           0,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tctx := &StreamContext{\n\t\t\t\tMessageStartInputTokens: tt.messageStartInputTokens,\n\t\t\t\tCollectedUsage: CollectedUsageData{\n\t\t\t\t\tInputTokens:          tt.collectedInputTokens,\n\t\t\t\t\tCacheReadInputTokens: tt.existingCacheRead,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tinferImplicitCacheRead(ctx, false)\n\n\t\t\tif ctx.CollectedUsage.CacheReadInputTokens != tt.wantCacheRead {\n\t\t\t\tt.Errorf(\"CacheReadInputTokens = %d, want %d\",\n\t\t\t\t\tctx.CollectedUsage.CacheReadInputTokens, tt.wantCacheRead)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestPatchTokensInEventWithCache 测试带缓存推断的事件修补\nfunc TestPatchTokensInEventWithCache(t *testing.T) {\n\textractCacheRead := func(t *testing.T, event string) float64 {\n\t\tt.Helper()\n\t\tfor _, line := range strings.Split(event, \"\\n\") {\n\t\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvar data map[string]interface{}\n\t\t\tif err := json.Unmarshal([]byte(strings.TrimPrefix(line, \"data: \")), &data); err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif usage, ok := data[\"usage\"].(map[string]interface{}); ok {\n\t\t\t\tif v, ok := usage[\"cache_read_input_tokens\"].(float64); ok {\n\t\t\t\t\treturn v\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn 0\n\t}\n\n\tt.Run(\"should write inferred cache_read when not present\", func(t *testing.T) {\n\t\tevent := \"event: message_delta\\ndata: {\\\"type\\\":\\\"message_delta\\\",\\\"usage\\\":{\\\"input_tokens\\\":20000,\\\"output_tokens\\\":100}}\\n\\n\"\n\t\tpatched := PatchTokensInEventWithCache(event, 20000, 100, 80000, true, false, false)\n\t\tgot := extractCacheRead(t, patched)\n\t\tif got != 80000 {\n\t\t\tt.Errorf(\"expected cache_read_input_tokens=80000, got %v\", got)\n\t\t}\n\t})\n\n\tt.Run(\"should not overwrite existing cache_read\", func(t *testing.T) {\n\t\tevent := \"event: message_delta\\ndata: {\\\"type\\\":\\\"message_delta\\\",\\\"usage\\\":{\\\"input_tokens\\\":20000,\\\"output_tokens\\\":100,\\\"cache_read_input_tokens\\\":50000}}\\n\\n\"\n\t\tpatched := PatchTokensInEventWithCache(event, 20000, 100, 80000, true, false, false)\n\t\tgot := extractCacheRead(t, patched)\n\t\tif got != 50000 {\n\t\t\tt.Errorf(\"expected cache_read_input_tokens=50000 (unchanged), got %v\", got)\n\t\t}\n\t})\n\n\tt.Run(\"should not write when inferredCacheRead is 0\", func(t *testing.T) {\n\t\tevent := \"event: message_delta\\ndata: {\\\"type\\\":\\\"message_delta\\\",\\\"usage\\\":{\\\"input_tokens\\\":20000,\\\"output_tokens\\\":100}}\\n\\n\"\n\t\tpatched := PatchTokensInEventWithCache(event, 20000, 100, 0, false, false, false)\n\t\tgot := extractCacheRead(t, patched)\n\t\tif got != 0 {\n\t\t\tt.Errorf(\"expected cache_read_input_tokens=0, got %v\", got)\n\t\t}\n\t})\n\n\tt.Run(\"should not overwrite explicit zero from upstream\", func(t *testing.T) {\n\t\t// 上游显式返回 cache_read_input_tokens: 0 表示\"明确无缓存\"，不应被推断值覆盖\n\t\tevent := \"event: message_delta\\ndata: {\\\"type\\\":\\\"message_delta\\\",\\\"usage\\\":{\\\"input_tokens\\\":20000,\\\"output_tokens\\\":100,\\\"cache_read_input_tokens\\\":0}}\\n\\n\"\n\t\tpatched := PatchTokensInEventWithCache(event, 20000, 100, 80000, true, false, false)\n\t\tgot := extractCacheRead(t, patched)\n\t\tif got != 0 {\n\t\t\tt.Errorf(\"expected cache_read_input_tokens=0 (explicit zero preserved), got %v\", got)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/common/upstream_failover.go",
    "content": "// Package common 提供 handlers 模块的公共功能\npackage common\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/metrics\"\n\t\"github.com/BenedictKing/claude-proxy/internal/scheduler\"\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n\t\"github.com/BenedictKing/claude-proxy/internal/utils\"\n\t\"github.com/BenedictKing/claude-proxy/internal/warmup\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// isClientSideError 判断错误是否由客户端明确取消（不应计入渠道失败）\n// 仅识别 context.Canceled，broken pipe/connection reset 视为连接故障需要 failover\nfunc isClientSideError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\t// 只有 context.Canceled 才是明确的客户端取消意图\n\treturn errors.Is(err, context.Canceled)\n}\n\n// NextAPIKeyFunc 返回下一个可用 API key（按 failover 策略）\ntype NextAPIKeyFunc func(upstream *config.UpstreamConfig, failedKeys map[string]bool) (string, error)\n\n// BuildRequestFunc 构建上游请求（upstreamCopy.BaseURL 已写入当前尝试的 BaseURL）\ntype BuildRequestFunc func(c *gin.Context, upstreamCopy *config.UpstreamConfig, apiKey string) (*http.Request, error)\n\n// DeprioritizeKeyFunc 对 quota 相关失败的 key 做降级（实现可选择是否记录日志）\ntype DeprioritizeKeyFunc func(apiKey string)\n\n// HandleSuccessFunc 处理成功响应（负责写回客户端），并返回 usage（可为 nil）\n// 注意：实现方需要自行关闭 resp.Body（与现有 handlers 保持一致）。\ntype HandleSuccessFunc func(c *gin.Context, resp *http.Response, upstreamCopy *config.UpstreamConfig, apiKey string) (*types.Usage, error)\n\n// TryUpstreamWithAllKeys 尝试一个 upstream 的所有 BaseURL + Key（纯 failover）\n// 返回:\n//   - handled: 是否已向客户端写回响应（成功或非 failover 错误）\n//   - successKey: 成功的 key（仅 handled=true 且成功时有值）\n//   - successBaseURLIdx: 成功 BaseURL 的原始索引（用于指标记录）\n//   - failoverErr: 最后一次可故障转移的上游错误（用于多渠道聚合错误）\n//   - usage: usage 统计（可能为 nil）\nfunc TryUpstreamWithAllKeys(\n\tc *gin.Context,\n\tenvCfg *config.EnvConfig,\n\tcfgManager *config.ConfigManager,\n\tchannelScheduler *scheduler.ChannelScheduler,\n\tkind scheduler.ChannelKind,\n\tapiType string,\n\tmetricsManager *metrics.MetricsManager,\n\tupstream *config.UpstreamConfig,\n\turlResults []warmup.URLLatencyResult,\n\trequestBody []byte,\n\tisStream bool,\n\tnextAPIKey NextAPIKeyFunc,\n\tbuildRequest BuildRequestFunc,\n\tdeprioritizeKey DeprioritizeKeyFunc,\n\tmarkURLFailure func(url string),\n\tmarkURLSuccess func(url string),\n\thandleSuccess HandleSuccessFunc,\n) (handled bool, successKey string, successBaseURLIdx int, failoverErr *FailoverError, usage *types.Usage, lastError error) {\n\tif upstream == nil || len(upstream.APIKeys) == 0 {\n\t\treturn false, \"\", 0, nil, nil, nil\n\t}\n\tif metricsManager == nil {\n\t\treturn false, \"\", 0, nil, nil, nil\n\t}\n\tif nextAPIKey == nil || buildRequest == nil || handleSuccess == nil {\n\t\treturn false, \"\", 0, nil, nil, nil\n\t}\n\tif len(urlResults) == 0 {\n\t\treturn false, \"\", 0, nil, nil, nil\n\t}\n\n\tvar lastFailoverError *FailoverError\n\tdeprioritizeCandidates := make(map[string]bool)\n\n\t// 强制探测模式：基于本次优先尝试的 BaseURL 判断（避免 BaseURL/BaseURLs 不一致导致误判）\n\tforceProbeMode := AreAllKeysSuspended(metricsManager, urlResults[0].URL, upstream.APIKeys)\n\tif forceProbeMode {\n\t\tlog.Printf(\"[%s-ForceProbe] 渠道 %s 所有 Key 都被熔断，启用强制探测模式\", apiType, upstream.Name)\n\t}\n\n\tfor urlIdx, urlResult := range urlResults {\n\t\tcurrentBaseURL := urlResult.URL\n\t\toriginalIdx := urlResult.OriginalIdx // 原始索引用于指标记录\n\t\tfailedKeys := make(map[string]bool)  // 每个 BaseURL 重置失败 Key 列表\n\t\tmaxRetries := len(upstream.APIKeys)\n\n\t\tfor attempt := 0; attempt < maxRetries; attempt++ {\n\t\t\tRestoreRequestBody(c, requestBody)\n\n\t\t\tapiKey, err := nextAPIKey(upstream, failedKeys)\n\t\t\tif err != nil {\n\t\t\t\tlastError = err\n\t\t\t\tbreak // 当前 BaseURL 没有可用 Key，尝试下一个 BaseURL\n\t\t\t}\n\n\t\t\t// 检查熔断状态\n\t\t\tif !forceProbeMode && metricsManager.ShouldSuspendKey(currentBaseURL, apiKey) {\n\t\t\t\tfailedKeys[apiKey] = true\n\t\t\t\tlog.Printf(\"[%s-Circuit] 跳过熔断中的 Key: %s\", apiType, utils.MaskAPIKey(apiKey))\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif envCfg.ShouldLog(\"info\") {\n\t\t\t\tlog.Printf(\"[%s-Key] 使用API密钥: %s (BaseURL %d/%d, 尝试 %d/%d)\",\n\t\t\t\t\tapiType, utils.MaskAPIKey(apiKey), urlIdx+1, len(urlResults), attempt+1, maxRetries)\n\t\t\t}\n\n\t\t\t// 使用深拷贝避免并发修改问题\n\t\t\tupstreamCopy := upstream.Clone()\n\t\t\tupstreamCopy.BaseURL = currentBaseURL\n\n\t\t\treq, err := buildRequest(c, upstreamCopy, apiKey)\n\t\t\tif err != nil {\n\t\t\t\tlastError = err\n\t\t\t\tfailedKeys[apiKey] = true\n\t\t\t\tchannelScheduler.RecordFailure(currentBaseURL, apiKey, kind)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// 记录请求开始\n\t\t\tchannelScheduler.RecordRequestStart(currentBaseURL, apiKey, kind)\n\n\t\t\t// TCP 建连开始即计数：将活跃度统计提前到发起上游请求之前\n\t\t\trequestID := metricsManager.RecordRequestConnected(currentBaseURL, apiKey)\n\n\t\t\tresp, err := SendRequest(req, upstream, envCfg, isStream, apiType)\n\t\t\tif err != nil {\n\t\t\t\tlastError = err\n\t\t\t\t// 区分客户端取消和真实渠道故障（统一口径）\n\t\t\t\tif isClientSideError(err) {\n\t\t\t\t\t// 客户端取消：不计入失败，不触发 failover\n\t\t\t\t\tmetricsManager.RecordRequestFinalizeClientCancel(currentBaseURL, apiKey, requestID)\n\t\t\t\t\tchannelScheduler.RecordRequestEnd(currentBaseURL, apiKey, kind)\n\t\t\t\t\tlog.Printf(\"[%s-Cancel] 请求已取消（SendRequest 阶段）\", apiType)\n\t\t\t\t\treturn true, \"\", 0, nil, nil, err\n\t\t\t\t}\n\t\t\t\t// 真实渠道故障：计入失败，继续 failover\n\t\t\t\tfailedKeys[apiKey] = true\n\t\t\t\tcfgManager.MarkKeyAsFailed(apiKey, apiType)\n\t\t\t\tmetricsManager.RecordRequestFinalizeFailure(currentBaseURL, apiKey, requestID)\n\t\t\t\tchannelScheduler.RecordRequestEnd(currentBaseURL, apiKey, kind)\n\t\t\t\tif markURLFailure != nil {\n\t\t\t\t\tmarkURLFailure(currentBaseURL)\n\t\t\t\t}\n\t\t\t\tlog.Printf(\"[%s-Key] 警告: API密钥失败: %v\", apiType, err)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif resp.StatusCode < 200 || resp.StatusCode >= 300 {\n\t\t\t\trespBodyBytes, _ := io.ReadAll(resp.Body)\n\t\t\t\tresp.Body.Close()\n\t\t\t\trespBodyBytes = utils.DecompressGzipIfNeeded(resp, respBodyBytes)\n\n\t\t\t\tshouldFailover, isQuotaRelated := ShouldRetryWithNextKey(resp.StatusCode, respBodyBytes, cfgManager.GetFuzzyModeEnabled(), apiType)\n\t\t\t\tif shouldFailover {\n\t\t\t\t\tlastError = fmt.Errorf(\"上游错误: %d\", resp.StatusCode)\n\t\t\t\t\tfailedKeys[apiKey] = true\n\t\t\t\t\tcfgManager.MarkKeyAsFailed(apiKey, apiType)\n\t\t\t\t\tmetricsManager.RecordRequestFinalizeFailure(currentBaseURL, apiKey, requestID)\n\t\t\t\t\tchannelScheduler.RecordRequestEnd(currentBaseURL, apiKey, kind)\n\t\t\t\t\tif markURLFailure != nil {\n\t\t\t\t\t\tmarkURLFailure(currentBaseURL)\n\t\t\t\t\t}\n\t\t\t\t\tlog.Printf(\"[%s-Key] 警告: API密钥失败 (状态: %d)，尝试下一个密钥\", apiType, resp.StatusCode)\n\n\t\t\t\t\tlastFailoverError = &FailoverError{\n\t\t\t\t\t\tStatus: resp.StatusCode,\n\t\t\t\t\t\tBody:   respBodyBytes,\n\t\t\t\t\t}\n\n\t\t\t\t\tif isQuotaRelated {\n\t\t\t\t\t\tdeprioritizeCandidates[apiKey] = true\n\t\t\t\t\t}\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// 非 failover 错误，记录失败指标后返回（请求已处理）\n\t\t\t\tmetricsManager.RecordRequestFinalizeFailure(currentBaseURL, apiKey, requestID)\n\t\t\t\tchannelScheduler.RecordRequestEnd(currentBaseURL, apiKey, kind)\n\t\t\t\tc.Data(resp.StatusCode, \"application/json\", respBodyBytes)\n\t\t\t\treturn true, \"\", 0, nil, nil, nil\n\t\t\t}\n\n\t\t\t// 成功响应：处理 quota key 降级\n\t\t\tif deprioritizeKey != nil && len(deprioritizeCandidates) > 0 {\n\t\t\t\tfor key := range deprioritizeCandidates {\n\t\t\t\t\tdeprioritizeKey(key)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif markURLSuccess != nil {\n\t\t\t\tmarkURLSuccess(currentBaseURL)\n\t\t\t}\n\n\t\t\tusage, err = handleSuccess(c, resp, upstreamCopy, apiKey)\n\t\t\tif err != nil {\n\t\t\t\tlastError = err\n\t\t\t\t// 区分客户端错误和渠道故障\n\t\t\t\tif isClientSideError(err) {\n\t\t\t\t\t// 客户端取消/断开：计入总请求数但不计入失败\n\t\t\t\t\tmetricsManager.RecordRequestFinalizeClientCancel(currentBaseURL, apiKey, requestID)\n\t\t\t\t\tchannelScheduler.RecordRequestEnd(currentBaseURL, apiKey, kind)\n\t\t\t\t\tlog.Printf(\"[%s-Cancel] 请求已取消，停止渠道 failover\", apiType)\n\t\t\t\t} else {\n\t\t\t\t\t// 真实渠道故障：计入失败指标\n\t\t\t\t\tcfgManager.MarkKeyAsFailed(apiKey, apiType)\n\t\t\t\t\tmetricsManager.RecordRequestFinalizeFailure(currentBaseURL, apiKey, requestID)\n\t\t\t\t\tchannelScheduler.RecordRequestEnd(currentBaseURL, apiKey, kind)\n\t\t\t\t\tlog.Printf(\"[%s-Key] 警告: 响应处理失败: %v\", apiType, err)\n\t\t\t\t}\n\t\t\t\treturn true, \"\", 0, nil, usage, err\n\t\t\t}\n\n\t\t\tmetricsManager.RecordRequestFinalizeSuccess(currentBaseURL, apiKey, requestID, usage)\n\t\t\tchannelScheduler.RecordRequestEnd(currentBaseURL, apiKey, kind)\n\t\t\treturn true, apiKey, originalIdx, nil, usage, nil\n\t\t}\n\n\t\t// 当前 BaseURL 的所有 Key 都失败，记录并尝试下一个 BaseURL\n\t\tif envCfg.ShouldLog(\"info\") && urlIdx < len(urlResults)-1 {\n\t\t\tlog.Printf(\"[%s-BaseURL] BaseURL %d/%d 所有 Key 失败，切换到下一个 BaseURL\", apiType, urlIdx+1, len(urlResults))\n\t\t}\n\t}\n\n\treturn false, \"\", 0, lastFailoverError, nil, lastError\n}\n\n// BuildDefaultURLResults 将 URLs 转为按原始顺序的结果列表（无动态排序）\nfunc BuildDefaultURLResults(urls []string) []warmup.URLLatencyResult {\n\tresults := make([]warmup.URLLatencyResult, len(urls))\n\tfor i, url := range urls {\n\t\tresults[i] = warmup.URLLatencyResult{\n\t\t\tURL:         url,\n\t\t\tOriginalIdx: i,\n\t\t\tSuccess:     true,\n\t\t}\n\t}\n\treturn results\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/frontend.go",
    "content": "package handlers\n\nimport (\n\t\"embed\"\n\t\"io/fs\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// ServeFrontend 提供前端静态文件服务\nfunc ServeFrontend(r *gin.Engine, frontendFS embed.FS) {\n\t// 从嵌入的文件系统中提取 frontend/dist 子目录\n\tdistFS, err := fs.Sub(frontendFS, \"frontend/dist\")\n\tif err != nil {\n\t\t// 如果提取失败，返回错误页面\n\t\tr.GET(\"/\", func(c *gin.Context) {\n\t\t\tc.Data(503, \"text/html; charset=utf-8\", []byte(getErrorPage()))\n\t\t})\n\t\treturn\n\t}\n\n\t// 使用 Gin 的静态文件服务 - /assets 路由\n\tr.StaticFS(\"/assets\", http.FS(distFS))\n\n\t// 根路径返回 index.html\n\tr.GET(\"/\", func(c *gin.Context) {\n\t\tindexContent, err := fs.ReadFile(distFS, \"index.html\")\n\t\tif err != nil {\n\t\t\tc.Data(503, \"text/html; charset=utf-8\", []byte(getErrorPage()))\n\t\t\treturn\n\t\t}\n\t\tc.Data(200, \"text/html; charset=utf-8\", indexContent)\n\t})\n\n\t// NoRoute 处理器 - 智能SPA支持\n\tr.NoRoute(func(c *gin.Context) {\n\t\tpath := c.Request.URL.Path\n\n\t\t// API 路由优先处理 - 返回 JSON 格式的 404\n\t\tif isAPIPath(path) {\n\t\t\tc.JSON(http.StatusNotFound, gin.H{\n\t\t\t\t\"error\":   \"API endpoint not found\",\n\t\t\t\t\"path\":    path,\n\t\t\t\t\"message\": \"请求的API端点不存在\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\t// 去掉开头的 /\n\t\tif len(path) > 0 && path[0] == '/' {\n\t\t\tpath = path[1:]\n\t\t}\n\n\t\t// 尝试从嵌入的文件系统读取文件\n\t\tfileContent, err := fs.ReadFile(distFS, path)\n\t\tif err == nil {\n\t\t\t// 文件存在，根据扩展名设置正确的 Content-Type\n\t\t\tcontentType := getContentType(path)\n\t\t\tc.Data(200, contentType, fileContent)\n\t\t\treturn\n\t\t}\n\n\t\t// 文件不存在，返回 index.html (SPA 路由支持)\n\t\tindexContent, err := fs.ReadFile(distFS, \"index.html\")\n\t\tif err != nil {\n\t\t\tc.Data(503, \"text/html; charset=utf-8\", []byte(getErrorPage()))\n\t\t\treturn\n\t\t}\n\t\tc.Data(200, \"text/html; charset=utf-8\", indexContent)\n\t})\n}\n\n// isAPIPath 检查路径是否为 API 端点\nfunc isAPIPath(path string) bool {\n\t// API 路由前缀列表\n\tapiPrefixes := []string{\n\t\t\"/v1/\",    // Claude API 代理端点\n\t\t\"/api/\",   // Web 管理界面 API\n\t\t\"/admin/\", // 管理端点\n\t}\n\n\tfor _, prefix := range apiPrefixes {\n\t\tif strings.HasPrefix(path, prefix) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// getContentType 根据文件扩展名返回 Content-Type\nfunc getContentType(path string) string {\n\tif len(path) == 0 {\n\t\treturn \"text/html; charset=utf-8\"\n\t}\n\n\t// 从路径末尾查找扩展名\n\text := \"\"\n\tfor i := len(path) - 1; i >= 0 && path[i] != '/'; i-- {\n\t\tif path[i] == '.' {\n\t\t\text = path[i:]\n\t\t\tbreak\n\t\t}\n\t}\n\n\tswitch ext {\n\tcase \".html\":\n\t\treturn \"text/html; charset=utf-8\"\n\tcase \".css\":\n\t\treturn \"text/css; charset=utf-8\"\n\tcase \".js\":\n\t\treturn \"application/javascript; charset=utf-8\"\n\tcase \".json\":\n\t\treturn \"application/json; charset=utf-8\"\n\tcase \".png\":\n\t\treturn \"image/png\"\n\tcase \".jpg\", \".jpeg\":\n\t\treturn \"image/jpeg\"\n\tcase \".gif\":\n\t\treturn \"image/gif\"\n\tcase \".svg\":\n\t\treturn \"image/svg+xml\"\n\tcase \".ico\":\n\t\treturn \"image/x-icon\"\n\tcase \".woff\":\n\t\treturn \"font/woff\"\n\tcase \".woff2\":\n\t\treturn \"font/woff2\"\n\tcase \".ttf\":\n\t\treturn \"font/ttf\"\n\tcase \".eot\":\n\t\treturn \"application/vnd.ms-fontobject\"\n\tdefault:\n\t\treturn \"application/octet-stream\"\n\t}\n}\n\n// getErrorPage 获取错误页面\nfunc getErrorPage() string {\n\treturn `<!DOCTYPE html>\n<html>\n<head>\n  <title>Claude Proxy - 配置错误</title>\n  <meta charset=\"utf-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <style>\n    body { font-family: system-ui; padding: 40px; background: #f5f5f5; }\n    .error { max-width: 600px; margin: 0 auto; background: white; padding: 40px; border-radius: 8px; }\n    h1 { color: #dc3545; }\n    code { background: #f8f9fa; padding: 2px 6px; border-radius: 3px; }\n    pre { background: #f8f9fa; padding: 16px; border-radius: 4px; overflow-x: auto; }\n  </style>\n</head>\n<body>\n  <div class=\"error\">\n    <h1>前端资源未找到</h1>\n    <p>无法找到前端构建文件。请执行以下步骤之一：</p>\n    <h3>方案1: 重新构建(推荐)</h3>\n    <pre>./build.sh</pre>\n    <h3>方案2: 禁用Web界面</h3>\n    <p>在 <code>.env</code> 文件中设置: <code>ENABLE_WEB_UI=false</code></p>\n    <p>然后只使用API端点: <code>/v1/messages</code></p>\n  </div>\n</body>\n</html>`\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/gemini/channels.go",
    "content": "// Package gemini 提供 Gemini API 的渠道管理\npackage gemini\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/scheduler\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// GetUpstreams 获取 Gemini 上游列表\nfunc GetUpstreams(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tcfg := cfgManager.GetConfig()\n\n\t\tupstreams := make([]gin.H, len(cfg.GeminiUpstream))\n\t\tfor i, up := range cfg.GeminiUpstream {\n\t\t\tstatus := config.GetChannelStatus(&up)\n\t\t\tpriority := config.GetChannelPriority(&up, i)\n\n\t\t\tupstreams[i] = gin.H{\n\t\t\t\t\"index\":                       i,\n\t\t\t\t\"name\":                        up.Name,\n\t\t\t\t\"serviceType\":                 up.ServiceType,\n\t\t\t\t\"baseUrl\":                     up.BaseURL,\n\t\t\t\t\"baseUrls\":                    up.BaseURLs,\n\t\t\t\t\"apiKeys\":                     up.APIKeys,\n\t\t\t\t\"description\":                 up.Description,\n\t\t\t\t\"website\":                     up.Website,\n\t\t\t\t\"insecureSkipVerify\":          up.InsecureSkipVerify,\n\t\t\t\t\"modelMapping\":                up.ModelMapping,\n\t\t\t\t\"latency\":                     nil,\n\t\t\t\t\"status\":                      status,\n\t\t\t\t\"priority\":                    priority,\n\t\t\t\t\"promotionUntil\":              up.PromotionUntil,\n\t\t\t\t\"lowQuality\":                  up.LowQuality,\n\t\t\t\t\"injectDummyThoughtSignature\": up.InjectDummyThoughtSignature,\n\t\t\t\t\"stripThoughtSignature\":       up.StripThoughtSignature,\n\t\t\t}\n\t\t}\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"channels\":    upstreams,\n\t\t\t\"loadBalance\": cfg.GeminiLoadBalance,\n\t\t})\n\t}\n}\n\n// AddUpstream 添加 Gemini 上游\nfunc AddUpstream(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tvar upstream config.UpstreamConfig\n\t\tif err := c.ShouldBindJSON(&upstream); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\n\t\tif err := cfgManager.AddGeminiUpstream(upstream); err != nil {\n\t\t\tc.JSON(500, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\"message\": \"Gemini upstream added successfully\"})\n\t}\n}\n\n// UpdateUpstream 更新 Gemini 上游\nfunc UpdateUpstream(cfgManager *config.ConfigManager, sch *scheduler.ChannelScheduler) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid upstream ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tvar updates config.UpstreamUpdate\n\t\tif err := c.ShouldBindJSON(&updates); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\n\t\tshouldResetMetrics, err := cfgManager.UpdateGeminiUpstream(id, updates)\n\t\tif err != nil {\n\t\t\tc.JSON(500, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\n\t\t// 单 key 更换时重置熔断状态\n\t\tif shouldResetMetrics {\n\t\t\tsch.ResetChannelMetrics(id, scheduler.ChannelKindGemini)\n\t\t}\n\n\t\tc.JSON(200, gin.H{\"message\": \"Gemini upstream updated successfully\"})\n\t}\n}\n\n// DeleteUpstream 删除 Gemini 上游\nfunc DeleteUpstream(cfgManager *config.ConfigManager, sch *scheduler.ChannelScheduler) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid upstream ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tremoved, err := cfgManager.RemoveGeminiUpstream(id)\n\t\tif err != nil {\n\t\t\tif strings.Contains(err.Error(), \"无效的\") {\n\t\t\t\tc.JSON(404, gin.H{\"error\": \"Upstream not found\"})\n\t\t\t} else {\n\t\t\t\tc.JSON(500, gin.H{\"error\": err.Error()})\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\t// 删除成功后清理指标数据（使用 RemoveGeminiUpstream 返回的渠道信息）\n\t\tsch.DeleteChannelMetrics(removed, scheduler.ChannelKindGemini)\n\n\t\tc.JSON(200, gin.H{\"message\": \"Gemini upstream deleted successfully\"})\n\t}\n}\n\n// AddApiKey 添加 Gemini 渠道 API 密钥\nfunc AddApiKey(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid upstream ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tvar req struct {\n\t\t\tAPIKey string `json:\"apiKey\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid request body\"})\n\t\t\treturn\n\t\t}\n\n\t\tif err := cfgManager.AddGeminiAPIKey(id, req.APIKey); err != nil {\n\t\t\tif strings.Contains(err.Error(), \"无效的上游索引\") {\n\t\t\t\tc.JSON(404, gin.H{\"error\": \"Upstream not found\"})\n\t\t\t} else if strings.Contains(err.Error(), \"API密钥已存在\") {\n\t\t\t\tc.JSON(400, gin.H{\"error\": \"API密钥已存在\"})\n\t\t\t} else {\n\t\t\t\tc.JSON(500, gin.H{\"error\": \"Failed to save config\"})\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"message\": \"API密钥已添加\",\n\t\t\t\"success\": true,\n\t\t})\n\t}\n}\n\n// DeleteApiKey 删除 Gemini 渠道 API 密钥\nfunc DeleteApiKey(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid upstream ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tapiKey := c.Param(\"apiKey\")\n\t\tif apiKey == \"\" {\n\t\t\tc.JSON(400, gin.H{\"error\": \"API key is required\"})\n\t\t\treturn\n\t\t}\n\n\t\tif err := cfgManager.RemoveGeminiAPIKey(id, apiKey); err != nil {\n\t\t\tif strings.Contains(err.Error(), \"无效的上游索引\") {\n\t\t\t\tc.JSON(404, gin.H{\"error\": \"Upstream not found\"})\n\t\t\t} else if strings.Contains(err.Error(), \"API密钥不存在\") {\n\t\t\t\tc.JSON(404, gin.H{\"error\": \"API key not found\"})\n\t\t\t} else {\n\t\t\t\tc.JSON(500, gin.H{\"error\": \"Failed to save config\"})\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"message\": \"API密钥已删除\",\n\t\t})\n\t}\n}\n\n// MoveApiKeyToTop 将 Gemini 渠道 API 密钥移到最前面\nfunc MoveApiKeyToTop(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tid, _ := strconv.Atoi(c.Param(\"id\"))\n\t\tapiKey := c.Param(\"apiKey\")\n\n\t\tif err := cfgManager.MoveGeminiAPIKeyToTop(id, apiKey); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(200, gin.H{\"message\": \"API密钥已置顶\"})\n\t}\n}\n\n// MoveApiKeyToBottom 将 Gemini 渠道 API 密钥移到最后面\nfunc MoveApiKeyToBottom(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tid, _ := strconv.Atoi(c.Param(\"id\"))\n\t\tapiKey := c.Param(\"apiKey\")\n\n\t\tif err := cfgManager.MoveGeminiAPIKeyToBottom(id, apiKey); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(200, gin.H{\"message\": \"API密钥已置底\"})\n\t}\n}\n\n// ReorderChannels 重新排序 Gemini 渠道优先级\nfunc ReorderChannels(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tOrder []int `json:\"order\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid request body\"})\n\t\t\treturn\n\t\t}\n\n\t\tif err := cfgManager.ReorderGeminiUpstreams(req.Order); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"success\": true,\n\t\t\t\"message\": \"Gemini 渠道优先级已更新\",\n\t\t})\n\t}\n}\n\n// SetChannelStatus 设置 Gemini 渠道状态\nfunc SetChannelStatus(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid channel ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tvar req struct {\n\t\t\tStatus string `json:\"status\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid request body\"})\n\t\t\treturn\n\t\t}\n\n\t\tif err := cfgManager.SetGeminiChannelStatus(id, req.Status); err != nil {\n\t\t\tif strings.Contains(err.Error(), \"无效的上游索引\") {\n\t\t\t\tc.JSON(404, gin.H{\"error\": \"Channel not found\"})\n\t\t\t} else {\n\t\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"success\": true,\n\t\t\t\"message\": \"Gemini 渠道状态已更新\",\n\t\t\t\"status\":  req.Status,\n\t\t})\n\t}\n}\n\n// SetChannelPromotion 设置 Gemini 渠道促销期\nfunc SetChannelPromotion(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid channel ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tvar req struct {\n\t\t\tDuration int `json:\"duration\"` // 促销期时长（秒），0 表示清除\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid request body\"})\n\t\t\treturn\n\t\t}\n\n\t\tduration := time.Duration(req.Duration) * time.Second\n\t\tif err := cfgManager.SetGeminiChannelPromotion(id, duration); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\n\t\tif req.Duration <= 0 {\n\t\t\tc.JSON(200, gin.H{\n\t\t\t\t\"success\": true,\n\t\t\t\t\"message\": \"Gemini 渠道促销期已清除\",\n\t\t\t})\n\t\t} else {\n\t\t\tc.JSON(200, gin.H{\n\t\t\t\t\"success\":  true,\n\t\t\t\t\"message\":  \"Gemini 渠道促销期已设置\",\n\t\t\t\t\"duration\": req.Duration,\n\t\t\t})\n\t\t}\n\t}\n}\n\n// PingChannel 测试 Gemini 渠道连通性\nfunc PingChannel(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid channel ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tcfg := cfgManager.GetConfig()\n\t\tif id < 0 || id >= len(cfg.GeminiUpstream) {\n\t\t\tc.JSON(404, gin.H{\"error\": \"Channel not found\"})\n\t\t\treturn\n\t\t}\n\n\t\tupstream := cfg.GeminiUpstream[id]\n\t\tbaseURL := upstream.GetEffectiveBaseURL()\n\t\tif baseURL == \"\" {\n\t\t\tc.JSON(400, gin.H{\"error\": \"No base URL configured\"})\n\t\t\treturn\n\t\t}\n\n\t\t// 简单的连通性测试\n\t\tclient := &http.Client{Timeout: 10 * time.Second}\n\t\ttestURL := fmt.Sprintf(\"%s/v1beta/models\", strings.TrimRight(baseURL, \"/\"))\n\n\t\treq, _ := http.NewRequest(\"GET\", testURL, nil)\n\t\tif len(upstream.APIKeys) > 0 {\n\t\t\treq.Header.Set(\"x-goog-api-key\", upstream.APIKeys[0])\n\t\t}\n\n\t\tstart := time.Now()\n\t\tresp, err := client.Do(req)\n\t\tlatency := time.Since(start).Milliseconds()\n\n\t\tif err != nil {\n\t\t\tc.JSON(200, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"error\":   err.Error(),\n\t\t\t\t\"latency\": latency,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"success\":    resp.StatusCode >= 200 && resp.StatusCode < 400,\n\t\t\t\"statusCode\": resp.StatusCode,\n\t\t\t\"latency\":    latency,\n\t\t})\n\t}\n}\n\n// PingAllChannels 测试所有 Gemini 渠道连通性\nfunc PingAllChannels(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tcfg := cfgManager.GetConfig()\n\t\tresults := make([]gin.H, len(cfg.GeminiUpstream))\n\n\t\tclient := &http.Client{Timeout: 10 * time.Second}\n\n\t\tfor i, upstream := range cfg.GeminiUpstream {\n\t\t\tbaseURL := upstream.GetEffectiveBaseURL()\n\t\t\tif baseURL == \"\" {\n\t\t\t\tresults[i] = gin.H{\n\t\t\t\t\t\"index\":   i,\n\t\t\t\t\t\"name\":    upstream.Name,\n\t\t\t\t\t\"success\": false,\n\t\t\t\t\t\"error\":   \"No base URL configured\",\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\ttestURL := fmt.Sprintf(\"%s/v1beta/models\", strings.TrimRight(baseURL, \"/\"))\n\t\t\treq, _ := http.NewRequest(\"GET\", testURL, nil)\n\t\t\tif len(upstream.APIKeys) > 0 {\n\t\t\t\treq.Header.Set(\"x-goog-api-key\", upstream.APIKeys[0])\n\t\t\t}\n\n\t\t\tstart := time.Now()\n\t\t\tresp, err := client.Do(req)\n\t\t\tlatency := time.Since(start).Milliseconds()\n\n\t\t\tif err != nil {\n\t\t\t\tresults[i] = gin.H{\n\t\t\t\t\t\"index\":   i,\n\t\t\t\t\t\"name\":    upstream.Name,\n\t\t\t\t\t\"success\": false,\n\t\t\t\t\t\"error\":   err.Error(),\n\t\t\t\t\t\"latency\": latency,\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tresp.Body.Close()\n\n\t\t\tresults[i] = gin.H{\n\t\t\t\t\"index\":      i,\n\t\t\t\t\"name\":       upstream.Name,\n\t\t\t\t\"success\":    resp.StatusCode >= 200 && resp.StatusCode < 400,\n\t\t\t\t\"statusCode\": resp.StatusCode,\n\t\t\t\t\"latency\":    latency,\n\t\t\t}\n\t\t}\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"channels\": results,\n\t\t})\n\t}\n}\n\n// UpdateLoadBalance 更新 Gemini 负载均衡策略\nfunc UpdateLoadBalance(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tStrategy string `json:\"strategy\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid request body\"})\n\t\t\treturn\n\t\t}\n\n\t\tif err := cfgManager.SetGeminiLoadBalance(req.Strategy); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"success\":  true,\n\t\t\t\"message\":  \"Gemini 负载均衡策略已更新\",\n\t\t\t\"strategy\": req.Strategy,\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/gemini/dashboard.go",
    "content": "package gemini\n\nimport (\n\t\"github.com/gin-gonic/gin\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/metrics\"\n\t\"github.com/BenedictKing/claude-proxy/internal/scheduler\"\n)\n\n// GetDashboard 获取 Gemini 渠道仪表盘数据（合并 channels + metrics + stats + recentActivity）\n// GET /api/gemini/channels/dashboard\n// 将原本需要 3 个请求的数据合并为 1 个请求，减少网络开销\nfunc GetDashboard(cfgManager *config.ConfigManager, sch *scheduler.ChannelScheduler) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tcfg := cfgManager.GetConfig()\n\t\tupstreams := cfg.GeminiUpstream\n\t\tloadBalance := cfg.GeminiLoadBalance\n\t\tmetricsManager := sch.GetGeminiMetricsManager()\n\n\t\t// 1. 构建 channels 数据\n\t\tchannels := make([]gin.H, len(upstreams))\n\t\tfor i, up := range upstreams {\n\t\t\tstatus := config.GetChannelStatus(&up)\n\t\t\tpriority := config.GetChannelPriority(&up, i)\n\n\t\t\tchannels[i] = gin.H{\n\t\t\t\t\"index\":                       i,\n\t\t\t\t\"name\":                        up.Name,\n\t\t\t\t\"serviceType\":                 up.ServiceType,\n\t\t\t\t\"baseUrl\":                     up.BaseURL,\n\t\t\t\t\"baseUrls\":                    up.BaseURLs,\n\t\t\t\t\"apiKeys\":                     up.APIKeys,\n\t\t\t\t\"description\":                 up.Description,\n\t\t\t\t\"website\":                     up.Website,\n\t\t\t\t\"insecureSkipVerify\":          up.InsecureSkipVerify,\n\t\t\t\t\"modelMapping\":                up.ModelMapping,\n\t\t\t\t\"latency\":                     nil,\n\t\t\t\t\"status\":                      status,\n\t\t\t\t\"priority\":                    priority,\n\t\t\t\t\"promotionUntil\":              up.PromotionUntil,\n\t\t\t\t\"lowQuality\":                  up.LowQuality,\n\t\t\t\t\"injectDummyThoughtSignature\": up.InjectDummyThoughtSignature,\n\t\t\t\t\"stripThoughtSignature\":       up.StripThoughtSignature,\n\t\t\t}\n\t\t}\n\n\t\t// 2. 构建 metrics 数据\n\t\tmetricsResult := make([]gin.H, 0, len(upstreams))\n\t\tfor i, upstream := range upstreams {\n\t\t\tresp := metricsManager.ToResponseMultiURL(i, upstream.GetAllBaseURLs(), upstream.APIKeys, 0, upstream.HistoricalAPIKeys)\n\n\t\t\titem := gin.H{\n\t\t\t\t\"channelIndex\":        i,\n\t\t\t\t\"channelName\":         upstream.Name,\n\t\t\t\t\"requestCount\":        resp.RequestCount,\n\t\t\t\t\"successCount\":        resp.SuccessCount,\n\t\t\t\t\"failureCount\":        resp.FailureCount,\n\t\t\t\t\"successRate\":         resp.SuccessRate,\n\t\t\t\t\"errorRate\":           resp.ErrorRate,\n\t\t\t\t\"consecutiveFailures\": resp.ConsecutiveFailures,\n\t\t\t\t\"latency\":             resp.Latency,\n\t\t\t\t\"keyMetrics\":          resp.KeyMetrics,\n\t\t\t\t\"timeWindows\":         resp.TimeWindows,\n\t\t\t}\n\n\t\t\tif resp.LastSuccessAt != nil {\n\t\t\t\titem[\"lastSuccessAt\"] = *resp.LastSuccessAt\n\t\t\t}\n\t\t\tif resp.LastFailureAt != nil {\n\t\t\t\titem[\"lastFailureAt\"] = *resp.LastFailureAt\n\t\t\t}\n\t\t\tif resp.CircuitBrokenAt != nil {\n\t\t\t\titem[\"circuitBrokenAt\"] = *resp.CircuitBrokenAt\n\t\t\t}\n\n\t\t\tmetricsResult = append(metricsResult, item)\n\t\t}\n\n\t\t// 3. 构建 stats 数据\n\t\tstats := gin.H{\n\t\t\t\"multiChannelMode\":    sch.IsMultiChannelMode(scheduler.ChannelKindGemini),\n\t\t\t\"activeChannelCount\":  sch.GetActiveChannelCount(scheduler.ChannelKindGemini),\n\t\t\t\"traceAffinityCount\":  sch.GetTraceAffinityManager().Size(),\n\t\t\t\"traceAffinityTTL\":    sch.GetTraceAffinityManager().GetTTL().String(),\n\t\t\t\"failureThreshold\":    metricsManager.GetFailureThreshold() * 100,\n\t\t\t\"windowSize\":          metricsManager.GetWindowSize(),\n\t\t\t\"circuitRecoveryTime\": metricsManager.GetCircuitRecoveryTime().String(),\n\t\t}\n\n\t\t// 4. 构建 recentActivity 数据（最近 15 分钟分段活跃度）\n\t\trecentActivity := make([]*metrics.ChannelRecentActivity, len(upstreams))\n\t\tfor i, upstream := range upstreams {\n\t\t\trecentActivity[i] = metricsManager.GetRecentActivityMultiURL(i, upstream.GetAllBaseURLs(), upstream.APIKeys)\n\t\t}\n\n\t\t// 返回合并数据\n\t\tc.JSON(200, gin.H{\n\t\t\t\"channels\":       channels,\n\t\t\t\"loadBalance\":    loadBalance,\n\t\t\t\"metrics\":        metricsResult,\n\t\t\t\"stats\":          stats,\n\t\t\t\"recentActivity\": recentActivity,\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/gemini/dashboard_test.go",
    "content": "package gemini\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/metrics\"\n\t\"github.com/BenedictKing/claude-proxy/internal/scheduler\"\n\t\"github.com/BenedictKing/claude-proxy/internal/session\"\n\t\"github.com/BenedictKing/claude-proxy/internal/warmup\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc TestGetDashboard_IncludesStripThoughtSignature(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tcfg := config.Config{\n\t\tGeminiUpstream: []config.UpstreamConfig{\n\t\t\t{\n\t\t\t\tName:                  \"gemini-test\",\n\t\t\t\tServiceType:           \"gemini\",\n\t\t\t\tBaseURL:               \"https://example.com\",\n\t\t\t\tAPIKeys:               []string{\"test-key\"},\n\t\t\t\tStripThoughtSignature: true,\n\t\t\t},\n\t\t},\n\t\tGeminiLoadBalance: \"round-robin\",\n\t}\n\n\ttmpDir := t.TempDir()\n\tconfigFile := filepath.Join(tmpDir, \"config.json\")\n\tdata, err := json.MarshalIndent(cfg, \"\", \"  \")\n\tif err != nil {\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\tif err := os.WriteFile(configFile, data, 0644); err != nil {\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\n\tcfgManager, err := config.NewConfigManager(configFile)\n\tif err != nil {\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\tt.Cleanup(func() { cfgManager.Close() })\n\n\tmessagesMetrics := metrics.NewMetricsManager()\n\tresponsesMetrics := metrics.NewMetricsManager()\n\tgeminiMetrics := metrics.NewMetricsManager()\n\tt.Cleanup(func() {\n\t\tmessagesMetrics.Stop()\n\t\tresponsesMetrics.Stop()\n\t\tgeminiMetrics.Stop()\n\t})\n\n\ttraceAffinity := session.NewTraceAffinityManager()\n\turlManager := warmup.NewURLManager(30*time.Second, 3)\n\tsch := scheduler.NewChannelScheduler(cfgManager, messagesMetrics, responsesMetrics, geminiMetrics, traceAffinity, urlManager)\n\n\tr := gin.New()\n\tr.GET(\"/gemini/channels/dashboard\", GetDashboard(cfgManager, sch))\n\n\treq := httptest.NewRequest(http.MethodGet, \"/gemini/channels/dashboard\", nil)\n\tw := httptest.NewRecorder()\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status=%d, want=%d, body=%s\", w.Code, http.StatusOK, w.Body.String())\n\t}\n\n\tvar resp struct {\n\t\tChannels []map[string]any `json:\"channels\"`\n\t}\n\tif err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {\n\t\tt.Fatalf(\"解析响应失败: %v\", err)\n\t}\n\tif len(resp.Channels) != 1 {\n\t\tt.Fatalf(\"channels len=%d, want=1\", len(resp.Channels))\n\t}\n\n\tvalue, ok := resp.Channels[0][\"stripThoughtSignature\"]\n\tif !ok {\n\t\tt.Fatalf(\"响应缺少 stripThoughtSignature 字段: %v\", resp.Channels[0])\n\t}\n\tstrip, ok := value.(bool)\n\tif !ok {\n\t\tt.Fatalf(\"stripThoughtSignature 类型=%T, want=bool\", value)\n\t}\n\tif strip != true {\n\t\tt.Fatalf(\"stripThoughtSignature=%v, want=true\", strip)\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/gemini/handler.go",
    "content": "// Package gemini 提供 Gemini API 的处理器\npackage gemini\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/converters\"\n\t\"github.com/BenedictKing/claude-proxy/internal/handlers/common\"\n\t\"github.com/BenedictKing/claude-proxy/internal/middleware\"\n\t\"github.com/BenedictKing/claude-proxy/internal/scheduler\"\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n\t\"github.com/BenedictKing/claude-proxy/internal/utils\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// Handler Gemini API 代理处理器\n// 支持多渠道调度：当配置多个渠道时自动启用\nfunc Handler(\n\tenvCfg *config.EnvConfig,\n\tcfgManager *config.ConfigManager,\n\tchannelScheduler *scheduler.ChannelScheduler,\n) gin.HandlerFunc {\n\treturn gin.HandlerFunc(func(c *gin.Context) {\n\t\t// Gemini 代理端点统一使用代理访问密钥鉴权（x-api-key / Authorization: Bearer）\n\t\tmiddleware.ProxyAuthMiddleware(envCfg)(c)\n\t\tif c.IsAborted() {\n\t\t\treturn\n\t\t}\n\n\t\tstartTime := time.Now()\n\n\t\t// 读取原始请求体\n\t\tmaxBodySize := envCfg.MaxRequestBodySize\n\t\tbodyBytes, err := common.ReadRequestBody(c, maxBodySize)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\n\t\t// 解析 Gemini 请求\n\t\tvar geminiReq types.GeminiRequest\n\t\tif len(bodyBytes) > 0 {\n\t\t\tif err := json.Unmarshal(bodyBytes, &geminiReq); err != nil {\n\t\t\t\tc.JSON(400, types.GeminiError{\n\t\t\t\t\tError: types.GeminiErrorDetail{\n\t\t\t\t\t\tCode:    400,\n\t\t\t\t\t\tMessage: fmt.Sprintf(\"Invalid request body: %v\", err),\n\t\t\t\t\t\tStatus:  \"INVALID_ARGUMENT\",\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// 从 URL 路径提取模型名称\n\t\t// 格式: /v1/models/{model}:generateContent 或 /v1/models/{model}:streamGenerateContent\n\t\t// 使用 *modelAction 通配符捕获整个后缀，如 /gemini-pro:generateContent\n\t\tmodelAction := c.Param(\"modelAction\")\n\t\t// 移除前导斜杠（Gin 的 * 通配符会保留前导斜杠）\n\t\tmodelAction = strings.TrimPrefix(modelAction, \"/\")\n\t\tmodel := extractModelName(modelAction)\n\t\tif model == \"\" {\n\t\t\tc.JSON(400, types.GeminiError{\n\t\t\t\tError: types.GeminiErrorDetail{\n\t\t\t\t\tCode:    400,\n\t\t\t\t\tMessage: \"Model name is required in URL path\",\n\t\t\t\t\tStatus:  \"INVALID_ARGUMENT\",\n\t\t\t\t},\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\t// 判断是否流式\n\t\tisStream := strings.Contains(c.Request.URL.Path, \"streamGenerateContent\")\n\n\t\t// 提取对话标识用于 Trace 亲和性\n\t\tuserID := common.ExtractConversationID(c, bodyBytes)\n\n\t\t// 记录原始请求信息\n\t\tcommon.LogOriginalRequest(c, bodyBytes, envCfg, \"Gemini\")\n\n\t\t// 检查是否为多渠道模式\n\t\tisMultiChannel := channelScheduler.IsMultiChannelMode(scheduler.ChannelKindGemini)\n\n\t\tif isMultiChannel {\n\t\t\thandleMultiChannel(c, envCfg, cfgManager, channelScheduler, bodyBytes, &geminiReq, model, isStream, userID, startTime)\n\t\t} else {\n\t\t\thandleSingleChannel(c, envCfg, cfgManager, channelScheduler, bodyBytes, &geminiReq, model, isStream, startTime)\n\t\t}\n\t})\n}\n\n// extractModelName 从 URL 参数提取模型名称\n// 输入: \"gemini-2.0-flash:generateContent\" 或 \"gemini-2.0-flash\"\n// 输出: \"gemini-2.0-flash\"\nfunc extractModelName(param string) string {\n\tif param == \"\" {\n\t\treturn \"\"\n\t}\n\t// 移除 :generateContent 或 :streamGenerateContent 后缀\n\tif idx := strings.Index(param, \":\"); idx > 0 {\n\t\treturn param[:idx]\n\t}\n\treturn param\n}\n\n// handleMultiChannel 处理多渠道 Gemini 请求\nfunc handleMultiChannel(\n\tc *gin.Context,\n\tenvCfg *config.EnvConfig,\n\tcfgManager *config.ConfigManager,\n\tchannelScheduler *scheduler.ChannelScheduler,\n\tbodyBytes []byte,\n\tgeminiReq *types.GeminiRequest,\n\tmodel string,\n\tisStream bool,\n\tuserID string,\n\tstartTime time.Time,\n) {\n\tmetricsManager := channelScheduler.GetGeminiMetricsManager()\n\tcommon.HandleMultiChannelFailover(\n\t\tc,\n\t\tenvCfg,\n\t\tchannelScheduler,\n\t\tscheduler.ChannelKindGemini,\n\t\t\"Gemini\",\n\t\tuserID,\n\t\tfunc(selection *scheduler.SelectionResult) common.MultiChannelAttemptResult {\n\t\t\tupstream := selection.Upstream\n\t\t\tchannelIndex := selection.ChannelIndex\n\n\t\t\tif upstream == nil {\n\t\t\t\treturn common.MultiChannelAttemptResult{}\n\t\t\t}\n\n\t\t\tbaseURLs := upstream.GetAllBaseURLs()\n\t\t\tsortedURLResults := channelScheduler.GetSortedURLsForChannel(scheduler.ChannelKindGemini, channelIndex, baseURLs)\n\n\t\t\thandled, successKey, successBaseURLIdx, failoverErr, usage, lastErr := common.TryUpstreamWithAllKeys(\n\t\t\t\tc,\n\t\t\t\tenvCfg,\n\t\t\t\tcfgManager,\n\t\t\t\tchannelScheduler,\n\t\t\t\tscheduler.ChannelKindGemini,\n\t\t\t\t\"Gemini\",\n\t\t\t\tmetricsManager,\n\t\t\t\tupstream,\n\t\t\t\tsortedURLResults,\n\t\t\t\tbodyBytes,\n\t\t\t\tisStream,\n\t\t\t\tfunc(upstream *config.UpstreamConfig, failedKeys map[string]bool) (string, error) {\n\t\t\t\t\treturn cfgManager.GetNextGeminiAPIKey(upstream, failedKeys)\n\t\t\t\t},\n\t\t\t\tfunc(c *gin.Context, upstreamCopy *config.UpstreamConfig, apiKey string) (*http.Request, error) {\n\t\t\t\t\treturn buildProviderRequest(c, upstreamCopy, upstreamCopy.BaseURL, apiKey, geminiReq, model, isStream)\n\t\t\t\t},\n\t\t\t\tfunc(apiKey string) {\n\t\t\t\t\t_ = cfgManager.DeprioritizeAPIKey(apiKey)\n\t\t\t\t},\n\t\t\t\tfunc(url string) {\n\t\t\t\t\tchannelScheduler.MarkURLFailure(scheduler.ChannelKindGemini, channelIndex, url)\n\t\t\t\t},\n\t\t\t\tfunc(url string) {\n\t\t\t\t\tchannelScheduler.MarkURLSuccess(scheduler.ChannelKindGemini, channelIndex, url)\n\t\t\t\t},\n\t\t\t\tfunc(c *gin.Context, resp *http.Response, upstreamCopy *config.UpstreamConfig, apiKey string) (*types.Usage, error) {\n\t\t\t\t\treturn handleSuccess(c, resp, upstreamCopy.ServiceType, envCfg, startTime, geminiReq, model, isStream)\n\t\t\t\t},\n\t\t\t)\n\n\t\t\treturn common.MultiChannelAttemptResult{\n\t\t\t\tHandled:           handled,\n\t\t\t\tAttempted:         true,\n\t\t\t\tSuccessKey:        successKey,\n\t\t\t\tSuccessBaseURLIdx: successBaseURLIdx,\n\t\t\t\tFailoverError:     failoverErr,\n\t\t\t\tUsage:             usage,\n\t\t\t\tLastError:         lastErr,\n\t\t\t}\n\t\t},\n\t\tnil,\n\t\tfunc(ctx *gin.Context, failoverErr *common.FailoverError, lastError error) {\n\t\t\thandleAllChannelsFailed(ctx, failoverErr, lastError)\n\t\t},\n\t)\n}\n\n// handleSingleChannel 处理单渠道 Gemini 请求\nfunc handleSingleChannel(\n\tc *gin.Context,\n\tenvCfg *config.EnvConfig,\n\tcfgManager *config.ConfigManager,\n\tchannelScheduler *scheduler.ChannelScheduler,\n\tbodyBytes []byte,\n\tgeminiReq *types.GeminiRequest,\n\tmodel string,\n\tisStream bool,\n\tstartTime time.Time,\n) {\n\tupstream, err := cfgManager.GetCurrentGeminiUpstream()\n\tif err != nil {\n\t\tc.JSON(503, types.GeminiError{\n\t\t\tError: types.GeminiErrorDetail{\n\t\t\t\tCode:    503,\n\t\t\t\tMessage: \"No Gemini upstream configured\",\n\t\t\t\tStatus:  \"UNAVAILABLE\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\n\tif len(upstream.APIKeys) == 0 {\n\t\tc.JSON(503, types.GeminiError{\n\t\t\tError: types.GeminiErrorDetail{\n\t\t\t\tCode:    503,\n\t\t\t\tMessage: fmt.Sprintf(\"No API keys configured for upstream \\\"%s\\\"\", upstream.Name),\n\t\t\t\tStatus:  \"UNAVAILABLE\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\n\tmetricsManager := channelScheduler.GetGeminiMetricsManager()\n\tbaseURLs := upstream.GetAllBaseURLs()\n\turlResults := common.BuildDefaultURLResults(baseURLs)\n\n\thandled, _, _, lastFailoverError, _, lastError := common.TryUpstreamWithAllKeys(\n\t\tc,\n\t\tenvCfg,\n\t\tcfgManager,\n\t\tchannelScheduler,\n\t\tscheduler.ChannelKindGemini,\n\t\t\"Gemini\",\n\t\tmetricsManager,\n\t\tupstream,\n\t\turlResults,\n\t\tbodyBytes,\n\t\tisStream,\n\t\tfunc(upstream *config.UpstreamConfig, failedKeys map[string]bool) (string, error) {\n\t\t\treturn cfgManager.GetNextGeminiAPIKey(upstream, failedKeys)\n\t\t},\n\t\tfunc(c *gin.Context, upstreamCopy *config.UpstreamConfig, apiKey string) (*http.Request, error) {\n\t\t\treturn buildProviderRequest(c, upstreamCopy, upstreamCopy.BaseURL, apiKey, geminiReq, model, isStream)\n\t\t},\n\t\tfunc(apiKey string) {\n\t\t\t_ = cfgManager.DeprioritizeAPIKey(apiKey)\n\t\t},\n\t\tnil,\n\t\tnil,\n\t\tfunc(c *gin.Context, resp *http.Response, upstreamCopy *config.UpstreamConfig, apiKey string) (*types.Usage, error) {\n\t\t\treturn handleSuccess(c, resp, upstreamCopy.ServiceType, envCfg, startTime, geminiReq, model, isStream)\n\t\t},\n\t)\n\tif handled {\n\t\treturn\n\t}\n\n\tlog.Printf(\"[Gemini-Error] 所有 API密钥都失败了\")\n\thandleAllKeysFailed(c, lastFailoverError, lastError)\n}\n\n// ensureThoughtSignatures 确保所有 functionCall 都有 thought_signature 字段\n// 用于兼容 x666.me 等要求必须有该字段的第三方 API\n// 参考: https://ai.google.dev/gemini-api/docs/thought-signatures\n//\n// 行为：\n//   - 如果 functionCall 已有 thought_signature（非空），保留原始值\n//   - 如果 functionCall 没有 thought_signature（空字符串），填充 DummyThoughtSignature\n//\n// 使用场景：\n//   - x666.me 等第三方 API 会验证 thought_signature 字段必须存在\n//   - Gemini CLI 等客户端可能不会为所有 functionCall 提供 thought_signature\nfunc ensureThoughtSignatures(geminiReq *types.GeminiRequest) {\n\tfor i := range geminiReq.Contents {\n\t\tfor j := range geminiReq.Contents[i].Parts {\n\t\t\tpart := &geminiReq.Contents[i].Parts[j]\n\t\t\tif part.FunctionCall != nil && part.FunctionCall.ThoughtSignature == \"\" {\n\t\t\t\tpart.FunctionCall.ThoughtSignature = types.DummyThoughtSignature\n\t\t\t}\n\t\t}\n\t}\n}\n\n// stripThoughtSignature 移除所有 functionCall 的 thought_signature 字段\n// 用于兼容旧版 Gemini API（不支持该字段）\nfunc stripThoughtSignature(geminiReq *types.GeminiRequest) {\n\tfor i := range geminiReq.Contents {\n\t\tfor j := range geminiReq.Contents[i].Parts {\n\t\t\tpart := &geminiReq.Contents[i].Parts[j]\n\t\t\tif part.FunctionCall != nil {\n\t\t\t\t// 使用特殊标记表示需要完全移除字段\n\t\t\t\tpart.FunctionCall.ThoughtSignature = types.StripThoughtSignatureMarker\n\t\t\t}\n\t\t}\n\t}\n}\n\n// cloneGeminiRequest 深拷贝 GeminiRequest（通过 JSON 序列化/反序列化）\nfunc cloneGeminiRequest(req *types.GeminiRequest) *types.GeminiRequest {\n\tclone := &types.GeminiRequest{}\n\tdata, _ := json.Marshal(req)\n\tjson.Unmarshal(data, clone)\n\treturn clone\n}\n\n// buildProviderRequest 构建上游请求\nfunc buildProviderRequest(\n\tc *gin.Context,\n\tupstream *config.UpstreamConfig,\n\tbaseURL string,\n\tapiKey string,\n\tgeminiReq *types.GeminiRequest,\n\tmodel string,\n\tisStream bool,\n) (*http.Request, error) {\n\t// 应用模型映射\n\tmappedModel := config.RedirectModel(model, upstream)\n\n\tvar requestBody []byte\n\tvar url string\n\tvar err error\n\n\tswitch upstream.ServiceType {\n\tcase \"gemini\":\n\t\t// Gemini 上游：根据配置处理 thought_signature 字段\n\t\treqToUse := geminiReq\n\n\t\t// 优先处理 StripThoughtSignature（移除字段）\n\t\tif upstream.StripThoughtSignature {\n\t\t\treqCopy := cloneGeminiRequest(geminiReq)\n\t\t\tstripThoughtSignature(reqCopy)\n\t\t\treqToUse = reqCopy\n\t\t} else if upstream.InjectDummyThoughtSignature {\n\t\t\t// 给空签名注入 dummy 值（兼容 x666.me 等要求必须有该字段的 API）\n\t\t\treqCopy := cloneGeminiRequest(geminiReq)\n\t\t\tensureThoughtSignatures(reqCopy)\n\t\t\treqToUse = reqCopy\n\t\t}\n\t\t// else: 默认直接透传，不做任何修改\n\n\t\trequestBody, err = json.Marshal(reqToUse)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\taction := \"generateContent\"\n\t\tif isStream {\n\t\t\taction = \"streamGenerateContent\"\n\t\t}\n\t\turl = fmt.Sprintf(\"%s/v1beta/models/%s:%s\", strings.TrimRight(baseURL, \"/\"), mappedModel, action)\n\t\tif isStream {\n\t\t\turl += \"?alt=sse\"\n\t\t}\n\n\tcase \"claude\":\n\t\t// Claude 上游：需要转换\n\t\tclaudeReq, err := converters.GeminiToClaudeRequest(geminiReq, mappedModel)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tclaudeReq[\"stream\"] = isStream\n\t\trequestBody, err = json.Marshal(claudeReq)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\turl = fmt.Sprintf(\"%s/v1/messages\", strings.TrimRight(baseURL, \"/\"))\n\n\tcase \"openai\":\n\t\t// OpenAI 上游：需要转换\n\t\topenaiReq, err := converters.GeminiToOpenAIRequest(geminiReq, mappedModel)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\topenaiReq[\"stream\"] = isStream\n\t\trequestBody, err = json.Marshal(openaiReq)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\turl = fmt.Sprintf(\"%s/v1/chat/completions\", strings.TrimRight(baseURL, \"/\"))\n\n\tdefault:\n\t\t// 默认当作 Gemini 处理，根据配置处理 thought_signature 字段\n\t\treqToUse := geminiReq\n\n\t\t// 优先处理 StripThoughtSignature（移除字段）\n\t\tif upstream.StripThoughtSignature {\n\t\t\treqCopy := cloneGeminiRequest(geminiReq)\n\t\t\tstripThoughtSignature(reqCopy)\n\t\t\treqToUse = reqCopy\n\t\t} else if upstream.InjectDummyThoughtSignature {\n\t\t\t// 给空签名注入 dummy 值（兼容 x666.me 等要求必须有该字段的 API）\n\t\t\treqCopy := cloneGeminiRequest(geminiReq)\n\t\t\tensureThoughtSignatures(reqCopy)\n\t\t\treqToUse = reqCopy\n\t\t}\n\t\t// else: 默认直接透传，不做任何修改\n\n\t\trequestBody, err = json.Marshal(reqToUse)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\taction := \"generateContent\"\n\t\tif isStream {\n\t\t\taction = \"streamGenerateContent\"\n\t\t}\n\t\turl = fmt.Sprintf(\"%s/v1beta/models/%s:%s\", strings.TrimRight(baseURL, \"/\"), mappedModel, action)\n\t\tif isStream {\n\t\t\turl += \"?alt=sse\"\n\t\t}\n\t}\n\n\treq, err := http.NewRequestWithContext(c.Request.Context(), \"POST\", url, bytes.NewReader(requestBody))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 使用统一的头部处理逻辑（透明代理）\n\t// 保留客户端的大部分 headers，只移除/替换必要的认证和代理相关 headers\n\treq.Header = utils.PrepareUpstreamHeaders(c, req.URL.Host)\n\n\t// 设置 Content-Type（覆盖可能来自客户端的值）\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\t// 设置认证头\n\tswitch upstream.ServiceType {\n\tcase \"gemini\":\n\t\tutils.SetGeminiAuthenticationHeader(req.Header, apiKey)\n\tcase \"claude\":\n\t\tutils.SetAuthenticationHeader(req.Header, apiKey)\n\t\treq.Header.Set(\"anthropic-version\", \"2023-06-01\")\n\tcase \"openai\":\n\t\tutils.SetAuthenticationHeader(req.Header, apiKey)\n\tdefault:\n\t\tutils.SetGeminiAuthenticationHeader(req.Header, apiKey)\n\t}\n\n\treturn req, nil\n}\n\n// handleSuccess 处理成功的响应\nfunc handleSuccess(\n\tc *gin.Context,\n\tresp *http.Response,\n\tupstreamType string,\n\tenvCfg *config.EnvConfig,\n\tstartTime time.Time,\n\tgeminiReq *types.GeminiRequest,\n\tmodel string,\n\tisStream bool,\n) (*types.Usage, error) {\n\tdefer resp.Body.Close()\n\n\tif isStream {\n\t\treturn handleStreamSuccess(c, resp, upstreamType, envCfg, startTime, model), nil\n\t}\n\n\t// 非流式响应处理\n\tbodyBytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tc.JSON(500, types.GeminiError{\n\t\t\tError: types.GeminiErrorDetail{\n\t\t\t\tCode:    500,\n\t\t\t\tMessage: \"Failed to read response\",\n\t\t\t\tStatus:  \"INTERNAL\",\n\t\t\t},\n\t\t})\n\t\treturn nil, err\n\t}\n\n\tif envCfg.EnableResponseLogs {\n\t\tresponseTime := time.Since(startTime).Milliseconds()\n\t\tlog.Printf(\"[Gemini-Timing] 响应完成: %dms, 状态: %d\", responseTime, resp.StatusCode)\n\t}\n\n\t// 根据上游类型转换响应\n\tvar geminiResp *types.GeminiResponse\n\n\tswitch upstreamType {\n\tcase \"gemini\":\n\t\t// 直接解析 Gemini 响应\n\t\tif err := json.Unmarshal(bodyBytes, &geminiResp); err != nil {\n\t\t\tc.Data(resp.StatusCode, \"application/json\", bodyBytes)\n\t\t\treturn nil, nil\n\t\t}\n\n\tcase \"claude\":\n\t\t// 转换 Claude 响应为 Gemini 格式\n\t\tvar claudeResp map[string]interface{}\n\t\tif err := json.Unmarshal(bodyBytes, &claudeResp); err != nil {\n\t\t\tc.Data(resp.StatusCode, \"application/json\", bodyBytes)\n\t\t\treturn nil, nil\n\t\t}\n\t\tgeminiResp, err = converters.ClaudeResponseToGemini(claudeResp)\n\t\tif err != nil {\n\t\t\tc.Data(resp.StatusCode, \"application/json\", bodyBytes)\n\t\t\treturn nil, nil\n\t\t}\n\n\tcase \"openai\":\n\t\t// 转换 OpenAI 响应为 Gemini 格式\n\t\tvar openaiResp map[string]interface{}\n\t\tif err := json.Unmarshal(bodyBytes, &openaiResp); err != nil {\n\t\t\tc.Data(resp.StatusCode, \"application/json\", bodyBytes)\n\t\t\treturn nil, nil\n\t\t}\n\t\tgeminiResp, err = converters.OpenAIResponseToGemini(openaiResp)\n\t\tif err != nil {\n\t\t\tc.Data(resp.StatusCode, \"application/json\", bodyBytes)\n\t\t\treturn nil, nil\n\t\t}\n\n\tdefault:\n\t\t// 默认直接返回\n\t\tc.Data(resp.StatusCode, \"application/json\", bodyBytes)\n\t\treturn nil, nil\n\t}\n\n\t// 返回 Gemini 格式响应\n\trespBytes, err := json.Marshal(geminiResp)\n\tif err != nil {\n\t\tc.Data(resp.StatusCode, \"application/json\", bodyBytes)\n\t\treturn nil, nil\n\t}\n\n\tc.Data(resp.StatusCode, \"application/json\", respBytes)\n\n\t// 提取 usage 统计\n\tvar usage *types.Usage\n\tif geminiResp.UsageMetadata != nil {\n\t\tusage = &types.Usage{\n\t\t\tInputTokens:  geminiResp.UsageMetadata.PromptTokenCount - geminiResp.UsageMetadata.CachedContentTokenCount,\n\t\t\tOutputTokens: geminiResp.UsageMetadata.CandidatesTokenCount,\n\t\t}\n\t}\n\n\treturn usage, nil\n}\n\n// handleAllChannelsFailed 处理所有渠道失败的情况\nfunc handleAllChannelsFailed(c *gin.Context, failoverErr *common.FailoverError, lastError error) {\n\tif failoverErr != nil {\n\t\tc.Data(failoverErr.Status, \"application/json\", failoverErr.Body)\n\t\treturn\n\t}\n\n\terrMsg := \"All channels failed\"\n\tif lastError != nil {\n\t\terrMsg = lastError.Error()\n\t}\n\n\tc.JSON(503, types.GeminiError{\n\t\tError: types.GeminiErrorDetail{\n\t\t\tCode:    503,\n\t\t\tMessage: errMsg,\n\t\t\tStatus:  \"UNAVAILABLE\",\n\t\t},\n\t})\n}\n\n// handleAllKeysFailed 处理所有 Key 失败的情况\nfunc handleAllKeysFailed(c *gin.Context, failoverErr *common.FailoverError, lastError error) {\n\tif failoverErr != nil {\n\t\tc.Data(failoverErr.Status, \"application/json\", failoverErr.Body)\n\t\treturn\n\t}\n\n\terrMsg := \"All API keys failed\"\n\tif lastError != nil {\n\t\terrMsg = lastError.Error()\n\t}\n\n\tc.JSON(503, types.GeminiError{\n\t\tError: types.GeminiErrorDetail{\n\t\t\tCode:    503,\n\t\t\tMessage: errMsg,\n\t\t\tStatus:  \"UNAVAILABLE\",\n\t\t},\n\t})\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/gemini/handler_test.go",
    "content": "package gemini\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc TestHandler_RequiresProxyAccessKeyEvenWhenGeminiKeyProvided(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tenvCfg := &config.EnvConfig{\n\t\tProxyAccessKey:     \"secret-key\",\n\t\tMaxRequestBodySize: 1024 * 1024,\n\t}\n\n\tr := gin.New()\n\tr.POST(\"/v1beta/models/*modelAction\", Handler(envCfg, nil, nil))\n\n\tt.Run(\"x-goog-api-key does not bypass proxy auth\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodPost, \"/v1beta/models/gemini-2.0-flash:generateContent\", bytes.NewReader([]byte(`{}`)))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"x-goog-api-key\", \"any-gemini-key\")\n\t\tw := httptest.NewRecorder()\n\n\t\tr.ServeHTTP(w, req)\n\n\t\tif w.Code != http.StatusUnauthorized {\n\t\t\tt.Fatalf(\"status = %d, want %d\", w.Code, http.StatusUnauthorized)\n\t\t}\n\t})\n\n\tt.Run(\"query key does not bypass proxy auth\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodPost, \"/v1beta/models/gemini-2.0-flash:generateContent?key=any-gemini-key\", bytes.NewReader([]byte(`{}`)))\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tw := httptest.NewRecorder()\n\n\t\tr.ServeHTTP(w, req)\n\n\t\tif w.Code != http.StatusUnauthorized {\n\t\t\tt.Fatalf(\"status = %d, want %d\", w.Code, http.StatusUnauthorized)\n\t\t}\n\t})\n}\n\n// TestStripThoughtSignature 测试 stripThoughtSignature 函数\nfunc TestStripThoughtSignature(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    *types.GeminiRequest\n\t\texpected *types.GeminiRequest\n\t}{\n\t\t{\n\t\t\tname: \"移除单个 functionCall 的 thought_signature\",\n\t\t\tinput: &types.GeminiRequest{\n\t\t\t\tContents: []types.GeminiContent{\n\t\t\t\t\t{\n\t\t\t\t\t\tParts: []types.GeminiPart{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tFunctionCall: &types.GeminiFunctionCall{\n\t\t\t\t\t\t\t\t\tName:             \"test_function\",\n\t\t\t\t\t\t\t\t\tArgs:             map[string]interface{}{\"arg1\": \"value1\"},\n\t\t\t\t\t\t\t\t\tThoughtSignature: \"test_signature\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: &types.GeminiRequest{\n\t\t\t\tContents: []types.GeminiContent{\n\t\t\t\t\t{\n\t\t\t\t\t\tParts: []types.GeminiPart{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tFunctionCall: &types.GeminiFunctionCall{\n\t\t\t\t\t\t\t\t\tName:             \"test_function\",\n\t\t\t\t\t\t\t\t\tArgs:             map[string]interface{}{\"arg1\": \"value1\"},\n\t\t\t\t\t\t\t\t\tThoughtSignature: \"\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"移除多个 functionCall 的 thought_signature\",\n\t\t\tinput: &types.GeminiRequest{\n\t\t\t\tContents: []types.GeminiContent{\n\t\t\t\t\t{\n\t\t\t\t\t\tParts: []types.GeminiPart{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tFunctionCall: &types.GeminiFunctionCall{\n\t\t\t\t\t\t\t\t\tName:             \"func1\",\n\t\t\t\t\t\t\t\t\tArgs:             map[string]interface{}{},\n\t\t\t\t\t\t\t\t\tThoughtSignature: \"sig1\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tFunctionCall: &types.GeminiFunctionCall{\n\t\t\t\t\t\t\t\t\tName:             \"func2\",\n\t\t\t\t\t\t\t\t\tArgs:             map[string]interface{}{},\n\t\t\t\t\t\t\t\t\tThoughtSignature: \"sig2\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: &types.GeminiRequest{\n\t\t\t\tContents: []types.GeminiContent{\n\t\t\t\t\t{\n\t\t\t\t\t\tParts: []types.GeminiPart{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tFunctionCall: &types.GeminiFunctionCall{\n\t\t\t\t\t\t\t\t\tName:             \"func1\",\n\t\t\t\t\t\t\t\t\tArgs:             map[string]interface{}{},\n\t\t\t\t\t\t\t\t\tThoughtSignature: \"\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tFunctionCall: &types.GeminiFunctionCall{\n\t\t\t\t\t\t\t\t\tName:             \"func2\",\n\t\t\t\t\t\t\t\t\tArgs:             map[string]interface{}{},\n\t\t\t\t\t\t\t\t\tThoughtSignature: \"\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"不影响非 functionCall 的 parts\",\n\t\t\tinput: &types.GeminiRequest{\n\t\t\t\tContents: []types.GeminiContent{\n\t\t\t\t\t{\n\t\t\t\t\t\tParts: []types.GeminiPart{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tText: \"some text\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tFunctionCall: &types.GeminiFunctionCall{\n\t\t\t\t\t\t\t\t\tName:             \"func\",\n\t\t\t\t\t\t\t\t\tArgs:             map[string]interface{}{},\n\t\t\t\t\t\t\t\t\tThoughtSignature: \"sig\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: &types.GeminiRequest{\n\t\t\t\tContents: []types.GeminiContent{\n\t\t\t\t\t{\n\t\t\t\t\t\tParts: []types.GeminiPart{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tText: \"some text\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tFunctionCall: &types.GeminiFunctionCall{\n\t\t\t\t\t\t\t\t\tName:             \"func\",\n\t\t\t\t\t\t\t\t\tArgs:             map[string]interface{}{},\n\t\t\t\t\t\t\t\t\tThoughtSignature: \"\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"处理空 thought_signature\",\n\t\t\tinput: &types.GeminiRequest{\n\t\t\t\tContents: []types.GeminiContent{\n\t\t\t\t\t{\n\t\t\t\t\t\tParts: []types.GeminiPart{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tFunctionCall: &types.GeminiFunctionCall{\n\t\t\t\t\t\t\t\t\tName:             \"func\",\n\t\t\t\t\t\t\t\t\tArgs:             map[string]interface{}{},\n\t\t\t\t\t\t\t\t\tThoughtSignature: \"\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: &types.GeminiRequest{\n\t\t\t\tContents: []types.GeminiContent{\n\t\t\t\t\t{\n\t\t\t\t\t\tParts: []types.GeminiPart{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tFunctionCall: &types.GeminiFunctionCall{\n\t\t\t\t\t\t\t\t\tName:             \"func\",\n\t\t\t\t\t\t\t\t\tArgs:             map[string]interface{}{},\n\t\t\t\t\t\t\t\t\tThoughtSignature: \"\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tstripThoughtSignature(tt.input)\n\n\t\t\t// 验证结果\n\t\t\tif len(tt.input.Contents) != len(tt.expected.Contents) {\n\t\t\t\tt.Fatalf(\"Contents length mismatch: got %d, want %d\", len(tt.input.Contents), len(tt.expected.Contents))\n\t\t\t}\n\n\t\t\tfor i := range tt.input.Contents {\n\t\t\t\tif len(tt.input.Contents[i].Parts) != len(tt.expected.Contents[i].Parts) {\n\t\t\t\t\tt.Fatalf(\"Parts length mismatch at content %d: got %d, want %d\", i, len(tt.input.Contents[i].Parts), len(tt.expected.Contents[i].Parts))\n\t\t\t\t}\n\n\t\t\t\tfor j := range tt.input.Contents[i].Parts {\n\t\t\t\t\tinputPart := &tt.input.Contents[i].Parts[j]\n\t\t\t\t\texpectedPart := &tt.expected.Contents[i].Parts[j]\n\n\t\t\t\t\tif inputPart.FunctionCall != nil {\n\t\t\t\t\t\tif expectedPart.FunctionCall == nil {\n\t\t\t\t\t\t\tt.Fatalf(\"FunctionCall mismatch at content %d, part %d: got non-nil, want nil\", i, j)\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// stripThoughtSignature 使用特殊标记而不是空字符串\n\t\t\t\t\t\tif inputPart.FunctionCall.ThoughtSignature != types.StripThoughtSignatureMarker {\n\t\t\t\t\t\t\tt.Errorf(\"ThoughtSignature mismatch at content %d, part %d: got %q, want %q\",\n\t\t\t\t\t\t\t\ti, j, inputPart.FunctionCall.ThoughtSignature, types.StripThoughtSignatureMarker)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestBuildProviderRequest_StripThoughtSignature 测试 buildProviderRequest 中的 StripThoughtSignature 配置\nfunc TestBuildProviderRequest_StripThoughtSignature(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\ttests := []struct {\n\t\tname                     string\n\t\tstripThoughtSignature    bool\n\t\tinjectDummyThoughtSig    bool\n\t\tinputThoughtSignature    string\n\t\texpectedThoughtSignature string\n\t}{\n\t\t{\n\t\t\tname:                     \"StripThoughtSignature=true 移除字段\",\n\t\t\tstripThoughtSignature:    true,\n\t\t\tinjectDummyThoughtSig:    false,\n\t\t\tinputThoughtSignature:    \"test_signature\",\n\t\t\texpectedThoughtSignature: \"\",\n\t\t},\n\t\t{\n\t\t\tname:                     \"默认行为：透传非空签名\",\n\t\t\tstripThoughtSignature:    false,\n\t\t\tinjectDummyThoughtSig:    false,\n\t\t\tinputThoughtSignature:    \"test_signature\",\n\t\t\texpectedThoughtSignature: \"test_signature\",\n\t\t},\n\t\t{\n\t\t\tname:                     \"默认行为：完全透传空签名\",\n\t\t\tstripThoughtSignature:    false,\n\t\t\tinjectDummyThoughtSig:    false,\n\t\t\tinputThoughtSignature:    \"\",\n\t\t\texpectedThoughtSignature: \"\",\n\t\t},\n\t\t{\n\t\t\tname:                     \"InjectDummyThoughtSignature=true 注入 dummy\",\n\t\t\tstripThoughtSignature:    false,\n\t\t\tinjectDummyThoughtSig:    true,\n\t\t\tinputThoughtSignature:    \"\",\n\t\t\texpectedThoughtSignature: types.DummyThoughtSignature,\n\t\t},\n\t\t{\n\t\t\tname:                     \"StripThoughtSignature=true 优先于 InjectDummyThoughtSignature\",\n\t\t\tstripThoughtSignature:    true,\n\t\t\tinjectDummyThoughtSig:    true,\n\t\t\tinputThoughtSignature:    \"test_signature\",\n\t\t\texpectedThoughtSignature: \"\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tupstream := &config.UpstreamConfig{\n\t\t\t\tBaseURL:                     \"https://test.example.com\",\n\t\t\t\tServiceType:                 \"gemini\",\n\t\t\t\tStripThoughtSignature:       tt.stripThoughtSignature,\n\t\t\t\tInjectDummyThoughtSignature: tt.injectDummyThoughtSig,\n\t\t\t}\n\n\t\t\tgeminiReq := &types.GeminiRequest{\n\t\t\t\tContents: []types.GeminiContent{\n\t\t\t\t\t{\n\t\t\t\t\t\tParts: []types.GeminiPart{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tFunctionCall: &types.GeminiFunctionCall{\n\t\t\t\t\t\t\t\t\tName:             \"test_function\",\n\t\t\t\t\t\t\t\t\tArgs:             map[string]interface{}{\"arg1\": \"value1\"},\n\t\t\t\t\t\t\t\t\tThoughtSignature: tt.inputThoughtSignature,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tc, _ := gin.CreateTestContext(httptest.NewRecorder())\n\t\t\tc.Request = httptest.NewRequest(http.MethodPost, \"/test\", nil)\n\n\t\t\treq, err := buildProviderRequest(c, upstream, upstream.BaseURL, \"test-key\", geminiReq, \"gemini-2.0-flash\", false)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"buildProviderRequest failed: %v\", err)\n\t\t\t}\n\n\t\t\t// 解析请求体\n\t\t\tvar resultReq types.GeminiRequest\n\t\t\tif err := json.NewDecoder(req.Body).Decode(&resultReq); err != nil {\n\t\t\t\tt.Fatalf(\"Failed to decode request body: %v\", err)\n\t\t\t}\n\n\t\t\t// 验证 thought_signature\n\t\t\tif len(resultReq.Contents) == 0 || len(resultReq.Contents[0].Parts) == 0 {\n\t\t\t\tt.Fatal(\"Request body is empty\")\n\t\t\t}\n\n\t\t\tpart := resultReq.Contents[0].Parts[0]\n\t\t\tif part.FunctionCall == nil {\n\t\t\t\tt.Fatal(\"FunctionCall is nil\")\n\t\t\t}\n\n\t\t\tif part.FunctionCall.ThoughtSignature != tt.expectedThoughtSignature {\n\t\t\t\tt.Errorf(\"ThoughtSignature mismatch: got %q, want %q\",\n\t\t\t\t\tpart.FunctionCall.ThoughtSignature, tt.expectedThoughtSignature)\n\t\t\t}\n\n\t\t\t// 验证原始请求未被修改（深拷贝机制）\n\t\t\tif geminiReq.Contents[0].Parts[0].FunctionCall.ThoughtSignature != tt.inputThoughtSignature {\n\t\t\t\tt.Errorf(\"Original request was modified: got %q, want %q\",\n\t\t\t\t\tgeminiReq.Contents[0].Parts[0].FunctionCall.ThoughtSignature, tt.inputThoughtSignature)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildProviderRequest_InjectDummyThoughtSignature_PreservesThoughtSignatureAtPartLevel(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tupstream := &config.UpstreamConfig{\n\t\tBaseURL:                     \"https://test.example.com\",\n\t\tServiceType:                 \"gemini\",\n\t\tStripThoughtSignature:       false,\n\t\tInjectDummyThoughtSignature: true,\n\t}\n\n\t// 模拟 Gemini CLI：thoughtSignature 出现在 part 层级（而非 functionCall 内部）\n\tvar geminiReq types.GeminiRequest\n\tif err := json.Unmarshal([]byte(`{\n  \"contents\": [\n    {\n      \"parts\": [\n        {\n          \"functionCall\": {\n            \"name\": \"run_shell_command\",\n            \"args\": { \"command\": \"ls -R\" }\n          },\n          \"thoughtSignature\": \"sig_from_cli\"\n        }\n      ]\n    }\n  ]\n}`), &geminiReq); err != nil {\n\t\tt.Fatalf(\"Unmarshal 请求失败: %v\", err)\n\t}\n\n\tc, _ := gin.CreateTestContext(httptest.NewRecorder())\n\tc.Request = httptest.NewRequest(http.MethodPost, \"/test\", nil)\n\n\treq, err := buildProviderRequest(c, upstream, upstream.BaseURL, \"test-key\", &geminiReq, \"gemini-2.0-flash\", false)\n\tif err != nil {\n\t\tt.Fatalf(\"buildProviderRequest failed: %v\", err)\n\t}\n\n\tbodyBytes, err := io.ReadAll(req.Body)\n\tif err != nil {\n\t\tt.Fatalf(\"读取请求体失败: %v\", err)\n\t}\n\n\t// 解析为通用 map，验证字段名格式（thought_signature vs thoughtSignature）\n\tvar raw map[string]interface{}\n\tif err := json.Unmarshal(bodyBytes, &raw); err != nil {\n\t\tt.Fatalf(\"解析请求体 JSON 失败: %v\", err)\n\t}\n\n\tcontents, ok := raw[\"contents\"].([]interface{})\n\tif !ok || len(contents) != 1 {\n\t\tt.Fatalf(\"contents 解析失败: %T, len=%d\", raw[\"contents\"], len(contents))\n\t}\n\tcontent0, ok := contents[0].(map[string]interface{})\n\tif !ok {\n\t\tt.Fatalf(\"contents[0] 类型=%T, want=map[string]interface{}\", contents[0])\n\t}\n\tparts, ok := content0[\"parts\"].([]interface{})\n\tif !ok || len(parts) != 1 {\n\t\tt.Fatalf(\"parts 解析失败: %T, len=%d\", content0[\"parts\"], len(parts))\n\t}\n\tpart0, ok := parts[0].(map[string]interface{})\n\tif !ok {\n\t\tt.Fatalf(\"parts[0] 类型=%T, want=map[string]interface{}\", parts[0])\n\t}\n\tif v, exists := part0[\"thoughtSignature\"]; !exists || v != \"sig_from_cli\" {\n\t\tt.Fatalf(\"part.thoughtSignature=%v, want=%v\", v, \"sig_from_cli\")\n\t}\n\tif _, exists := part0[\"thought_signature\"]; exists {\n\t\tt.Fatalf(\"不应在 part 层级输出 thought_signature: %v\", part0)\n\t}\n\n\tfc, ok := part0[\"functionCall\"].(map[string]interface{})\n\tif !ok {\n\t\tt.Fatalf(\"functionCall 类型=%T, want=map[string]interface{}\", part0[\"functionCall\"])\n\t}\n\tif _, exists := fc[\"thoughtSignature\"]; exists {\n\t\tt.Fatalf(\"不应在 functionCall 内输出 thoughtSignature: %v\", fc)\n\t}\n\tif _, exists := fc[\"thought_signature\"]; exists {\n\t\tt.Fatalf(\"不应在 functionCall 内输出 thought_signature: %v\", fc)\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/gemini/stream.go",
    "content": "package gemini\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// handleStreamSuccess 处理流式响应\nfunc handleStreamSuccess(\n\tc *gin.Context,\n\tresp *http.Response,\n\tupstreamType string,\n\tenvCfg *config.EnvConfig,\n\tstartTime time.Time,\n\tmodel string,\n) *types.Usage {\n\t// 设置 SSE 响应头\n\tc.Header(\"Content-Type\", \"text/event-stream\")\n\tc.Header(\"Cache-Control\", \"no-cache\")\n\tc.Header(\"Connection\", \"keep-alive\")\n\tc.Header(\"X-Accel-Buffering\", \"no\")\n\n\tflusher, ok := c.Writer.(http.Flusher)\n\tif !ok {\n\t\tlog.Printf(\"[Gemini-Stream] 警告: ResponseWriter 不支持 Flusher\")\n\t}\n\n\tvar totalUsage *types.Usage\n\n\tswitch upstreamType {\n\tcase \"gemini\":\n\t\ttotalUsage = streamGeminiToGemini(c, resp, flusher, envCfg)\n\tcase \"claude\":\n\t\ttotalUsage = streamClaudeToGemini(c, resp, flusher, envCfg, model)\n\tcase \"openai\":\n\t\ttotalUsage = streamOpenAIToGemini(c, resp, flusher, envCfg, model)\n\tdefault:\n\t\t// 默认透传\n\t\ttotalUsage = streamGeminiToGemini(c, resp, flusher, envCfg)\n\t}\n\n\tif envCfg.EnableResponseLogs {\n\t\tresponseTime := time.Since(startTime).Milliseconds()\n\t\tlog.Printf(\"[Gemini-Stream-Timing] 流式响应完成: %dms\", responseTime)\n\t}\n\n\treturn totalUsage\n}\n\n// streamGeminiToGemini Gemini 上游直接透传\nfunc streamGeminiToGemini(\n\tc *gin.Context,\n\tresp *http.Response,\n\tflusher http.Flusher,\n\tenvCfg *config.EnvConfig,\n) *types.Usage {\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB buffer\n\n\tvar totalUsage *types.Usage\n\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\n\t\t// 直接转发 SSE 数据\n\t\tif strings.HasPrefix(line, \"data: \") {\n\t\t\tjsonData := strings.TrimPrefix(line, \"data: \")\n\n\t\t\t// 尝试解析 usage\n\t\t\tvar chunk types.GeminiStreamChunk\n\t\t\tif err := json.Unmarshal([]byte(jsonData), &chunk); err == nil {\n\t\t\t\tif chunk.UsageMetadata != nil {\n\t\t\t\t\ttotalUsage = &types.Usage{\n\t\t\t\t\t\tInputTokens:  chunk.UsageMetadata.PromptTokenCount - chunk.UsageMetadata.CachedContentTokenCount,\n\t\t\t\t\t\tOutputTokens: chunk.UsageMetadata.CandidatesTokenCount,\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfmt.Fprintf(c.Writer, \"%s\\n\", line)\n\t\t} else if line != \"\" {\n\t\t\tfmt.Fprintf(c.Writer, \"%s\\n\", line)\n\t\t} else {\n\t\t\tfmt.Fprintf(c.Writer, \"\\n\")\n\t\t}\n\n\t\tif flusher != nil {\n\t\t\tflusher.Flush()\n\t\t}\n\t}\n\n\treturn totalUsage\n}\n\n// streamClaudeToGemini Claude 流式响应转换为 Gemini 格式\nfunc streamClaudeToGemini(\n\tc *gin.Context,\n\tresp *http.Response,\n\tflusher http.Flusher,\n\tenvCfg *config.EnvConfig,\n\tmodel string,\n) *types.Usage {\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 1024*1024), 1024*1024)\n\n\tvar totalUsage *types.Usage\n\tvar currentText strings.Builder\n\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\n\t\tjsonData := strings.TrimPrefix(line, \"data: \")\n\t\tif jsonData == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar event map[string]interface{}\n\t\tif err := json.Unmarshal([]byte(jsonData), &event); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\teventType, _ := event[\"type\"].(string)\n\n\t\tswitch eventType {\n\t\tcase \"content_block_delta\":\n\t\t\t// 文本增量\n\t\t\tdelta, ok := event[\"delta\"].(map[string]interface{})\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeltaType, _ := delta[\"type\"].(string)\n\t\t\tif deltaType == \"text_delta\" {\n\t\t\t\ttext, _ := delta[\"text\"].(string)\n\t\t\t\tcurrentText.WriteString(text)\n\n\t\t\t\t// 转换为 Gemini 格式\n\t\t\t\tgeminiChunk := types.GeminiStreamChunk{\n\t\t\t\t\tCandidates: []types.GeminiCandidate{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tContent: &types.GeminiContent{\n\t\t\t\t\t\t\t\tParts: []types.GeminiPart{\n\t\t\t\t\t\t\t\t\t{Text: text},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\tRole: \"model\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t}\n\n\t\t\t\tchunkBytes, _ := json.Marshal(geminiChunk)\n\t\t\t\tfmt.Fprintf(c.Writer, \"data: %s\\n\\n\", string(chunkBytes))\n\t\t\t\tif flusher != nil {\n\t\t\t\t\tflusher.Flush()\n\t\t\t\t}\n\t\t\t}\n\n\t\tcase \"message_delta\":\n\t\t\t// 消息完成，包含 usage\n\t\t\tif usage, ok := event[\"usage\"].(map[string]interface{}); ok {\n\t\t\t\tinputTokens := 0\n\t\t\t\toutputTokens := 0\n\t\t\t\tif v, ok := usage[\"input_tokens\"].(float64); ok {\n\t\t\t\t\tinputTokens = int(v)\n\t\t\t\t}\n\t\t\t\tif v, ok := usage[\"output_tokens\"].(float64); ok {\n\t\t\t\t\toutputTokens = int(v)\n\t\t\t\t}\n\t\t\t\ttotalUsage = &types.Usage{\n\t\t\t\t\tInputTokens:  inputTokens,\n\t\t\t\t\tOutputTokens: outputTokens,\n\t\t\t\t}\n\n\t\t\t\t// 发送带 finishReason 和 usage 的最终块\n\t\t\t\tgeminiChunk := types.GeminiStreamChunk{\n\t\t\t\t\tCandidates: []types.GeminiCandidate{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tFinishReason: \"STOP\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tUsageMetadata: &types.GeminiUsageMetadata{\n\t\t\t\t\t\tPromptTokenCount:     inputTokens,\n\t\t\t\t\t\tCandidatesTokenCount: outputTokens,\n\t\t\t\t\t\tTotalTokenCount:      inputTokens + outputTokens,\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t\tchunkBytes, _ := json.Marshal(geminiChunk)\n\t\t\t\tfmt.Fprintf(c.Writer, \"data: %s\\n\\n\", string(chunkBytes))\n\t\t\t\tif flusher != nil {\n\t\t\t\t\tflusher.Flush()\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn totalUsage\n}\n\n// streamOpenAIToGemini OpenAI 流式响应转换为 Gemini 格式\nfunc streamOpenAIToGemini(\n\tc *gin.Context,\n\tresp *http.Response,\n\tflusher http.Flusher,\n\tenvCfg *config.EnvConfig,\n\tmodel string,\n) *types.Usage {\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 1024*1024), 1024*1024)\n\n\tvar totalUsage *types.Usage\n\tvar currentText strings.Builder\n\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\n\t\tjsonData := strings.TrimPrefix(line, \"data: \")\n\t\tif jsonData == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar chunk map[string]interface{}\n\t\tif err := json.Unmarshal([]byte(jsonData), &chunk); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tchoices, ok := chunk[\"choices\"].([]interface{})\n\t\tif !ok || len(choices) == 0 {\n\t\t\t// 检查是否有 usage（某些 OpenAI 兼容 API 在最后发送）\n\t\t\tif usage, ok := chunk[\"usage\"].(map[string]interface{}); ok {\n\t\t\t\tpromptTokens := 0\n\t\t\t\tcompletionTokens := 0\n\t\t\t\tif v, ok := usage[\"prompt_tokens\"].(float64); ok {\n\t\t\t\t\tpromptTokens = int(v)\n\t\t\t\t}\n\t\t\t\tif v, ok := usage[\"completion_tokens\"].(float64); ok {\n\t\t\t\t\tcompletionTokens = int(v)\n\t\t\t\t}\n\t\t\t\ttotalUsage = &types.Usage{\n\t\t\t\t\tInputTokens:  promptTokens,\n\t\t\t\t\tOutputTokens: completionTokens,\n\t\t\t\t}\n\n\t\t\t\t// 发送带 usage 的最终块\n\t\t\t\tgeminiChunk := types.GeminiStreamChunk{\n\t\t\t\t\tUsageMetadata: &types.GeminiUsageMetadata{\n\t\t\t\t\t\tPromptTokenCount:     promptTokens,\n\t\t\t\t\t\tCandidatesTokenCount: completionTokens,\n\t\t\t\t\t\tTotalTokenCount:      promptTokens + completionTokens,\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t\tchunkBytes, _ := json.Marshal(geminiChunk)\n\t\t\t\tfmt.Fprintf(c.Writer, \"data: %s\\n\\n\", string(chunkBytes))\n\t\t\t\tif flusher != nil {\n\t\t\t\t\tflusher.Flush()\n\t\t\t\t}\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tchoice, ok := choices[0].(map[string]interface{})\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 检查 finish_reason\n\t\tfinishReason, hasFinish := choice[\"finish_reason\"].(string)\n\n\t\t// 获取 delta\n\t\tdelta, ok := choice[\"delta\"].(map[string]interface{})\n\t\tif !ok {\n\t\t\tif hasFinish && finishReason != \"\" {\n\t\t\t\t// 发送 finishReason\n\t\t\t\tgeminiFinishReason := openaiFinishReasonToGemini(finishReason)\n\t\t\t\tgeminiChunk := types.GeminiStreamChunk{\n\t\t\t\t\tCandidates: []types.GeminiCandidate{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tFinishReason: geminiFinishReason,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t\tchunkBytes, _ := json.Marshal(geminiChunk)\n\t\t\t\tfmt.Fprintf(c.Writer, \"data: %s\\n\\n\", string(chunkBytes))\n\t\t\t\tif flusher != nil {\n\t\t\t\t\tflusher.Flush()\n\t\t\t\t}\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\t// 提取文本内容\n\t\tcontent, _ := delta[\"content\"].(string)\n\t\tif content != \"\" {\n\t\t\tcurrentText.WriteString(content)\n\n\t\t\tgeminiChunk := types.GeminiStreamChunk{\n\t\t\t\tCandidates: []types.GeminiCandidate{\n\t\t\t\t\t{\n\t\t\t\t\t\tContent: &types.GeminiContent{\n\t\t\t\t\t\t\tParts: []types.GeminiPart{\n\t\t\t\t\t\t\t\t{Text: content},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tRole: \"model\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tchunkBytes, _ := json.Marshal(geminiChunk)\n\t\t\tfmt.Fprintf(c.Writer, \"data: %s\\n\\n\", string(chunkBytes))\n\t\t\tif flusher != nil {\n\t\t\t\tflusher.Flush()\n\t\t\t}\n\t\t}\n\n\t\t// 如果有 finish_reason，发送\n\t\tif hasFinish && finishReason != \"\" {\n\t\t\tgeminiFinishReason := openaiFinishReasonToGemini(finishReason)\n\t\t\tgeminiChunk := types.GeminiStreamChunk{\n\t\t\t\tCandidates: []types.GeminiCandidate{\n\t\t\t\t\t{\n\t\t\t\t\t\tFinishReason: geminiFinishReason,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}\n\t\t\tchunkBytes, _ := json.Marshal(geminiChunk)\n\t\t\tfmt.Fprintf(c.Writer, \"data: %s\\n\\n\", string(chunkBytes))\n\t\t\tif flusher != nil {\n\t\t\t\tflusher.Flush()\n\t\t\t}\n\t\t}\n\t}\n\n\treturn totalUsage\n}\n\n// openaiFinishReasonToGemini 将 OpenAI 停止原因转换为 Gemini 格式\nfunc openaiFinishReasonToGemini(finishReason string) string {\n\tswitch finishReason {\n\tcase \"stop\":\n\t\treturn \"STOP\"\n\tcase \"length\":\n\t\treturn \"MAX_TOKENS\"\n\tcase \"tool_calls\":\n\t\treturn \"STOP\"\n\tcase \"content_filter\":\n\t\treturn \"SAFETY\"\n\tdefault:\n\t\treturn \"STOP\"\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/global_stats_handler.go",
    "content": "package handlers\n\nimport (\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/metrics\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// GetGlobalStatsHistory 获取全局统计历史数据\n// GET /api/{messages|responses}/global/stats/history?duration={1h|6h|24h|today}\nfunc GetGlobalStatsHistory(metricsManager *metrics.MetricsManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// 解析 duration 参数\n\t\tdurationStr := c.DefaultQuery(\"duration\", \"24h\")\n\n\t\tvar duration time.Duration\n\t\tvar err error\n\n\t\t// 特殊处理 \"today\" 参数\n\t\tif durationStr == \"today\" {\n\t\t\tduration = metrics.CalculateTodayDuration()\n\t\t\t// 如果刚过零点，duration 可能非常小，设置最小值\n\t\t\tif duration < time.Minute {\n\t\t\t\tduration = time.Minute\n\t\t\t}\n\t\t} else {\n\t\t\tduration, err = time.ParseDuration(durationStr)\n\t\t\tif err != nil {\n\t\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid duration parameter. Use: 1h, 6h, 24h, or today\"})\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// 限制最大查询范围为 24 小时\n\t\tif duration > 24*time.Hour {\n\t\t\tduration = 24 * time.Hour\n\t\t}\n\n\t\t// 解析或自动选择 interval\n\t\tintervalStr := c.Query(\"interval\")\n\t\tvar interval time.Duration\n\t\tif intervalStr != \"\" {\n\t\t\tinterval, err = time.ParseDuration(intervalStr)\n\t\t\tif err != nil {\n\t\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid interval parameter\"})\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// 限制 interval 最小值为 1 分钟，防止生成过多 bucket\n\t\t\tif interval < time.Minute {\n\t\t\t\tinterval = time.Minute\n\t\t\t}\n\t\t} else {\n\t\t\t// 根据 duration 自动选择合适的聚合粒度\n\t\t\t// 目标：每个时间段约 60-100 个数据点，保持图表清晰\n\t\t\t// 1h = 60 points (1m interval)\n\t\t\t// 6h = 72 points (5m interval)\n\t\t\t// 24h = 96 points (15m interval)\n\t\t\tswitch {\n\t\t\tcase duration <= time.Hour:\n\t\t\t\tinterval = time.Minute\n\t\t\tcase duration <= 6*time.Hour:\n\t\t\t\tinterval = 5 * time.Minute\n\t\t\tdefault:\n\t\t\t\tinterval = 15 * time.Minute\n\t\t\t}\n\t\t}\n\n\t\t// 获取全局统计数据\n\t\tresult := metricsManager.GetGlobalHistoricalStatsWithTokens(duration, interval)\n\n\t\t// 更新 duration 字符串（特别是 today 情况）\n\t\tif durationStr == \"today\" {\n\t\t\tresult.Summary.Duration = \"today\"\n\t\t}\n\n\t\tc.JSON(200, result)\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/health.go",
    "content": "package handlers\n\nimport (\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// HealthCheck 健康检查处理器\nfunc HealthCheck(envCfg *config.EnvConfig, cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tconfig := cfgManager.GetConfig()\n\n\t\thealthData := gin.H{\n\t\t\t\"status\":    \"healthy\",\n\t\t\t\"timestamp\": time.Now().Format(time.RFC3339),\n\t\t\t\"uptime\":    time.Since(startTime).Seconds(),\n\t\t\t\"mode\":      envCfg.Env,\n\t\t\t\"version\":   getVersion(),\n\t\t\t\"config\": gin.H{\n\t\t\t\t\"upstreamCount\":        len(config.Upstream),\n\t\t\t\t\"loadBalance\":          config.LoadBalance,\n\t\t\t\t\"responsesLoadBalance\": config.ResponsesLoadBalance,\n\t\t\t},\n\t\t}\n\n\t\tc.JSON(200, healthData)\n\t}\n}\n\n// getVersion 获取版本信息\nfunc getVersion() gin.H {\n\t// 这些变量在编译时通过 -ldflags 注入\n\t// 从根目录 VERSION 文件读取\n\treturn gin.H{\n\t\t\"version\":   getVersionString(),\n\t\t\"buildTime\": getBuildTime(),\n\t\t\"gitCommit\": getGitCommit(),\n\t}\n}\n\n// 以下函数用于从 main 包获取版本信息\n// 由于无法直接导入 main 包，使用默认值\nvar (\n\tversionString = \"v0.0.0-dev\"\n\tbuildTime     = \"unknown\"\n\tgitCommit     = \"unknown\"\n)\n\nfunc getVersionString() string { return versionString }\nfunc getBuildTime() string     { return buildTime }\nfunc getGitCommit() string     { return gitCommit }\n\n// SetVersionInfo 设置版本信息（从 main 调用）\nfunc SetVersionInfo(version, build, commit string) {\n\tversionString = version\n\tbuildTime = build\n\tgitCommit = commit\n}\n\n// SaveConfigHandler 配置保存处理器\nfunc SaveConfigHandler(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tif err := cfgManager.SaveConfig(); err != nil {\n\t\t\tc.JSON(500, gin.H{\n\t\t\t\t\"status\":    \"error\",\n\t\t\t\t\"message\":   \"配置保存失败\",\n\t\t\t\t\"error\":     err.Error(),\n\t\t\t\t\"timestamp\": time.Now().Format(time.RFC3339),\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tconfig := cfgManager.GetConfig()\n\t\tc.JSON(200, gin.H{\n\t\t\t\"status\":    \"success\",\n\t\t\t\"message\":   \"配置已保存\",\n\t\t\t\"timestamp\": time.Now().Format(time.RFC3339),\n\t\t\t\"config\": gin.H{\n\t\t\t\t\"upstreamCount\":        len(config.Upstream),\n\t\t\t\t\"loadBalance\":          config.LoadBalance,\n\t\t\t\t\"responsesLoadBalance\": config.ResponsesLoadBalance,\n\t\t\t},\n\t\t})\n\t}\n}\n\n// DevInfo 开发信息处理器\nfunc DevInfo(envCfg *config.EnvConfig, cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tc.JSON(200, gin.H{\n\t\t\t\"status\":      \"development\",\n\t\t\t\"timestamp\":   time.Now().Format(time.RFC3339),\n\t\t\t\"config\":      cfgManager.GetConfig(),\n\t\t\t\"environment\": envCfg,\n\t\t})\n\t}\n}\n\nvar startTime = time.Now()\n"
  },
  {
    "path": "backend-go/internal/handlers/messages/channels.go",
    "content": "// Package messages 提供 Claude Messages API 的渠道管理\npackage messages\n\nimport (\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/httpclient\"\n\t\"github.com/BenedictKing/claude-proxy/internal/scheduler\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// GetUpstreams 获取上游列表 (兼容前端 channels 字段名)\nfunc GetUpstreams(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tcfg := cfgManager.GetConfig()\n\n\t\tupstreams := make([]gin.H, len(cfg.Upstream))\n\t\tfor i, up := range cfg.Upstream {\n\t\t\tstatus := config.GetChannelStatus(&up)\n\t\t\tpriority := config.GetChannelPriority(&up, i)\n\n\t\t\tupstreams[i] = gin.H{\n\t\t\t\t\"index\":              i,\n\t\t\t\t\"name\":               up.Name,\n\t\t\t\t\"serviceType\":        up.ServiceType,\n\t\t\t\t\"baseUrl\":            up.BaseURL,\n\t\t\t\t\"baseUrls\":           up.BaseURLs,\n\t\t\t\t\"apiKeys\":            up.APIKeys,\n\t\t\t\t\"description\":        up.Description,\n\t\t\t\t\"website\":            up.Website,\n\t\t\t\t\"insecureSkipVerify\": up.InsecureSkipVerify,\n\t\t\t\t\"modelMapping\":       up.ModelMapping,\n\t\t\t\t\"latency\":            nil,\n\t\t\t\t\"status\":             status,\n\t\t\t\t\"priority\":           priority,\n\t\t\t\t\"promotionUntil\":     up.PromotionUntil,\n\t\t\t\t\"lowQuality\":         up.LowQuality,\n\t\t\t}\n\t\t}\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"channels\":    upstreams,\n\t\t\t\"loadBalance\": cfg.LoadBalance,\n\t\t})\n\t}\n}\n\n// AddUpstream 添加上游\nfunc AddUpstream(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tvar upstream config.UpstreamConfig\n\t\tif err := c.ShouldBindJSON(&upstream); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid request body\"})\n\t\t\treturn\n\t\t}\n\n\t\tif err := cfgManager.AddUpstream(upstream); err != nil {\n\t\t\tc.JSON(500, gin.H{\"error\": \"Failed to save config\"})\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"message\":  \"上游已添加\",\n\t\t\t\"upstream\": upstream,\n\t\t})\n\t}\n}\n\n// UpdateUpstream 更新上游\nfunc UpdateUpstream(cfgManager *config.ConfigManager, sch *scheduler.ChannelScheduler) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid upstream ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tvar updates config.UpstreamUpdate\n\t\tif err := c.ShouldBindJSON(&updates); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid request body\"})\n\t\t\treturn\n\t\t}\n\n\t\tshouldResetMetrics, err := cfgManager.UpdateUpstream(id, updates)\n\t\tif err != nil {\n\t\t\tif strings.Contains(err.Error(), \"无效的上游索引\") {\n\t\t\t\tc.JSON(404, gin.H{\"error\": \"Upstream not found\"})\n\t\t\t} else {\n\t\t\t\tc.JSON(500, gin.H{\"error\": \"Failed to save config\"})\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tif shouldResetMetrics {\n\t\t\tsch.ResetChannelMetrics(id, scheduler.ChannelKindMessages)\n\t\t}\n\n\t\tcfg := cfgManager.GetConfig()\n\t\tc.JSON(200, gin.H{\n\t\t\t\"message\":  \"上游已更新\",\n\t\t\t\"upstream\": cfg.Upstream[id],\n\t\t})\n\t}\n}\n\n// DeleteUpstream 删除上游\nfunc DeleteUpstream(cfgManager *config.ConfigManager, sch *scheduler.ChannelScheduler) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid upstream ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tremoved, err := cfgManager.RemoveUpstream(id)\n\t\tif err != nil {\n\t\t\tif strings.Contains(err.Error(), \"无效的上游索引\") {\n\t\t\t\tc.JSON(404, gin.H{\"error\": \"Upstream not found\"})\n\t\t\t} else {\n\t\t\t\tc.JSON(500, gin.H{\"error\": \"Failed to save config\"})\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\t// 删除成功后清理指标数据（使用 RemoveUpstream 返回的渠道信息）\n\t\tsch.DeleteChannelMetrics(removed, scheduler.ChannelKindMessages)\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"message\": \"上游已删除\",\n\t\t\t\"removed\": removed,\n\t\t})\n\t}\n}\n\n// AddApiKey 添加 API 密钥\nfunc AddApiKey(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid upstream ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tvar req struct {\n\t\t\tAPIKey string `json:\"apiKey\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid request body\"})\n\t\t\treturn\n\t\t}\n\n\t\tif err := cfgManager.AddAPIKey(id, req.APIKey); err != nil {\n\t\t\tif strings.Contains(err.Error(), \"无效的上游索引\") {\n\t\t\t\tc.JSON(404, gin.H{\"error\": \"Upstream not found\"})\n\t\t\t} else if strings.Contains(err.Error(), \"API密钥已存在\") {\n\t\t\t\tc.JSON(400, gin.H{\"error\": \"API密钥已存在\"})\n\t\t\t} else {\n\t\t\t\tc.JSON(500, gin.H{\"error\": \"Failed to save config\"})\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"message\": \"API密钥已添加\",\n\t\t\t\"success\": true,\n\t\t})\n\t}\n}\n\n// DeleteApiKey 删除 API 密钥\nfunc DeleteApiKey(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid upstream ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tapiKey := c.Param(\"apiKey\")\n\t\tif apiKey == \"\" {\n\t\t\tc.JSON(400, gin.H{\"error\": \"API key is required\"})\n\t\t\treturn\n\t\t}\n\n\t\tif err := cfgManager.RemoveAPIKey(id, apiKey); err != nil {\n\t\t\tif strings.Contains(err.Error(), \"无效的上游索引\") {\n\t\t\t\tc.JSON(404, gin.H{\"error\": \"Upstream not found\"})\n\t\t\t} else if strings.Contains(err.Error(), \"API密钥不存在\") {\n\t\t\t\tc.JSON(404, gin.H{\"error\": \"API key not found\"})\n\t\t\t} else {\n\t\t\t\tc.JSON(500, gin.H{\"error\": \"Failed to save config\"})\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"message\": \"API密钥已删除\",\n\t\t})\n\t}\n}\n\n// MoveApiKeyToTop 将 API 密钥移到顶部\nfunc MoveApiKeyToTop(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid upstream ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tapiKey := c.Param(\"apiKey\")\n\t\tif apiKey == \"\" {\n\t\t\tc.JSON(400, gin.H{\"error\": \"API key is required\"})\n\t\t\treturn\n\t\t}\n\n\t\tif err := cfgManager.MoveAPIKeyToTop(id, apiKey); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\"message\": \"API密钥已移到顶部\"})\n\t}\n}\n\n// MoveApiKeyToBottom 将 API 密钥移到底部\nfunc MoveApiKeyToBottom(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid upstream ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tapiKey := c.Param(\"apiKey\")\n\t\tif apiKey == \"\" {\n\t\t\tc.JSON(400, gin.H{\"error\": \"API key is required\"})\n\t\t\treturn\n\t\t}\n\n\t\tif err := cfgManager.MoveAPIKeyToBottom(id, apiKey); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\"message\": \"API密钥已移到底部\"})\n\t}\n}\n\n// UpdateLoadBalance 更新负载均衡策略\nfunc UpdateLoadBalance(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tStrategy string `json:\"strategy\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid request body\"})\n\t\t\treturn\n\t\t}\n\n\t\tif err := cfgManager.SetLoadBalance(req.Strategy); err != nil {\n\t\t\tif strings.Contains(err.Error(), \"无效的负载均衡策略\") {\n\t\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\t} else {\n\t\t\t\tc.JSON(500, gin.H{\"error\": \"Failed to save config\"})\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"message\":  \"负载均衡策略已更新\",\n\t\t\t\"strategy\": req.Strategy,\n\t\t})\n\t}\n}\n\n// ReorderChannels 重新排序渠道\nfunc ReorderChannels(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tOrder []int `json:\"order\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid request body\"})\n\t\t\treturn\n\t\t}\n\n\t\tif err := cfgManager.ReorderUpstreams(req.Order); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\"message\": \"渠道顺序已更新\"})\n\t}\n}\n\n// SetChannelStatus 设置渠道状态\nfunc SetChannelStatus(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid channel ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tvar req struct {\n\t\t\tStatus string `json:\"status\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid request body\"})\n\t\t\treturn\n\t\t}\n\n\t\tif err := cfgManager.SetChannelStatus(id, req.Status); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\"message\": \"渠道状态已更新\"})\n\t}\n}\n\n// SetChannelPromotion 设置渠道促销期\n// 促销期内的渠道会被优先选择，忽略 trace 亲和性\nfunc SetChannelPromotion(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"无效的渠道 ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tvar req struct {\n\t\t\tDuration int `json:\"duration\"` // 促销期时长（秒），0 表示清除\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"无效的请求参数\"})\n\t\t\treturn\n\t\t}\n\n\t\t// 调用配置管理器设置促销期\n\t\tduration := time.Duration(req.Duration) * time.Second\n\t\tif err := cfgManager.SetChannelPromotion(id, duration); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\n\t\tif req.Duration <= 0 {\n\t\t\tc.JSON(200, gin.H{\n\t\t\t\t\"success\": true,\n\t\t\t\t\"message\": \"渠道促销期已清除\",\n\t\t\t})\n\t\t} else {\n\t\t\tc.JSON(200, gin.H{\n\t\t\t\t\"success\":  true,\n\t\t\t\t\"message\":  \"渠道促销期已设置\",\n\t\t\t\t\"duration\": req.Duration,\n\t\t\t})\n\t\t}\n\t}\n}\n\n// PingChannel Ping单个渠道\nfunc PingChannel(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"Invalid channel ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tcfg := cfgManager.GetConfig()\n\t\tif id < 0 || id >= len(cfg.Upstream) {\n\t\t\tc.JSON(http.StatusNotFound, gin.H{\"error\": \"Channel not found\"})\n\t\t\treturn\n\t\t}\n\n\t\tchannel := cfg.Upstream[id]\n\t\tresult := pingChannelURLs(&channel)\n\t\tc.JSON(http.StatusOK, result)\n\t}\n}\n\n// pingChannelURLs 测试渠道的所有 BaseURL，返回最快的延迟\nfunc pingChannelURLs(ch *config.UpstreamConfig) gin.H {\n\turls := ch.GetAllBaseURLs()\n\tif len(urls) == 0 {\n\t\treturn gin.H{\"success\": false, \"latency\": 0, \"status\": \"error\", \"error\": \"no_base_url\"}\n\t}\n\n\t// 单个 URL 直接测试\n\tif len(urls) == 1 {\n\t\treturn pingURL(urls[0], ch.InsecureSkipVerify)\n\t}\n\n\t// 多个 URL 并发测试，返回最快的\n\ttype pingResult struct {\n\t\turl     string\n\t\tlatency int64\n\t\tsuccess bool\n\t\terr     string\n\t}\n\n\tresults := make(chan pingResult, len(urls))\n\tfor _, url := range urls {\n\t\tgo func(testURL string) {\n\t\t\tstartTime := time.Now()\n\t\t\ttestURL = strings.TrimSuffix(testURL, \"/\")\n\t\t\tclient := httpclient.GetManager().GetStandardClient(5*time.Second, ch.InsecureSkipVerify)\n\t\t\treq, err := http.NewRequest(\"HEAD\", testURL, nil)\n\t\t\tif err != nil {\n\t\t\t\tresults <- pingResult{url: testURL, latency: 0, success: false, err: \"req_creation_failed\"}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tresp, err := client.Do(req)\n\t\t\tlatency := time.Since(startTime).Milliseconds()\n\t\t\tif err != nil {\n\t\t\t\tresults <- pingResult{url: testURL, latency: latency, success: false, err: err.Error()}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tresp.Body.Close()\n\t\t\tresults <- pingResult{url: testURL, latency: latency, success: true}\n\t\t}(url)\n\t}\n\n\t// 收集结果，找最快的成功响应（成功结果始终优先于失败结果）\n\tvar bestResult *pingResult\n\tfor i := 0; i < len(urls); i++ {\n\t\tr := <-results\n\t\tif r.success {\n\t\t\t// 成功结果：优先选择，或选择延迟更低的\n\t\t\tif bestResult == nil || !bestResult.success || r.latency < bestResult.latency {\n\t\t\t\tbestResult = &r\n\t\t\t}\n\t\t} else if bestResult == nil || !bestResult.success {\n\t\t\t// 失败结果：仅当没有成功结果时保存\n\t\t\tbestResult = &r\n\t\t}\n\t}\n\n\tif bestResult == nil {\n\t\treturn gin.H{\"success\": false, \"latency\": 0, \"status\": \"error\", \"error\": \"all_urls_failed\"}\n\t}\n\n\tif bestResult.success {\n\t\treturn gin.H{\"success\": true, \"latency\": bestResult.latency, \"status\": \"healthy\"}\n\t}\n\treturn gin.H{\"success\": false, \"latency\": bestResult.latency, \"status\": \"error\", \"error\": bestResult.err}\n}\n\n// pingURL 测试单个 URL\nfunc pingURL(testURL string, insecureSkipVerify bool) gin.H {\n\tstartTime := time.Now()\n\ttestURL = strings.TrimSuffix(testURL, \"/\")\n\tclient := httpclient.GetManager().GetStandardClient(5*time.Second, insecureSkipVerify)\n\treq, err := http.NewRequest(\"HEAD\", testURL, nil)\n\tif err != nil {\n\t\treturn gin.H{\"success\": false, \"latency\": 0, \"status\": \"error\", \"error\": \"req_creation_failed\"}\n\t}\n\tresp, err := client.Do(req)\n\tlatency := time.Since(startTime).Milliseconds()\n\tif err != nil {\n\t\treturn gin.H{\"success\": false, \"latency\": latency, \"status\": \"error\", \"error\": err.Error()}\n\t}\n\tresp.Body.Close()\n\treturn gin.H{\"success\": true, \"latency\": latency, \"status\": \"healthy\"}\n}\n\n// PingAllChannels Ping所有渠道\nfunc PingAllChannels(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tcfg := cfgManager.GetConfig()\n\t\tresults := make(chan gin.H)\n\t\tvar wg sync.WaitGroup\n\n\t\tfor i, channel := range cfg.Upstream {\n\t\t\twg.Add(1)\n\t\t\tgo func(id int, ch config.UpstreamConfig) {\n\t\t\t\tdefer wg.Done()\n\t\t\t\tresult := pingChannelURLs(&ch)\n\t\t\t\tresult[\"id\"] = id\n\t\t\t\tresult[\"name\"] = ch.Name\n\t\t\t\tresults <- result\n\t\t\t}(i, channel)\n\t\t}\n\n\t\tgo func() {\n\t\t\twg.Wait()\n\t\t\tclose(results)\n\t\t}()\n\n\t\tvar finalResults []gin.H\n\t\tfor res := range results {\n\t\t\tfinalResults = append(finalResults, res)\n\t\t}\n\n\t\tc.JSON(http.StatusOK, finalResults)\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/messages/handler.go",
    "content": "// Package messages 提供 Claude Messages API 的处理器\npackage messages\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/handlers/common\"\n\t\"github.com/BenedictKing/claude-proxy/internal/middleware\"\n\t\"github.com/BenedictKing/claude-proxy/internal/providers\"\n\t\"github.com/BenedictKing/claude-proxy/internal/scheduler\"\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n\t\"github.com/BenedictKing/claude-proxy/internal/utils\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// Handler Messages API 代理处理器\n// 支持多渠道调度：当配置多个渠道时自动启用\nfunc Handler(envCfg *config.EnvConfig, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler) gin.HandlerFunc {\n\treturn gin.HandlerFunc(func(c *gin.Context) {\n\t\t// 先进行认证\n\t\tmiddleware.ProxyAuthMiddleware(envCfg)(c)\n\t\tif c.IsAborted() {\n\t\t\treturn\n\t\t}\n\n\t\tstartTime := time.Now()\n\n\t\t// 读取请求体\n\t\tbodyBytes, err := common.ReadRequestBody(c, envCfg.MaxRequestBodySize)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\n\t\t// 预处理：移除空 signature 字段，预防 400 错误\n\t\t// modified 表示请求体是否被修改，详细日志由 RemoveEmptySignatures 内部记录\n\t\tbodyBytes, modified := common.RemoveEmptySignatures(bodyBytes, envCfg.EnableRequestLogs, \"Messages\")\n\t\t_ = modified // 保留以便未来扩展（如需在 handler 层面做额外处理）\n\n\t\t// 解析请求\n\t\tvar claudeReq types.ClaudeRequest\n\t\tif len(bodyBytes) > 0 {\n\t\t\t_ = json.Unmarshal(bodyBytes, &claudeReq)\n\t\t}\n\n\t\t// 提取 user_id 用于 Trace 亲和性\n\t\tuserID := common.ExtractUserID(bodyBytes)\n\n\t\t// 记录原始请求信息（仅在入口处记录一次）\n\t\tcommon.LogOriginalRequest(c, bodyBytes, envCfg, \"Messages\")\n\n\t\t// 检查是否为多渠道模式\n\t\tisMultiChannel := channelScheduler.IsMultiChannelMode(scheduler.ChannelKindMessages)\n\n\t\tif isMultiChannel {\n\t\t\thandleMultiChannel(c, envCfg, cfgManager, channelScheduler, bodyBytes, claudeReq, userID, startTime)\n\t\t} else {\n\t\t\thandleSingleChannel(c, envCfg, cfgManager, channelScheduler, bodyBytes, claudeReq, startTime)\n\t\t}\n\t})\n}\n\n// handleMultiChannel 处理多渠道代理请求\nfunc handleMultiChannel(\n\tc *gin.Context,\n\tenvCfg *config.EnvConfig,\n\tcfgManager *config.ConfigManager,\n\tchannelScheduler *scheduler.ChannelScheduler,\n\tbodyBytes []byte,\n\tclaudeReq types.ClaudeRequest,\n\tuserID string,\n\tstartTime time.Time,\n) {\n\tcommon.HandleMultiChannelFailover(\n\t\tc,\n\t\tenvCfg,\n\t\tchannelScheduler,\n\t\tscheduler.ChannelKindMessages,\n\t\t\"Messages\",\n\t\tuserID,\n\t\tfunc(selection *scheduler.SelectionResult) common.MultiChannelAttemptResult {\n\t\t\tupstream := selection.Upstream\n\t\t\tchannelIndex := selection.ChannelIndex\n\n\t\t\tif upstream == nil {\n\t\t\t\treturn common.MultiChannelAttemptResult{}\n\t\t\t}\n\n\t\t\tprovider := providers.GetProvider(upstream.ServiceType)\n\t\t\tif provider == nil {\n\t\t\t\treturn common.MultiChannelAttemptResult{}\n\t\t\t}\n\n\t\t\tmetricsManager := channelScheduler.GetMessagesMetricsManager()\n\t\t\tbaseURLs := upstream.GetAllBaseURLs()\n\t\t\tsortedURLResults := channelScheduler.GetSortedURLsForChannel(scheduler.ChannelKindMessages, channelIndex, baseURLs)\n\n\t\t\thandled, successKey, successBaseURLIdx, failoverErr, usage, lastErr := common.TryUpstreamWithAllKeys(\n\t\t\t\tc,\n\t\t\t\tenvCfg,\n\t\t\t\tcfgManager,\n\t\t\t\tchannelScheduler,\n\t\t\t\tscheduler.ChannelKindMessages,\n\t\t\t\t\"Messages\",\n\t\t\t\tmetricsManager,\n\t\t\t\tupstream,\n\t\t\t\tsortedURLResults,\n\t\t\t\tbodyBytes,\n\t\t\t\tclaudeReq.Stream,\n\t\t\t\tfunc(upstream *config.UpstreamConfig, failedKeys map[string]bool) (string, error) {\n\t\t\t\t\treturn cfgManager.GetNextAPIKey(upstream, failedKeys, \"Messages\")\n\t\t\t\t},\n\t\t\t\tfunc(c *gin.Context, upstreamCopy *config.UpstreamConfig, apiKey string) (*http.Request, error) {\n\t\t\t\t\treq, _, err := provider.ConvertToProviderRequest(c, upstreamCopy, apiKey)\n\t\t\t\t\treturn req, err\n\t\t\t\t},\n\t\t\t\tfunc(apiKey string) {\n\t\t\t\t\tif err := cfgManager.DeprioritizeAPIKey(apiKey); err != nil {\n\t\t\t\t\t\tlog.Printf(\"[Messages-Key] 警告: 密钥降级失败: %v\", err)\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\tfunc(url string) {\n\t\t\t\t\tchannelScheduler.MarkURLFailure(scheduler.ChannelKindMessages, channelIndex, url)\n\t\t\t\t},\n\t\t\t\tfunc(url string) {\n\t\t\t\t\tchannelScheduler.MarkURLSuccess(scheduler.ChannelKindMessages, channelIndex, url)\n\t\t\t\t},\n\t\t\t\tfunc(c *gin.Context, resp *http.Response, upstreamCopy *config.UpstreamConfig, apiKey string) (*types.Usage, error) {\n\t\t\t\t\tif claudeReq.Stream {\n\t\t\t\t\t\treturn common.HandleStreamResponse(c, resp, provider, envCfg, startTime, upstreamCopy, bodyBytes, claudeReq.Model)\n\t\t\t\t\t}\n\t\t\t\t\treturn handleNormalResponse(c, resp, provider, envCfg, startTime, bodyBytes, upstreamCopy, apiKey)\n\t\t\t\t},\n\t\t\t)\n\n\t\t\treturn common.MultiChannelAttemptResult{\n\t\t\t\tHandled:           handled,\n\t\t\t\tAttempted:         true,\n\t\t\t\tSuccessKey:        successKey,\n\t\t\t\tSuccessBaseURLIdx: successBaseURLIdx,\n\t\t\t\tFailoverError:     failoverErr,\n\t\t\t\tUsage:             usage,\n\t\t\t\tLastError:         lastErr,\n\t\t\t}\n\t\t},\n\t\tnil,\n\t\tfunc(ctx *gin.Context, failoverErr *common.FailoverError, lastError error) {\n\t\t\tcommon.HandleAllChannelsFailed(ctx, cfgManager.GetFuzzyModeEnabled(), failoverErr, lastError, \"Messages\")\n\t\t},\n\t)\n}\n\n// handleSingleChannel 处理单渠道代理请求\nfunc handleSingleChannel(\n\tc *gin.Context,\n\tenvCfg *config.EnvConfig,\n\tcfgManager *config.ConfigManager,\n\tchannelScheduler *scheduler.ChannelScheduler,\n\tbodyBytes []byte,\n\tclaudeReq types.ClaudeRequest,\n\tstartTime time.Time,\n) {\n\tupstream, err := cfgManager.GetCurrentUpstream()\n\tif err != nil {\n\t\tc.JSON(503, gin.H{\n\t\t\t\"error\": \"未配置任何渠道，请先在管理界面添加渠道\",\n\t\t\t\"code\":  \"NO_UPSTREAM\",\n\t\t})\n\t\treturn\n\t}\n\n\tif len(upstream.APIKeys) == 0 {\n\t\tc.JSON(503, gin.H{\n\t\t\t\"error\": fmt.Sprintf(\"当前渠道 \\\"%s\\\" 未配置API密钥\", upstream.Name),\n\t\t\t\"code\":  \"NO_API_KEYS\",\n\t\t})\n\t\treturn\n\t}\n\n\tprovider := providers.GetProvider(upstream.ServiceType)\n\tif provider == nil {\n\t\tc.JSON(400, gin.H{\"error\": \"Unsupported service type\"})\n\t\treturn\n\t}\n\n\tmetricsManager := channelScheduler.GetMessagesMetricsManager()\n\tbaseURLs := upstream.GetAllBaseURLs()\n\n\turlResults := common.BuildDefaultURLResults(baseURLs)\n\n\thandled, _, _, lastFailoverError, _, lastError := common.TryUpstreamWithAllKeys(\n\t\tc,\n\t\tenvCfg,\n\t\tcfgManager,\n\t\tchannelScheduler,\n\t\tscheduler.ChannelKindMessages,\n\t\t\"Messages\",\n\t\tmetricsManager,\n\t\tupstream,\n\t\turlResults,\n\t\tbodyBytes,\n\t\tclaudeReq.Stream,\n\t\tfunc(upstream *config.UpstreamConfig, failedKeys map[string]bool) (string, error) {\n\t\t\treturn cfgManager.GetNextAPIKey(upstream, failedKeys, \"Messages\")\n\t\t},\n\t\tfunc(c *gin.Context, upstreamCopy *config.UpstreamConfig, apiKey string) (*http.Request, error) {\n\t\t\treq, _, err := provider.ConvertToProviderRequest(c, upstreamCopy, apiKey)\n\t\t\treturn req, err\n\t\t},\n\t\tfunc(apiKey string) {\n\t\t\tif err := cfgManager.DeprioritizeAPIKey(apiKey); err != nil {\n\t\t\t\tlog.Printf(\"[Messages-Key] 警告: 密钥降级失败: %v\", err)\n\t\t\t}\n\t\t},\n\t\tnil,\n\t\tnil,\n\t\tfunc(c *gin.Context, resp *http.Response, upstreamCopy *config.UpstreamConfig, apiKey string) (*types.Usage, error) {\n\t\t\tif claudeReq.Stream {\n\t\t\t\treturn common.HandleStreamResponse(c, resp, provider, envCfg, startTime, upstreamCopy, bodyBytes, claudeReq.Model)\n\t\t\t}\n\t\t\treturn handleNormalResponse(c, resp, provider, envCfg, startTime, bodyBytes, upstreamCopy, apiKey)\n\t\t},\n\t)\n\tif handled {\n\t\treturn\n\t}\n\n\tlog.Printf(\"[Messages-Error] 所有API密钥都失败了\")\n\tcommon.HandleAllKeysFailed(c, cfgManager.GetFuzzyModeEnabled(), lastFailoverError, lastError, \"Messages\")\n}\n\n// handleNormalResponse 处理非流式响应\nfunc handleNormalResponse(\n\tc *gin.Context,\n\tresp *http.Response,\n\tprovider providers.Provider,\n\tenvCfg *config.EnvConfig,\n\tstartTime time.Time,\n\trequestBody []byte,\n\tupstream *config.UpstreamConfig,\n\tapiKey string,\n) (*types.Usage, error) {\n\tdefer resp.Body.Close()\n\n\tbodyBytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tc.JSON(500, gin.H{\"error\": \"Failed to read response\"})\n\t\treturn nil, err\n\t}\n\n\tif envCfg.EnableResponseLogs {\n\t\tresponseTime := time.Since(startTime).Milliseconds()\n\t\tlog.Printf(\"[Messages-Timing] 响应完成: %dms, 状态: %d\", responseTime, resp.StatusCode)\n\t\tif envCfg.IsDevelopment() {\n\t\t\trespHeaders := make(map[string]string)\n\t\t\tfor key, values := range resp.Header {\n\t\t\t\tif len(values) > 0 {\n\t\t\t\t\trespHeaders[key] = values[0]\n\t\t\t\t}\n\t\t\t}\n\t\t\tvar respHeadersJSON []byte\n\t\t\tif envCfg.RawLogOutput {\n\t\t\t\trespHeadersJSON, _ = json.Marshal(respHeaders)\n\t\t\t} else {\n\t\t\t\trespHeadersJSON, _ = json.MarshalIndent(respHeaders, \"\", \"  \")\n\t\t\t}\n\t\t\tlog.Printf(\"[Messages-Response] 响应头:\\n%s\", string(respHeadersJSON))\n\n\t\t\tvar formattedBody string\n\t\t\tif envCfg.RawLogOutput {\n\t\t\t\tformattedBody = utils.FormatJSONBytesRaw(bodyBytes)\n\t\t\t} else {\n\t\t\t\tformattedBody = utils.FormatJSONBytesForLog(bodyBytes, 500)\n\t\t\t}\n\t\t\tlog.Printf(\"[Messages-Response] 响应体:\\n%s\", formattedBody)\n\t\t}\n\t}\n\n\tproviderResp := &types.ProviderResponse{\n\t\tStatusCode: resp.StatusCode,\n\t\tHeaders:    resp.Header,\n\t\tBody:       bodyBytes,\n\t\tStream:     false,\n\t}\n\n\tclaudeResp, err := provider.ConvertToClaudeResponse(providerResp)\n\tif err != nil {\n\t\tc.JSON(500, gin.H{\"error\": \"Failed to convert response\"})\n\t\treturn nil, err\n\t}\n\n\t// Token 补全逻辑\n\tif claudeResp.Usage == nil {\n\t\testimatedInput := utils.EstimateRequestTokens(requestBody)\n\t\testimatedOutput := utils.EstimateResponseTokens(claudeResp.Content)\n\t\tclaudeResp.Usage = &types.Usage{\n\t\t\tInputTokens:  estimatedInput,\n\t\t\tOutputTokens: estimatedOutput,\n\t\t}\n\t\tif envCfg.EnableResponseLogs {\n\t\t\tlog.Printf(\"[Messages-Token] 上游无Usage, 本地估算: input=%d, output=%d\", estimatedInput, estimatedOutput)\n\t\t}\n\t} else {\n\t\toriginalInput := claudeResp.Usage.InputTokens\n\t\toriginalOutput := claudeResp.Usage.OutputTokens\n\t\tpatched := false\n\n\t\thasCacheTokens := claudeResp.Usage.CacheCreationInputTokens > 0 || claudeResp.Usage.CacheReadInputTokens > 0\n\n\t\tif claudeResp.Usage.InputTokens <= 1 && !hasCacheTokens {\n\t\t\tclaudeResp.Usage.InputTokens = utils.EstimateRequestTokens(requestBody)\n\t\t\tpatched = true\n\t\t}\n\t\tif claudeResp.Usage.OutputTokens <= 1 {\n\t\t\tclaudeResp.Usage.OutputTokens = utils.EstimateResponseTokens(claudeResp.Content)\n\t\t\tpatched = true\n\t\t}\n\t\tif envCfg.EnableResponseLogs {\n\t\t\tif patched {\n\t\t\t\tlog.Printf(\"[Messages-Token] 虚假值补全: InputTokens=%d->%d, OutputTokens=%d->%d\",\n\t\t\t\t\toriginalInput, claudeResp.Usage.InputTokens, originalOutput, claudeResp.Usage.OutputTokens)\n\t\t\t}\n\t\t\tlog.Printf(\"[Messages-Token] InputTokens=%d, OutputTokens=%d, CacheCreationInputTokens=%d, CacheReadInputTokens=%d, CacheCreation5m=%d, CacheCreation1h=%d, CacheTTL=%s\",\n\t\t\t\tclaudeResp.Usage.InputTokens, claudeResp.Usage.OutputTokens,\n\t\t\t\tclaudeResp.Usage.CacheCreationInputTokens, claudeResp.Usage.CacheReadInputTokens,\n\t\t\t\tclaudeResp.Usage.CacheCreation5mInputTokens, claudeResp.Usage.CacheCreation1hInputTokens,\n\t\t\t\tclaudeResp.Usage.CacheTTL)\n\t\t}\n\t}\n\n\t// 监听客户端断开连接\n\tctx := c.Request.Context()\n\tgo func() {\n\t\t<-ctx.Done()\n\t\tif !c.Writer.Written() {\n\t\t\tif envCfg.EnableResponseLogs {\n\t\t\t\tresponseTime := time.Since(startTime).Milliseconds()\n\t\t\t\tlog.Printf(\"[Messages-Timing] 响应中断: %dms, 状态: %d\", responseTime, resp.StatusCode)\n\t\t\t}\n\t\t}\n\t}()\n\n\t// 转发上游响应头\n\tutils.ForwardResponseHeaders(resp.Header, c.Writer)\n\n\tc.JSON(200, claudeResp)\n\n\tif envCfg.EnableResponseLogs {\n\t\tresponseTime := time.Since(startTime).Milliseconds()\n\t\tlog.Printf(\"[Messages-Timing] 响应发送完成: %dms, 状态: %d\", responseTime, resp.StatusCode)\n\t}\n\n\treturn claudeResp.Usage, nil\n}\n\n// CountTokensHandler 处理 /v1/messages/count_tokens 请求\nfunc CountTokensHandler(envCfg *config.EnvConfig, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tmiddleware.ProxyAuthMiddleware(envCfg)(c)\n\t\tif c.IsAborted() {\n\t\t\treturn\n\t\t}\n\n\t\t// 使用统一的请求体读取函数，应用大小限制\n\t\tbodyBytes, err := common.ReadRequestBody(c, envCfg.MaxRequestBodySize)\n\t\tif err != nil {\n\t\t\t// ReadRequestBody 已经返回了错误响应\n\t\t\treturn\n\t\t}\n\n\t\tvar req struct {\n\t\t\tModel    string      `json:\"model\"`\n\t\t\tSystem   interface{} `json:\"system\"`\n\t\t\tMessages interface{} `json:\"messages\"`\n\t\t\tTools    interface{} `json:\"tools\"`\n\t\t}\n\t\tif err := json.Unmarshal(bodyBytes, &req); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid JSON\"})\n\t\t\treturn\n\t\t}\n\n\t\tinputTokens := utils.EstimateRequestTokens(bodyBytes)\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"input_tokens\": inputTokens,\n\t\t})\n\n\t\tif envCfg.EnableResponseLogs {\n\t\t\tlog.Printf(\"[Messages-Token] CountTokens本地估算: model=%s, input_tokens=%d\", req.Model, inputTokens)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/messages/models.go",
    "content": "// Package messages 提供 Claude Messages API 的处理器\npackage messages\n\nimport (\n\t\"encoding/json\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/httpclient\"\n\t\"github.com/BenedictKing/claude-proxy/internal/middleware\"\n\t\"github.com/BenedictKing/claude-proxy/internal/scheduler\"\n\t\"github.com/BenedictKing/claude-proxy/internal/utils\"\n\t\"github.com/gin-gonic/gin\"\n)\n\nconst modelsRequestTimeout = 30 * time.Second\n\n// ModelsResponse OpenAI 兼容的 models 响应格式\ntype ModelsResponse struct {\n\tObject string       `json:\"object\"`\n\tData   []ModelEntry `json:\"data\"`\n}\n\n// ModelEntry 单个模型条目\ntype ModelEntry struct {\n\tID      string `json:\"id\"`\n\tObject  string `json:\"object\"`\n\tCreated int64  `json:\"created\"`\n\tOwnedBy string `json:\"owned_by\"`\n}\n\n// ModelsHandler 处理 /v1/models 请求，从 Messages 和 Responses 渠道获取并合并模型列表\nfunc ModelsHandler(envCfg *config.EnvConfig, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tmiddleware.ProxyAuthMiddleware(envCfg)(c)\n\t\tif c.IsAborted() {\n\t\t\treturn\n\t\t}\n\n\t\t// 并行从两种渠道获取模型列表\n\t\tmessagesModels := fetchModelsFromChannels(c, cfgManager, channelScheduler, false)\n\t\tresponsesModels := fetchModelsFromChannels(c, cfgManager, channelScheduler, true)\n\n\t\t// 合并去重\n\t\tmergedModels := mergeModels(messagesModels, responsesModels)\n\n\t\tif len(mergedModels) == 0 {\n\t\t\tc.JSON(http.StatusNotFound, gin.H{\n\t\t\t\t\"error\": gin.H{\n\t\t\t\t\t\"message\": \"models endpoint not available from any upstream\",\n\t\t\t\t\t\"type\":    \"not_found_error\",\n\t\t\t\t},\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tresponse := ModelsResponse{\n\t\t\tObject: \"list\",\n\t\t\tData:   mergedModels,\n\t\t}\n\n\t\tlog.Printf(\"[Models] 合并完成: messages=%d, responses=%d, merged=%d\",\n\t\t\tlen(messagesModels), len(responsesModels), len(mergedModels))\n\n\t\tc.JSON(http.StatusOK, response)\n\t}\n}\n\n// ModelsDetailHandler 处理 /v1/models/:model 请求，转发到上游\nfunc ModelsDetailHandler(envCfg *config.EnvConfig, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tmiddleware.ProxyAuthMiddleware(envCfg)(c)\n\t\tif c.IsAborted() {\n\t\t\treturn\n\t\t}\n\n\t\tmodelID := c.Param(\"model\")\n\t\tif modelID == \"\" {\n\t\t\tc.JSON(http.StatusBadRequest, gin.H{\n\t\t\t\t\"error\": gin.H{\n\t\t\t\t\t\"message\": \"model id is required\",\n\t\t\t\t\t\"type\":    \"invalid_request_error\",\n\t\t\t\t},\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\t// 先尝试 Messages 渠道\n\t\tif body, ok := tryModelsRequest(c, cfgManager, channelScheduler, \"GET\", \"/\"+modelID, false); ok {\n\t\t\tc.Data(http.StatusOK, \"application/json\", body)\n\t\t\treturn\n\t\t}\n\n\t\t// 再尝试 Responses 渠道\n\t\tif body, ok := tryModelsRequest(c, cfgManager, channelScheduler, \"GET\", \"/\"+modelID, true); ok {\n\t\t\tc.Data(http.StatusOK, \"application/json\", body)\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(http.StatusNotFound, gin.H{\n\t\t\t\"error\": gin.H{\n\t\t\t\t\"message\": \"model not found\",\n\t\t\t\t\"type\":    \"not_found_error\",\n\t\t\t},\n\t\t})\n\t}\n}\n\n// fetchModelsFromChannels 从指定类型的渠道获取模型列表\nfunc fetchModelsFromChannels(c *gin.Context, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler, isResponses bool) []ModelEntry {\n\tbody, ok := tryModelsRequest(c, cfgManager, channelScheduler, \"GET\", \"\", isResponses)\n\tif !ok {\n\t\treturn nil\n\t}\n\n\tvar resp ModelsResponse\n\tif err := json.Unmarshal(body, &resp); err != nil {\n\t\tchannelType := \"Messages\"\n\t\tif isResponses {\n\t\t\tchannelType = \"Responses\"\n\t\t}\n\t\tlog.Printf(\"[%s-Models] 解析渠道响应失败: %v\", channelType, err)\n\t\treturn nil\n\t}\n\n\treturn resp.Data\n}\n\n// mergeModels 合并两个模型列表并去重（按 ID）\nfunc mergeModels(models1, models2 []ModelEntry) []ModelEntry {\n\tseen := make(map[string]bool)\n\tvar result []ModelEntry\n\n\t// 先添加第一个列表的模型\n\tfor _, m := range models1 {\n\t\tif !seen[m.ID] {\n\t\t\tseen[m.ID] = true\n\t\t\tresult = append(result, m)\n\t\t}\n\t}\n\n\t// 再添加第二个列表中不重复的模型\n\tfor _, m := range models2 {\n\t\tif !seen[m.ID] {\n\t\t\tseen[m.ID] = true\n\t\t\tresult = append(result, m)\n\t\t}\n\t}\n\n\treturn result\n}\n\n// tryModelsRequest 使用调度器选择渠道，按故障转移顺序尝试请求 models 端点\nfunc tryModelsRequest(c *gin.Context, cfgManager *config.ConfigManager, channelScheduler *scheduler.ChannelScheduler, method, suffix string, isResponses bool) ([]byte, bool) {\n\tfailedChannels := make(map[int]bool)\n\tmaxChannelRetries := 10 // 最多尝试 10 个渠道\n\n\tchannelType := \"Messages\"\n\tif isResponses {\n\t\tchannelType = \"Responses\"\n\t}\n\n\tfor attempt := 0; attempt < maxChannelRetries; attempt++ {\n\t\tkind := scheduler.ChannelKindMessages\n\t\tif isResponses {\n\t\t\tkind = scheduler.ChannelKindResponses\n\t\t}\n\n\t\t// 使用调度器选择渠道\n\t\tselection, err := channelScheduler.SelectChannel(c.Request.Context(), \"\", failedChannels, kind)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"[%s-Models] 渠道无可用: %v\", channelType, err)\n\t\t\tbreak\n\t\t}\n\n\t\tupstream := selection.Upstream\n\n\t\t// 尝试该渠道的第一个 key\n\t\tif len(upstream.APIKeys) == 0 {\n\t\t\tfailedChannels[selection.ChannelIndex] = true\n\t\t\tcontinue\n\t\t}\n\n\t\turl := buildModelsURL(upstream.BaseURL) + suffix\n\t\tclient := httpclient.GetManager().GetStandardClient(modelsRequestTimeout, upstream.InsecureSkipVerify)\n\n\t\t// 获取第一个可用的 key\n\t\tapiKey, err := cfgManager.GetNextAPIKey(upstream, nil, channelType)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"[%s-Models] 获取 API Key 失败: channel=%s, error=%v\", channelType, upstream.Name, err)\n\t\t\tfailedChannels[selection.ChannelIndex] = true\n\t\t\tcontinue\n\t\t}\n\n\t\treq, err := http.NewRequestWithContext(c.Request.Context(), method, url, nil)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"[%s-Models] 创建请求失败: channel=%s, url=%s, error=%v\", channelType, upstream.Name, url, err)\n\t\t\tfailedChannels[selection.ChannelIndex] = true\n\t\t\tcontinue\n\t\t}\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+apiKey)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\t\tresp, err := client.Do(req)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"[%s-Models] 请求失败: channel=%s, key=%s, url=%s, error=%v\",\n\t\t\t\tchannelType, upstream.Name, utils.MaskAPIKey(apiKey), url, err)\n\t\t\tfailedChannels[selection.ChannelIndex] = true\n\t\t\tcontinue\n\t\t}\n\n\t\tif resp.StatusCode == http.StatusOK {\n\t\t\tbody, err := io.ReadAll(resp.Body)\n\t\t\tresp.Body.Close()\n\t\t\tif err != nil {\n\t\t\t\tlog.Printf(\"[%s-Models] 读取响应失败: channel=%s, error=%v\", channelType, upstream.Name, err)\n\t\t\t\tfailedChannels[selection.ChannelIndex] = true\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tlog.Printf(\"[%s-Models] 请求成功: method=%s, channel=%s, key=%s, url=%s, reason=%s\",\n\t\t\t\tchannelType, method, upstream.Name, utils.MaskAPIKey(apiKey), url, selection.Reason)\n\t\t\treturn body, true\n\t\t}\n\n\t\tlog.Printf(\"[%s-Models] 上游返回非 200: channel=%s, key=%s, status=%d, url=%s\",\n\t\t\tchannelType, upstream.Name, utils.MaskAPIKey(apiKey), resp.StatusCode, url)\n\t\tresp.Body.Close()\n\t\tfailedChannels[selection.ChannelIndex] = true\n\t}\n\n\tlog.Printf(\"[%s-Models] 所有渠道均失败: method=%s, suffix=%s\", channelType, method, suffix)\n\treturn nil, false\n}\n\n// buildModelsURL 构建 models 端点的 URL\nfunc buildModelsURL(baseURL string) string {\n\tskipVersionPrefix := strings.HasSuffix(baseURL, \"#\")\n\tif skipVersionPrefix {\n\t\tbaseURL = strings.TrimSuffix(baseURL, \"#\")\n\t}\n\tbaseURL = strings.TrimSuffix(baseURL, \"/\")\n\n\tversionPattern := regexp.MustCompile(`/v\\d+[a-z]*$`)\n\thasVersionSuffix := versionPattern.MatchString(baseURL)\n\n\tendpoint := \"/models\"\n\tif !hasVersionSuffix && !skipVersionPrefix {\n\t\tendpoint = \"/v1\" + endpoint\n\t}\n\n\treturn baseURL + endpoint\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/responses/channels.go",
    "content": "// Package responses 提供 Responses API 的渠道管理\npackage responses\n\nimport (\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/scheduler\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// GetUpstreams 获取 Responses 上游列表\nfunc GetUpstreams(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tcfg := cfgManager.GetConfig()\n\n\t\tupstreams := make([]gin.H, len(cfg.ResponsesUpstream))\n\t\tfor i, up := range cfg.ResponsesUpstream {\n\t\t\tstatus := config.GetChannelStatus(&up)\n\t\t\tpriority := config.GetChannelPriority(&up, i)\n\n\t\t\tupstreams[i] = gin.H{\n\t\t\t\t\"index\":              i,\n\t\t\t\t\"name\":               up.Name,\n\t\t\t\t\"serviceType\":        up.ServiceType,\n\t\t\t\t\"baseUrl\":            up.BaseURL,\n\t\t\t\t\"baseUrls\":           up.BaseURLs,\n\t\t\t\t\"apiKeys\":            up.APIKeys,\n\t\t\t\t\"description\":        up.Description,\n\t\t\t\t\"website\":            up.Website,\n\t\t\t\t\"insecureSkipVerify\": up.InsecureSkipVerify,\n\t\t\t\t\"modelMapping\":       up.ModelMapping,\n\t\t\t\t\"latency\":            nil,\n\t\t\t\t\"status\":             status,\n\t\t\t\t\"priority\":           priority,\n\t\t\t\t\"promotionUntil\":     up.PromotionUntil,\n\t\t\t\t\"lowQuality\":         up.LowQuality,\n\t\t\t}\n\t\t}\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"channels\":    upstreams,\n\t\t\t\"loadBalance\": cfg.ResponsesLoadBalance,\n\t\t})\n\t}\n}\n\n// AddUpstream 添加 Responses 上游\nfunc AddUpstream(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tvar upstream config.UpstreamConfig\n\t\tif err := c.ShouldBindJSON(&upstream); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\n\t\tif err := cfgManager.AddResponsesUpstream(upstream); err != nil {\n\t\t\tc.JSON(500, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\"message\": \"Responses upstream added successfully\"})\n\t}\n}\n\n// UpdateUpstream 更新 Responses 上游\nfunc UpdateUpstream(cfgManager *config.ConfigManager, sch *scheduler.ChannelScheduler) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid upstream ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tvar updates config.UpstreamUpdate\n\t\tif err := c.ShouldBindJSON(&updates); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\n\t\tshouldResetMetrics, err := cfgManager.UpdateResponsesUpstream(id, updates)\n\t\tif err != nil {\n\t\t\tc.JSON(500, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\n\t\t// 单 key 更换时重置熔断状态\n\t\tif shouldResetMetrics {\n\t\t\tsch.ResetChannelMetrics(id, scheduler.ChannelKindResponses)\n\t\t}\n\n\t\tc.JSON(200, gin.H{\"message\": \"Responses upstream updated successfully\"})\n\t}\n}\n\n// DeleteUpstream 删除 Responses 上游\nfunc DeleteUpstream(cfgManager *config.ConfigManager, sch *scheduler.ChannelScheduler) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid upstream ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tremoved, err := cfgManager.RemoveResponsesUpstream(id)\n\t\tif err != nil {\n\t\t\tif strings.Contains(err.Error(), \"无效的\") {\n\t\t\t\tc.JSON(404, gin.H{\"error\": \"Upstream not found\"})\n\t\t\t} else {\n\t\t\t\tc.JSON(500, gin.H{\"error\": err.Error()})\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\t// 删除成功后清理指标数据（使用 RemoveResponsesUpstream 返回的渠道信息）\n\t\tsch.DeleteChannelMetrics(removed, scheduler.ChannelKindResponses)\n\n\t\tc.JSON(200, gin.H{\"message\": \"Responses upstream deleted successfully\"})\n\t}\n}\n\n// AddApiKey 添加 Responses 渠道 API 密钥\nfunc AddApiKey(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid upstream ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tvar req struct {\n\t\t\tAPIKey string `json:\"apiKey\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid request body\"})\n\t\t\treturn\n\t\t}\n\n\t\tif err := cfgManager.AddResponsesAPIKey(id, req.APIKey); err != nil {\n\t\t\tif strings.Contains(err.Error(), \"无效的上游索引\") {\n\t\t\t\tc.JSON(404, gin.H{\"error\": \"Upstream not found\"})\n\t\t\t} else if strings.Contains(err.Error(), \"API密钥已存在\") {\n\t\t\t\tc.JSON(400, gin.H{\"error\": \"API密钥已存在\"})\n\t\t\t} else {\n\t\t\t\tc.JSON(500, gin.H{\"error\": \"Failed to save config\"})\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"message\": \"API密钥已添加\",\n\t\t\t\"success\": true,\n\t\t})\n\t}\n}\n\n// DeleteApiKey 删除 Responses 渠道 API 密钥\nfunc DeleteApiKey(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid upstream ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tapiKey := c.Param(\"apiKey\")\n\t\tif apiKey == \"\" {\n\t\t\tc.JSON(400, gin.H{\"error\": \"API key is required\"})\n\t\t\treturn\n\t\t}\n\n\t\tif err := cfgManager.RemoveResponsesAPIKey(id, apiKey); err != nil {\n\t\t\tif strings.Contains(err.Error(), \"无效的上游索引\") {\n\t\t\t\tc.JSON(404, gin.H{\"error\": \"Upstream not found\"})\n\t\t\t} else if strings.Contains(err.Error(), \"API密钥不存在\") {\n\t\t\t\tc.JSON(404, gin.H{\"error\": \"API key not found\"})\n\t\t\t} else {\n\t\t\t\tc.JSON(500, gin.H{\"error\": \"Failed to save config\"})\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"message\": \"API密钥已删除\",\n\t\t})\n\t}\n}\n\n// MoveApiKeyToTop 将 Responses 渠道 API 密钥移到最前面\nfunc MoveApiKeyToTop(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tid, _ := strconv.Atoi(c.Param(\"id\"))\n\t\tapiKey := c.Param(\"apiKey\")\n\n\t\tif err := cfgManager.MoveResponsesAPIKeyToTop(id, apiKey); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(200, gin.H{\"message\": \"API密钥已置顶\"})\n\t}\n}\n\n// MoveApiKeyToBottom 将 Responses 渠道 API 密钥移到最后面\nfunc MoveApiKeyToBottom(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tid, _ := strconv.Atoi(c.Param(\"id\"))\n\t\tapiKey := c.Param(\"apiKey\")\n\n\t\tif err := cfgManager.MoveResponsesAPIKeyToBottom(id, apiKey); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(200, gin.H{\"message\": \"API密钥已置底\"})\n\t}\n}\n\n// UpdateLoadBalance 更新 Responses 负载均衡策略\nfunc UpdateLoadBalance(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tStrategy string `json:\"strategy\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid request body\"})\n\t\t\treturn\n\t\t}\n\n\t\tif err := cfgManager.SetResponsesLoadBalance(req.Strategy); err != nil {\n\t\t\tif strings.Contains(err.Error(), \"无效的负载均衡策略\") {\n\t\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\t} else {\n\t\t\t\tc.JSON(500, gin.H{\"error\": \"Failed to save config\"})\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"message\":  \"Responses 负载均衡策略已更新\",\n\t\t\t\"strategy\": req.Strategy,\n\t\t})\n\t}\n}\n\n// ReorderChannels 重新排序 Responses 渠道优先级\nfunc ReorderChannels(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tOrder []int `json:\"order\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid request body\"})\n\t\t\treturn\n\t\t}\n\n\t\tif err := cfgManager.ReorderResponsesUpstreams(req.Order); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"success\": true,\n\t\t\t\"message\": \"Responses 渠道优先级已更新\",\n\t\t})\n\t}\n}\n\n// SetChannelStatus 设置 Responses 渠道状态\nfunc SetChannelStatus(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tidStr := c.Param(\"id\")\n\t\tid, err := strconv.Atoi(idStr)\n\t\tif err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid channel ID\"})\n\t\t\treturn\n\t\t}\n\n\t\tvar req struct {\n\t\t\tStatus string `json:\"status\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid request body\"})\n\t\t\treturn\n\t\t}\n\n\t\tif err := cfgManager.SetResponsesChannelStatus(id, req.Status); err != nil {\n\t\t\tif strings.Contains(err.Error(), \"无效的上游索引\") {\n\t\t\t\tc.JSON(404, gin.H{\"error\": \"Channel not found\"})\n\t\t\t} else {\n\t\t\t\tc.JSON(400, gin.H{\"error\": err.Error()})\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"success\": true,\n\t\t\t\"message\": \"Responses 渠道状态已更新\",\n\t\t\t\"status\":  req.Status,\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/responses/compact.go",
    "content": "// Package responses 提供 Responses API 的处理器\npackage responses\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/handlers/common\"\n\t\"github.com/BenedictKing/claude-proxy/internal/middleware\"\n\t\"github.com/BenedictKing/claude-proxy/internal/scheduler\"\n\t\"github.com/BenedictKing/claude-proxy/internal/session\"\n\t\"github.com/BenedictKing/claude-proxy/internal/utils\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// compactError 封装 compact 请求错误\ntype compactError struct {\n\tstatus         int\n\tbody           []byte\n\tshouldFailover bool\n}\n\n// CompactHandler Responses API compact 端点处理器\n// POST /v1/responses/compact - 压缩对话上下文，用于长期代理工作流\nfunc CompactHandler(\n\tenvCfg *config.EnvConfig,\n\tcfgManager *config.ConfigManager,\n\t_ *session.SessionManager,\n\tchannelScheduler *scheduler.ChannelScheduler,\n) gin.HandlerFunc {\n\treturn gin.HandlerFunc(func(c *gin.Context) {\n\t\t// 认证\n\t\tmiddleware.ProxyAuthMiddleware(envCfg)(c)\n\t\tif c.IsAborted() {\n\t\t\treturn\n\t\t}\n\n\t\t// 读取请求体\n\t\tmaxBodySize := envCfg.MaxRequestBodySize\n\t\tbodyBytes, err := common.ReadRequestBody(c, maxBodySize)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\n\t\t// 提取对话标识用于 Trace 亲和性\n\t\tuserID := common.ExtractConversationID(c, bodyBytes)\n\n\t\t// 检查是否为多渠道模式\n\t\tisMultiChannel := channelScheduler.IsMultiChannelMode(scheduler.ChannelKindResponses)\n\n\t\tif isMultiChannel {\n\t\t\thandleMultiChannelCompact(c, envCfg, cfgManager, channelScheduler, bodyBytes, userID)\n\t\t} else {\n\t\t\thandleSingleChannelCompact(c, envCfg, cfgManager, bodyBytes)\n\t\t}\n\t})\n}\n\n// handleSingleChannelCompact 单渠道 compact 请求（带 key 轮转）\nfunc handleSingleChannelCompact(\n\tc *gin.Context,\n\tenvCfg *config.EnvConfig,\n\tcfgManager *config.ConfigManager,\n\tbodyBytes []byte,\n) {\n\tupstream, err := cfgManager.GetCurrentResponsesUpstream()\n\tif err != nil {\n\t\tc.JSON(503, gin.H{\"error\": \"未配置任何 Responses 渠道\"})\n\t\treturn\n\t}\n\n\tif len(upstream.APIKeys) == 0 {\n\t\tc.JSON(503, gin.H{\"error\": \"当前渠道未配置 API 密钥\"})\n\t\treturn\n\t}\n\n\t// Key 轮转：尝试所有可用 key\n\tfailedKeys := make(map[string]bool)\n\tvar lastErr *compactError\n\n\tfor attempt := 0; attempt < len(upstream.APIKeys); attempt++ {\n\t\tapiKey, err := cfgManager.GetNextResponsesAPIKey(upstream, failedKeys)\n\t\tif err != nil {\n\t\t\tbreak\n\t\t}\n\n\t\tsuccess, compactErr := tryCompactWithKey(c, upstream, apiKey, bodyBytes, envCfg, cfgManager)\n\t\tif success {\n\t\t\treturn\n\t\t}\n\n\t\tif compactErr != nil {\n\t\t\tlastErr = compactErr\n\t\t\tif compactErr.shouldFailover {\n\t\t\t\tfailedKeys[apiKey] = true\n\t\t\t\tcfgManager.MarkKeyAsFailed(apiKey, \"Responses\")\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// 非故障转移错误，直接返回\n\t\t\tc.Data(compactErr.status, \"application/json\", compactErr.body)\n\t\t\treturn\n\t\t}\n\t}\n\n\t// 所有 key 都失败\n\tif cfgManager.GetFuzzyModeEnabled() {\n\t\tc.JSON(503, gin.H{\n\t\t\t\"type\": \"error\",\n\t\t\t\"error\": gin.H{\n\t\t\t\t\"type\":    \"service_unavailable\",\n\t\t\t\t\"message\": \"All upstream channels are currently unavailable\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\n\tif lastErr != nil {\n\t\tc.Data(lastErr.status, \"application/json\", lastErr.body)\n\t} else {\n\t\tc.JSON(503, gin.H{\"error\": \"所有 API 密钥都不可用\"})\n\t}\n}\n\n// handleMultiChannelCompact 多渠道 compact 请求（带故障转移和亲和性）\nfunc handleMultiChannelCompact(\n\tc *gin.Context,\n\tenvCfg *config.EnvConfig,\n\tcfgManager *config.ConfigManager,\n\tchannelScheduler *scheduler.ChannelScheduler,\n\tbodyBytes []byte,\n\tuserID string,\n) {\n\tfailedChannels := make(map[int]bool)\n\tmaxAttempts := channelScheduler.GetActiveChannelCount(scheduler.ChannelKindResponses)\n\tvar lastErr *compactError\n\n\tfor attempt := 0; attempt < maxAttempts; attempt++ {\n\t\tselection, err := channelScheduler.SelectChannel(c.Request.Context(), userID, failedChannels, scheduler.ChannelKindResponses)\n\t\tif err != nil {\n\t\t\tbreak\n\t\t}\n\n\t\tupstream := selection.Upstream\n\t\tchannelIndex := selection.ChannelIndex\n\n\t\t// 每个渠道尝试所有 key\n\t\tsuccess, successKey, compactErr := tryCompactChannelWithAllKeys(c, upstream, cfgManager, channelScheduler, bodyBytes, envCfg)\n\n\t\tif success {\n\t\t\t// compact 不产生 usage，但仍需记录成功以更新熔断器/权重\n\t\t\tif successKey != \"\" {\n\t\t\t\tchannelScheduler.RecordSuccessWithUsage(upstream.BaseURL, successKey, nil, scheduler.ChannelKindResponses)\n\t\t\t\t// 只有真正成功的请求才设置 Trace 亲和\n\t\t\t\tchannelScheduler.SetTraceAffinity(userID, channelIndex)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tfailedChannels[channelIndex] = true\n\t\tif compactErr != nil {\n\t\t\tlastErr = compactErr\n\t\t}\n\t}\n\n\t// 所有渠道都失败\n\tif cfgManager.GetFuzzyModeEnabled() {\n\t\tc.JSON(503, gin.H{\n\t\t\t\"type\": \"error\",\n\t\t\t\"error\": gin.H{\n\t\t\t\t\"type\":    \"service_unavailable\",\n\t\t\t\t\"message\": \"All upstream channels are currently unavailable\",\n\t\t\t},\n\t\t})\n\t\treturn\n\t}\n\n\tif lastErr != nil {\n\t\tc.Data(lastErr.status, \"application/json\", lastErr.body)\n\t} else {\n\t\tc.JSON(503, gin.H{\"error\": \"所有 Responses 渠道都不可用\"})\n\t}\n}\n\n// tryCompactChannelWithAllKeys 尝试渠道的所有 key\nfunc tryCompactChannelWithAllKeys(\n\tc *gin.Context,\n\tupstream *config.UpstreamConfig,\n\tcfgManager *config.ConfigManager,\n\tchannelScheduler *scheduler.ChannelScheduler,\n\tbodyBytes []byte,\n\tenvCfg *config.EnvConfig,\n) (bool, string, *compactError) {\n\tif len(upstream.APIKeys) == 0 {\n\t\treturn false, \"\", nil\n\t}\n\n\tmetricsManager := channelScheduler.GetResponsesMetricsManager()\n\n\tfailedKeys := make(map[string]bool)\n\tvar lastErr *compactError\n\n\t// 强制探测模式\n\tforceProbeMode := common.AreAllKeysSuspended(metricsManager, upstream.BaseURL, upstream.APIKeys)\n\tif forceProbeMode {\n\t\tlog.Printf(\"[Compact-Probe] 渠道 %s 所有 Key 都被熔断，启用强制探测模式\", upstream.Name)\n\t}\n\n\tfor attempt := 0; attempt < len(upstream.APIKeys); attempt++ {\n\t\tapiKey, err := cfgManager.GetNextResponsesAPIKey(upstream, failedKeys)\n\t\tif err != nil {\n\t\t\tbreak\n\t\t}\n\n\t\t// 检查熔断状态\n\t\tif !forceProbeMode && metricsManager.ShouldSuspendKey(upstream.BaseURL, apiKey) {\n\t\t\tfailedKeys[apiKey] = true\n\t\t\tlog.Printf(\"[Compact-Key] 跳过熔断中的 Key: %s\", utils.MaskAPIKey(apiKey))\n\t\t\tcontinue\n\t\t}\n\n\t\tsuccess, compactErr := tryCompactWithKey(c, upstream, apiKey, bodyBytes, envCfg, cfgManager)\n\t\tif success {\n\t\t\treturn true, apiKey, nil\n\t\t}\n\n\t\tif compactErr != nil {\n\t\t\tlastErr = compactErr\n\t\t\tif compactErr.shouldFailover {\n\t\t\t\tfailedKeys[apiKey] = true\n\t\t\t\tcfgManager.MarkKeyAsFailed(apiKey, \"Responses\")\n\t\t\t\tchannelScheduler.RecordFailure(upstream.BaseURL, apiKey, scheduler.ChannelKindResponses)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// 非故障转移错误，返回但标记渠道成功（请求已处理）\n\t\t\tc.Data(compactErr.status, \"application/json\", compactErr.body)\n\t\t\treturn true, \"\", nil\n\t\t}\n\t}\n\n\treturn false, \"\", lastErr\n}\n\n// tryCompactWithKey 使用单个 key 尝试 compact 请求\nfunc tryCompactWithKey(\n\tc *gin.Context,\n\tupstream *config.UpstreamConfig,\n\tapiKey string,\n\tbodyBytes []byte,\n\tenvCfg *config.EnvConfig,\n\tcfgManager *config.ConfigManager,\n) (bool, *compactError) {\n\ttargetURL := buildCompactURL(upstream)\n\treq, err := http.NewRequestWithContext(c.Request.Context(), \"POST\", targetURL, bytes.NewReader(bodyBytes))\n\tif err != nil {\n\t\treturn false, &compactError{status: 500, body: []byte(`{\"error\":\"创建请求失败\"}`), shouldFailover: true}\n\t}\n\n\treq.Header = utils.PrepareUpstreamHeaders(c, req.URL.Host)\n\treq.Header.Del(\"authorization\")\n\treq.Header.Del(\"x-api-key\")\n\tutils.SetAuthenticationHeader(req.Header, apiKey)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tresp, err := common.SendRequest(req, upstream, envCfg, false, \"Responses\")\n\tif err != nil {\n\t\treturn false, &compactError{status: 502, body: []byte(`{\"error\":\"上游请求失败\"}`), shouldFailover: true}\n\t}\n\tdefer resp.Body.Close()\n\n\trespBody, _ := io.ReadAll(resp.Body)\n\trespBody = utils.DecompressGzipIfNeeded(resp, respBody)\n\n\t// 判断是否需要故障转移\n\tif resp.StatusCode < 200 || resp.StatusCode >= 300 {\n\t\tshouldFailover, _ := common.ShouldRetryWithNextKey(resp.StatusCode, respBody, cfgManager.GetFuzzyModeEnabled(), \"Responses\")\n\t\treturn false, &compactError{status: resp.StatusCode, body: respBody, shouldFailover: shouldFailover}\n\t}\n\n\t// 成功\n\tutils.ForwardResponseHeaders(resp.Header, c.Writer)\n\tc.Data(resp.StatusCode, \"application/json\", respBody)\n\treturn true, nil\n}\n\n// buildCompactURL 构建 compact 端点 URL\nfunc buildCompactURL(upstream *config.UpstreamConfig) string {\n\tbaseURL := strings.TrimSuffix(upstream.BaseURL, \"/\")\n\tversionPattern := regexp.MustCompile(`/v\\d+[a-z]*$`)\n\tif versionPattern.MatchString(baseURL) {\n\t\treturn baseURL + \"/responses/compact\"\n\t}\n\treturn baseURL + \"/v1/responses/compact\"\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/responses/handler.go",
    "content": "// Package responses 提供 Responses API 的处理器\npackage responses\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/converters\"\n\t\"github.com/BenedictKing/claude-proxy/internal/handlers/common\"\n\t\"github.com/BenedictKing/claude-proxy/internal/middleware\"\n\t\"github.com/BenedictKing/claude-proxy/internal/providers\"\n\t\"github.com/BenedictKing/claude-proxy/internal/scheduler\"\n\t\"github.com/BenedictKing/claude-proxy/internal/session\"\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n\t\"github.com/BenedictKing/claude-proxy/internal/utils\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// Handler Responses API 代理处理器\n// 支持多渠道调度：当配置多个渠道时自动启用\nfunc Handler(\n\tenvCfg *config.EnvConfig,\n\tcfgManager *config.ConfigManager,\n\tsessionManager *session.SessionManager,\n\tchannelScheduler *scheduler.ChannelScheduler,\n) gin.HandlerFunc {\n\treturn gin.HandlerFunc(func(c *gin.Context) {\n\t\t// 先进行认证\n\t\tmiddleware.ProxyAuthMiddleware(envCfg)(c)\n\t\tif c.IsAborted() {\n\t\t\treturn\n\t\t}\n\n\t\tstartTime := time.Now()\n\n\t\t// 读取原始请求体\n\t\tmaxBodySize := envCfg.MaxRequestBodySize\n\t\tbodyBytes, err := common.ReadRequestBody(c, maxBodySize)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\n\t\t// 解析 Responses 请求\n\t\tvar responsesReq types.ResponsesRequest\n\t\tif len(bodyBytes) > 0 {\n\t\t\t_ = json.Unmarshal(bodyBytes, &responsesReq)\n\t\t}\n\n\t\t// 提取对话标识用于 Trace 亲和性\n\t\tuserID := common.ExtractConversationID(c, bodyBytes)\n\n\t\t// 记录原始请求信息（仅在入口处记录一次）\n\t\tcommon.LogOriginalRequest(c, bodyBytes, envCfg, \"Responses\")\n\n\t\t// 检查是否为多渠道模式\n\t\tisMultiChannel := channelScheduler.IsMultiChannelMode(scheduler.ChannelKindResponses)\n\n\t\tif isMultiChannel {\n\t\t\thandleMultiChannel(c, envCfg, cfgManager, channelScheduler, sessionManager, bodyBytes, responsesReq, userID, startTime)\n\t\t} else {\n\t\t\thandleSingleChannel(c, envCfg, cfgManager, channelScheduler, sessionManager, bodyBytes, responsesReq, startTime)\n\t\t}\n\t})\n}\n\n// handleMultiChannel 处理多渠道 Responses 请求\nfunc handleMultiChannel(\n\tc *gin.Context,\n\tenvCfg *config.EnvConfig,\n\tcfgManager *config.ConfigManager,\n\tchannelScheduler *scheduler.ChannelScheduler,\n\tsessionManager *session.SessionManager,\n\tbodyBytes []byte,\n\tresponsesReq types.ResponsesRequest,\n\tuserID string,\n\tstartTime time.Time,\n) {\n\tprovider := &providers.ResponsesProvider{SessionManager: sessionManager}\n\tmetricsManager := channelScheduler.GetResponsesMetricsManager()\n\n\tcommon.HandleMultiChannelFailover(\n\t\tc,\n\t\tenvCfg,\n\t\tchannelScheduler,\n\t\tscheduler.ChannelKindResponses,\n\t\t\"Responses\",\n\t\tuserID,\n\t\tfunc(selection *scheduler.SelectionResult) common.MultiChannelAttemptResult {\n\t\t\tupstream := selection.Upstream\n\t\t\tchannelIndex := selection.ChannelIndex\n\n\t\t\tif upstream == nil {\n\t\t\t\treturn common.MultiChannelAttemptResult{}\n\t\t\t}\n\n\t\t\tbaseURLs := upstream.GetAllBaseURLs()\n\t\t\tsortedURLResults := channelScheduler.GetSortedURLsForChannel(scheduler.ChannelKindResponses, channelIndex, baseURLs)\n\n\t\t\thandled, successKey, successBaseURLIdx, failoverErr, usage, lastErr := common.TryUpstreamWithAllKeys(\n\t\t\t\tc,\n\t\t\t\tenvCfg,\n\t\t\t\tcfgManager,\n\t\t\t\tchannelScheduler,\n\t\t\t\tscheduler.ChannelKindResponses,\n\t\t\t\t\"Responses\",\n\t\t\t\tmetricsManager,\n\t\t\t\tupstream,\n\t\t\t\tsortedURLResults,\n\t\t\t\tbodyBytes,\n\t\t\t\tresponsesReq.Stream,\n\t\t\t\tfunc(upstream *config.UpstreamConfig, failedKeys map[string]bool) (string, error) {\n\t\t\t\t\treturn cfgManager.GetNextResponsesAPIKey(upstream, failedKeys)\n\t\t\t\t},\n\t\t\t\tfunc(c *gin.Context, upstreamCopy *config.UpstreamConfig, apiKey string) (*http.Request, error) {\n\t\t\t\t\treq, _, err := provider.ConvertToProviderRequest(c, upstreamCopy, apiKey)\n\t\t\t\t\treturn req, err\n\t\t\t\t},\n\t\t\t\tfunc(apiKey string) {\n\t\t\t\t\t_ = cfgManager.DeprioritizeAPIKey(apiKey)\n\t\t\t\t},\n\t\t\t\tfunc(url string) {\n\t\t\t\t\tchannelScheduler.MarkURLFailure(scheduler.ChannelKindResponses, channelIndex, url)\n\t\t\t\t},\n\t\t\t\tfunc(url string) {\n\t\t\t\t\tchannelScheduler.MarkURLSuccess(scheduler.ChannelKindResponses, channelIndex, url)\n\t\t\t\t},\n\t\t\t\tfunc(c *gin.Context, resp *http.Response, upstreamCopy *config.UpstreamConfig, apiKey string) (*types.Usage, error) {\n\t\t\t\t\treturn handleSuccess(c, resp, provider, upstream.ServiceType, envCfg, sessionManager, startTime, &responsesReq, bodyBytes)\n\t\t\t\t},\n\t\t\t)\n\n\t\t\treturn common.MultiChannelAttemptResult{\n\t\t\t\tHandled:           handled,\n\t\t\t\tAttempted:         true,\n\t\t\t\tSuccessKey:        successKey,\n\t\t\t\tSuccessBaseURLIdx: successBaseURLIdx,\n\t\t\t\tFailoverError:     failoverErr,\n\t\t\t\tUsage:             usage,\n\t\t\t\tLastError:         lastErr,\n\t\t\t}\n\t\t},\n\t\tnil,\n\t\tfunc(ctx *gin.Context, failoverErr *common.FailoverError, lastError error) {\n\t\t\tcommon.HandleAllChannelsFailed(ctx, cfgManager.GetFuzzyModeEnabled(), failoverErr, lastError, \"Responses\")\n\t\t},\n\t)\n}\n\n// handleSingleChannel 处理单渠道 Responses 请求\nfunc handleSingleChannel(\n\tc *gin.Context,\n\tenvCfg *config.EnvConfig,\n\tcfgManager *config.ConfigManager,\n\tchannelScheduler *scheduler.ChannelScheduler,\n\tsessionManager *session.SessionManager,\n\tbodyBytes []byte,\n\tresponsesReq types.ResponsesRequest,\n\tstartTime time.Time,\n) {\n\tupstream, err := cfgManager.GetCurrentResponsesUpstream()\n\tif err != nil {\n\t\tc.JSON(503, gin.H{\n\t\t\t\"error\": \"未配置任何 Responses 渠道，请先在管理界面添加渠道\",\n\t\t\t\"code\":  \"NO_RESPONSES_UPSTREAM\",\n\t\t})\n\t\treturn\n\t}\n\n\tif len(upstream.APIKeys) == 0 {\n\t\tc.JSON(503, gin.H{\n\t\t\t\"error\": fmt.Sprintf(\"当前 Responses 渠道 \\\"%s\\\" 未配置API密钥\", upstream.Name),\n\t\t\t\"code\":  \"NO_API_KEYS\",\n\t\t})\n\t\treturn\n\t}\n\n\tprovider := &providers.ResponsesProvider{SessionManager: sessionManager}\n\n\tmetricsManager := channelScheduler.GetResponsesMetricsManager()\n\tbaseURLs := upstream.GetAllBaseURLs()\n\n\turlResults := common.BuildDefaultURLResults(baseURLs)\n\n\thandled, _, _, lastFailoverError, _, lastError := common.TryUpstreamWithAllKeys(\n\t\tc,\n\t\tenvCfg,\n\t\tcfgManager,\n\t\tchannelScheduler,\n\t\tscheduler.ChannelKindResponses,\n\t\t\"Responses\",\n\t\tmetricsManager,\n\t\tupstream,\n\t\turlResults,\n\t\tbodyBytes,\n\t\tresponsesReq.Stream,\n\t\tfunc(upstream *config.UpstreamConfig, failedKeys map[string]bool) (string, error) {\n\t\t\treturn cfgManager.GetNextResponsesAPIKey(upstream, failedKeys)\n\t\t},\n\t\tfunc(c *gin.Context, upstreamCopy *config.UpstreamConfig, apiKey string) (*http.Request, error) {\n\t\t\treq, _, err := provider.ConvertToProviderRequest(c, upstreamCopy, apiKey)\n\t\t\treturn req, err\n\t\t},\n\t\tfunc(apiKey string) {\n\t\t\tif err := cfgManager.DeprioritizeAPIKey(apiKey); err != nil {\n\t\t\t\tlog.Printf(\"[Responses-Key] 警告: 密钥降级失败: %v\", err)\n\t\t\t}\n\t\t},\n\t\tnil,\n\t\tnil,\n\t\tfunc(c *gin.Context, resp *http.Response, upstreamCopy *config.UpstreamConfig, apiKey string) (*types.Usage, error) {\n\t\t\treturn handleSuccess(c, resp, provider, upstream.ServiceType, envCfg, sessionManager, startTime, &responsesReq, bodyBytes)\n\t\t},\n\t)\n\tif handled {\n\t\treturn\n\t}\n\n\tlog.Printf(\"[Responses-Error] 所有 Responses API密钥都失败了\")\n\tcommon.HandleAllKeysFailed(c, cfgManager.GetFuzzyModeEnabled(), lastFailoverError, lastError, \"Responses\")\n}\n\n// handleSuccess 处理成功的 Responses 响应\nfunc handleSuccess(\n\tc *gin.Context,\n\tresp *http.Response,\n\tprovider *providers.ResponsesProvider,\n\tupstreamType string,\n\tenvCfg *config.EnvConfig,\n\tsessionManager *session.SessionManager,\n\tstartTime time.Time,\n\toriginalReq *types.ResponsesRequest,\n\toriginalRequestJSON []byte,\n) (*types.Usage, error) {\n\tdefer resp.Body.Close()\n\n\tisStream := originalReq != nil && originalReq.Stream\n\n\tif isStream {\n\t\treturn handleStreamSuccess(c, resp, upstreamType, envCfg, startTime, originalReq, originalRequestJSON), nil\n\t}\n\n\t// 非流式响应处理\n\tbodyBytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tc.JSON(500, gin.H{\"error\": \"Failed to read response\"})\n\t\treturn nil, err\n\t}\n\n\tif envCfg.EnableResponseLogs {\n\t\tresponseTime := time.Since(startTime).Milliseconds()\n\t\tlog.Printf(\"[Responses-Timing] Responses 响应完成: %dms, 状态: %d\", responseTime, resp.StatusCode)\n\t\tif envCfg.IsDevelopment() {\n\t\t\trespHeaders := make(map[string]string)\n\t\t\tfor key, values := range resp.Header {\n\t\t\t\tif len(values) > 0 {\n\t\t\t\t\trespHeaders[key] = values[0]\n\t\t\t\t}\n\t\t\t}\n\t\t\tvar respHeadersJSON []byte\n\t\t\tif envCfg.RawLogOutput {\n\t\t\t\trespHeadersJSON, _ = json.Marshal(respHeaders)\n\t\t\t} else {\n\t\t\t\trespHeadersJSON, _ = json.MarshalIndent(respHeaders, \"\", \"  \")\n\t\t\t}\n\t\t\tlog.Printf(\"[Responses-Response] 响应头:\\n%s\", string(respHeadersJSON))\n\n\t\t\tvar formattedBody string\n\t\t\tif envCfg.RawLogOutput {\n\t\t\t\tformattedBody = utils.FormatJSONBytesRaw(bodyBytes)\n\t\t\t} else {\n\t\t\t\tformattedBody = utils.FormatJSONBytesForLog(bodyBytes, 500)\n\t\t\t}\n\t\t\tlog.Printf(\"[Responses-Response] 响应体:\\n%s\", formattedBody)\n\t\t}\n\t}\n\n\tproviderResp := &types.ProviderResponse{\n\t\tStatusCode: resp.StatusCode,\n\t\tHeaders:    resp.Header,\n\t\tBody:       bodyBytes,\n\t\tStream:     false,\n\t}\n\n\tresponsesResp, err := provider.ConvertToResponsesResponse(providerResp, upstreamType, \"\")\n\tif err != nil {\n\t\tc.JSON(500, gin.H{\"error\": \"Failed to convert response\"})\n\t\treturn nil, err\n\t}\n\n\t// Token 补全逻辑\n\tpatchResponsesUsage(responsesResp, originalRequestJSON, envCfg)\n\n\t// 更新会话\n\tif originalReq.Store == nil || *originalReq.Store {\n\t\tsess, err := sessionManager.GetOrCreateSession(originalReq.PreviousResponseID)\n\t\tif err == nil {\n\t\t\tinputItems, _ := parseInputToItems(originalReq.Input)\n\t\t\tfor _, item := range inputItems {\n\t\t\t\tsessionManager.AppendMessage(sess.ID, item, 0)\n\t\t\t}\n\n\t\t\tfor _, item := range responsesResp.Output {\n\t\t\t\tsessionManager.AppendMessage(sess.ID, item, responsesResp.Usage.TotalTokens)\n\t\t\t}\n\n\t\t\tsessionManager.UpdateLastResponseID(sess.ID, responsesResp.ID)\n\t\t\tsessionManager.RecordResponseMapping(responsesResp.ID, sess.ID)\n\n\t\t\tif sess.LastResponseID != \"\" {\n\t\t\t\tresponsesResp.PreviousID = sess.LastResponseID\n\t\t\t}\n\t\t}\n\t}\n\n\tutils.ForwardResponseHeaders(resp.Header, c.Writer)\n\tc.JSON(200, responsesResp)\n\n\t// 返回 usage 数据用于指标记录\n\treturn &types.Usage{\n\t\tInputTokens:                responsesResp.Usage.InputTokens,\n\t\tOutputTokens:               responsesResp.Usage.OutputTokens,\n\t\tCacheCreationInputTokens:   responsesResp.Usage.CacheCreationInputTokens,\n\t\tCacheReadInputTokens:       responsesResp.Usage.CacheReadInputTokens,\n\t\tCacheCreation5mInputTokens: responsesResp.Usage.CacheCreation5mInputTokens,\n\t\tCacheCreation1hInputTokens: responsesResp.Usage.CacheCreation1hInputTokens,\n\t\tCacheTTL:                   responsesResp.Usage.CacheTTL,\n\t}, nil\n}\n\n// patchResponsesUsage 补全 Responses 响应的 Token 统计\nfunc patchResponsesUsage(resp *types.ResponsesResponse, requestBody []byte, envCfg *config.EnvConfig) {\n\t// 检查是否有 Claude 原生缓存 token（有时才跳过 input_tokens 修补）\n\t// 仅检测 Claude 原生字段：cache_creation_input_tokens, cache_read_input_tokens,\n\t// cache_creation_5m_input_tokens, cache_creation_1h_input_tokens\n\t// 注意：不检测 input_tokens_details.cached_tokens（OpenAI 格式），避免错误跳过\n\thasClaudeCache := resp.Usage.CacheCreationInputTokens > 0 ||\n\t\tresp.Usage.CacheReadInputTokens > 0 ||\n\t\tresp.Usage.CacheCreation5mInputTokens > 0 ||\n\t\tresp.Usage.CacheCreation1hInputTokens > 0\n\n\t// 检查是否需要补全\n\tneedInputPatch := resp.Usage.InputTokens <= 1 && !hasClaudeCache\n\tneedOutputPatch := resp.Usage.OutputTokens <= 1\n\n\t// 如果 usage 完全为空，进行完整估算\n\tif resp.Usage.InputTokens == 0 && resp.Usage.OutputTokens == 0 && resp.Usage.TotalTokens == 0 {\n\t\testimatedInput := utils.EstimateResponsesRequestTokens(requestBody)\n\t\testimatedOutput := estimateResponsesOutputFromItems(resp.Output)\n\t\tresp.Usage.InputTokens = estimatedInput\n\t\tresp.Usage.OutputTokens = estimatedOutput\n\t\tresp.Usage.TotalTokens = estimatedInput + estimatedOutput\n\t\tif envCfg.EnableResponseLogs {\n\t\t\tlog.Printf(\"[Responses-Token] 上游无Usage, 本地估算: input=%d, output=%d\", estimatedInput, estimatedOutput)\n\t\t}\n\t\treturn\n\t}\n\n\t// 修补虚假值\n\toriginalInput := resp.Usage.InputTokens\n\toriginalOutput := resp.Usage.OutputTokens\n\tpatched := false\n\n\tif needInputPatch {\n\t\tresp.Usage.InputTokens = utils.EstimateResponsesRequestTokens(requestBody)\n\t\tpatched = true\n\t}\n\tif needOutputPatch {\n\t\tresp.Usage.OutputTokens = estimateResponsesOutputFromItems(resp.Output)\n\t\tpatched = true\n\t}\n\n\t// 重新计算 TotalTokens（修补时或 total_tokens 为 0 但 input/output 有效时）\n\tif patched || (resp.Usage.TotalTokens == 0 && (resp.Usage.InputTokens > 0 || resp.Usage.OutputTokens > 0)) {\n\t\tresp.Usage.TotalTokens = resp.Usage.InputTokens + resp.Usage.OutputTokens\n\t}\n\n\tif envCfg.EnableResponseLogs {\n\t\tif patched {\n\t\t\tlog.Printf(\"[Responses-Token] 虚假值修补: InputTokens=%d->%d, OutputTokens=%d->%d\",\n\t\t\t\toriginalInput, resp.Usage.InputTokens, originalOutput, resp.Usage.OutputTokens)\n\t\t}\n\t\tlog.Printf(\"[Responses-Token] InputTokens=%d, OutputTokens=%d, TotalTokens=%d, CacheCreation=%d, CacheRead=%d, CacheCreation5m=%d, CacheCreation1h=%d, CacheTTL=%s\",\n\t\t\tresp.Usage.InputTokens, resp.Usage.OutputTokens, resp.Usage.TotalTokens,\n\t\t\tresp.Usage.CacheCreationInputTokens, resp.Usage.CacheReadInputTokens,\n\t\t\tresp.Usage.CacheCreation5mInputTokens, resp.Usage.CacheCreation1hInputTokens,\n\t\t\tresp.Usage.CacheTTL)\n\t}\n}\n\n// estimateResponsesOutputFromItems 从 ResponsesItem 数组估算输出 token\nfunc estimateResponsesOutputFromItems(output []types.ResponsesItem) int {\n\tif len(output) == 0 {\n\t\treturn 0\n\t}\n\n\ttotal := 0\n\tfor _, item := range output {\n\t\t// 处理 content\n\t\tif item.Content != nil {\n\t\t\tswitch v := item.Content.(type) {\n\t\t\tcase string:\n\t\t\t\ttotal += utils.EstimateTokens(v)\n\t\t\tcase []interface{}:\n\t\t\t\tfor _, block := range v {\n\t\t\t\t\tif b, ok := block.(map[string]interface{}); ok {\n\t\t\t\t\t\tif text, ok := b[\"text\"].(string); ok {\n\t\t\t\t\t\t\ttotal += utils.EstimateTokens(text)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\tcase []types.ContentBlock:\n\t\t\t\t// 处理结构化 ContentBlock 数组\n\t\t\t\tfor _, block := range v {\n\t\t\t\t\tif block.Text != \"\" {\n\t\t\t\t\t\ttotal += utils.EstimateTokens(block.Text)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\tdefault:\n\t\t\t\t// 回退：序列化后估算\n\t\t\t\tdata, _ := json.Marshal(v)\n\t\t\t\ttotal += utils.EstimateTokens(string(data))\n\t\t\t}\n\t\t}\n\n\t\t// 处理 tool_use\n\t\tif item.ToolUse != nil {\n\t\t\tif item.ToolUse.Name != \"\" {\n\t\t\t\ttotal += utils.EstimateTokens(item.ToolUse.Name) + 2\n\t\t\t}\n\t\t\tif item.ToolUse.Input != nil {\n\t\t\t\tdata, _ := json.Marshal(item.ToolUse.Input)\n\t\t\t\ttotal += utils.EstimateTokens(string(data))\n\t\t\t}\n\t\t}\n\n\t\t// 处理 function_call 类型（item.Type == \"function_call\"）\n\t\tif item.Type == \"function_call\" {\n\t\t\t// 在转换后的响应中，function_call 的参数可能在 Content 中\n\t\t\tif contentStr, ok := item.Content.(string); ok {\n\t\t\t\ttotal += utils.EstimateTokens(contentStr)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn total\n}\n\n// handleStreamSuccess 处理流式响应\nfunc handleStreamSuccess(\n\tc *gin.Context,\n\tresp *http.Response,\n\tupstreamType string,\n\tenvCfg *config.EnvConfig,\n\tstartTime time.Time,\n\toriginalReq *types.ResponsesRequest,\n\toriginalRequestJSON []byte,\n) *types.Usage {\n\tif envCfg.EnableResponseLogs {\n\t\tresponseTime := time.Since(startTime).Milliseconds()\n\t\tlog.Printf(\"[Responses-Stream] Responses 流式响应开始: %dms, 状态: %d\", responseTime, resp.StatusCode)\n\t}\n\n\tutils.ForwardResponseHeaders(resp.Header, c.Writer)\n\n\tc.Header(\"Content-Type\", \"text/event-stream\")\n\tc.Header(\"Cache-Control\", \"no-cache\")\n\tc.Header(\"Connection\", \"keep-alive\")\n\tc.Header(\"X-Accel-Buffering\", \"no\")\n\n\tvar synthesizer *utils.StreamSynthesizer\n\tvar logBuffer bytes.Buffer\n\tstreamLoggingEnabled := envCfg.IsDevelopment() && envCfg.EnableResponseLogs\n\n\tif streamLoggingEnabled {\n\t\tsynthesizer = utils.NewStreamSynthesizer(upstreamType)\n\t}\n\n\tneedConvert := upstreamType != \"responses\"\n\tvar converterState any\n\n\tc.Status(resp.StatusCode)\n\tflusher, _ := c.Writer.(http.Flusher)\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tconst maxCapacity = 1024 * 1024\n\tbuf := make([]byte, 0, 64*1024)\n\tscanner.Buffer(buf, maxCapacity)\n\n\t// Token 统计状态\n\tvar outputTextBuffer bytes.Buffer\n\tconst maxOutputBufferSize = 1024 * 1024 // 1MB 上限，防止内存溢出\n\tvar collectedUsage responsesStreamUsage\n\thasUsage := false\n\tneedTokenPatch := false\n\tclientGone := false\n\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\n\t\tif streamLoggingEnabled {\n\t\t\tlogBuffer.WriteString(line + \"\\n\")\n\t\t\tif synthesizer != nil {\n\t\t\t\tsynthesizer.ProcessLine(line)\n\t\t\t}\n\t\t}\n\n\t\t// 处理转换后的事件\n\t\tvar eventsToProcess []string\n\n\t\tif needConvert {\n\t\t\tevents := converters.ConvertOpenAIChatToResponses(\n\t\t\t\tc.Request.Context(),\n\t\t\t\toriginalReq.Model,\n\t\t\t\toriginalRequestJSON,\n\t\t\t\tnil,\n\t\t\t\t[]byte(line),\n\t\t\t\t&converterState,\n\t\t\t)\n\t\t\teventsToProcess = events\n\t\t} else {\n\t\t\teventsToProcess = []string{line + \"\\n\"}\n\t\t}\n\n\t\tfor _, event := range eventsToProcess {\n\t\t\t// 提取文本内容用于估算（限制缓冲区大小）\n\t\t\tif outputTextBuffer.Len() < maxOutputBufferSize {\n\t\t\t\textractResponsesTextFromEvent(event, &outputTextBuffer)\n\t\t\t}\n\n\t\t\t// 检测并收集 usage\n\t\t\tdetected, needPatch, usageData := checkResponsesEventUsage(event, envCfg.EnableResponseLogs && envCfg.ShouldLog(\"debug\"))\n\t\t\tif detected {\n\t\t\t\tif !hasUsage {\n\t\t\t\t\thasUsage = true\n\t\t\t\t\tneedTokenPatch = needPatch\n\t\t\t\t\tif envCfg.EnableResponseLogs && envCfg.ShouldLog(\"debug\") && needPatch {\n\t\t\t\t\t\tlog.Printf(\"[Responses-Stream-Token] 检测到虚假值, 延迟到流结束修补\")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tupdateResponsesStreamUsage(&collectedUsage, usageData)\n\t\t\t}\n\n\t\t\t// 在 response.completed 事件前注入/修补 usage\n\t\t\teventToSend := event\n\t\t\tif isResponsesCompletedEvent(event) {\n\t\t\t\tif !hasUsage {\n\t\t\t\t\t// 上游完全没有 usage，注入本地估算\n\t\t\t\t\tvar injectedInput, injectedOutput int\n\t\t\t\t\teventToSend, injectedInput, injectedOutput = injectResponsesUsageToCompletedEvent(event, originalRequestJSON, outputTextBuffer.String(), envCfg)\n\t\t\t\t\t// 更新 collectedUsage 以便最终日志输出\n\t\t\t\t\tcollectedUsage.InputTokens = injectedInput\n\t\t\t\t\tcollectedUsage.OutputTokens = injectedOutput\n\t\t\t\t\tcollectedUsage.TotalTokens = injectedInput + injectedOutput\n\t\t\t\t\tif envCfg.EnableResponseLogs && envCfg.ShouldLog(\"debug\") {\n\t\t\t\t\t\tlog.Printf(\"[Responses-Stream-Token] 上游无usage, 注入本地估算: input=%d, output=%d\", injectedInput, injectedOutput)\n\t\t\t\t\t}\n\t\t\t\t} else if needTokenPatch {\n\t\t\t\t\t// 需要修补虚假值\n\t\t\t\t\teventToSend = patchResponsesCompletedEventUsage(event, originalRequestJSON, outputTextBuffer.String(), &collectedUsage, envCfg)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// 转发给客户端\n\t\t\tif !clientGone {\n\t\t\t\t_, err := c.Writer.Write([]byte(eventToSend))\n\t\t\t\tif err != nil {\n\t\t\t\t\tclientGone = true\n\t\t\t\t\tif !isClientDisconnectError(err) {\n\t\t\t\t\t\tlog.Printf(\"[Responses-Stream] 警告: 流式响应传输错误: %v\", err)\n\t\t\t\t\t} else if envCfg.ShouldLog(\"info\") {\n\t\t\t\t\t\tlog.Printf(\"[Responses-Stream] 客户端中断连接 (正常行为)，继续接收上游数据...\")\n\t\t\t\t\t}\n\t\t\t\t} else if flusher != nil {\n\t\t\t\t\tflusher.Flush()\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tlog.Printf(\"[Responses-Stream] 警告: 流式响应读取错误: %v\", err)\n\t}\n\n\tif envCfg.EnableResponseLogs {\n\t\tresponseTime := time.Since(startTime).Milliseconds()\n\t\tlog.Printf(\"[Responses-Stream] Responses 流式响应完成: %dms\", responseTime)\n\n\t\t// 输出 Token 统计\n\t\tif hasUsage || collectedUsage.InputTokens > 0 || collectedUsage.OutputTokens > 0 {\n\t\t\tlog.Printf(\"[Responses-Stream-Token] InputTokens=%d, OutputTokens=%d, CacheCreation=%d, CacheRead=%d, CacheCreation5m=%d, CacheCreation1h=%d, CacheTTL=%s\",\n\t\t\t\tcollectedUsage.InputTokens, collectedUsage.OutputTokens,\n\t\t\t\tcollectedUsage.CacheCreationInputTokens, collectedUsage.CacheReadInputTokens,\n\t\t\t\tcollectedUsage.CacheCreation5mInputTokens, collectedUsage.CacheCreation1hInputTokens,\n\t\t\t\tcollectedUsage.CacheTTL)\n\t\t}\n\n\t\tif envCfg.IsDevelopment() {\n\t\t\tif synthesizer != nil {\n\t\t\t\tsynthesizedContent := synthesizer.GetSynthesizedContent()\n\t\t\t\tparseFailed := synthesizer.IsParseFailed()\n\t\t\t\tif synthesizedContent != \"\" && !parseFailed {\n\t\t\t\t\tlog.Printf(\"[Responses-Stream] 上游流式响应合成内容:\\n%s\", strings.TrimSpace(synthesizedContent))\n\t\t\t\t} else if logBuffer.Len() > 0 {\n\t\t\t\t\tlog.Printf(\"[Responses-Stream] 上游流式响应原始内容:\\n%s\", logBuffer.String())\n\t\t\t\t}\n\t\t\t} else if logBuffer.Len() > 0 {\n\t\t\t\tlog.Printf(\"[Responses-Stream] 上游流式响应原始内容:\\n%s\", logBuffer.String())\n\t\t\t}\n\t\t}\n\t}\n\n\t// 返回收集到的 usage 数据\n\treturn &types.Usage{\n\t\tInputTokens:                collectedUsage.InputTokens,\n\t\tOutputTokens:               collectedUsage.OutputTokens,\n\t\tCacheCreationInputTokens:   collectedUsage.CacheCreationInputTokens,\n\t\tCacheReadInputTokens:       collectedUsage.CacheReadInputTokens,\n\t\tCacheCreation5mInputTokens: collectedUsage.CacheCreation5mInputTokens,\n\t\tCacheCreation1hInputTokens: collectedUsage.CacheCreation1hInputTokens,\n\t\tCacheTTL:                   collectedUsage.CacheTTL,\n\t}\n}\n\n// responsesStreamUsage 流式响应 usage 收集结构\ntype responsesStreamUsage struct {\n\tInputTokens                int\n\tOutputTokens               int\n\tTotalTokens                int // 用于检测 total_tokens 是否需要补全\n\tCacheCreationInputTokens   int\n\tCacheReadInputTokens       int\n\tCacheCreation5mInputTokens int\n\tCacheCreation1hInputTokens int\n\tCacheTTL                   string\n\tHasClaudeCache             bool // 是否检测到 Claude 原生缓存字段（区别于 OpenAI cached_tokens）\n}\n\n// extractResponsesTextFromEvent 从 Responses SSE 事件中提取文本内容\nfunc extractResponsesTextFromEvent(event string, buf *bytes.Buffer) {\n\tfor _, line := range strings.Split(event, \"\\n\") {\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\t\tjsonStr := strings.TrimPrefix(line, \"data: \")\n\n\t\tvar data map[string]interface{}\n\t\tif err := json.Unmarshal([]byte(jsonStr), &data); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\teventType, _ := data[\"type\"].(string)\n\n\t\t// 处理各种 delta 类型\n\t\tswitch eventType {\n\t\tcase \"response.output_text.delta\":\n\t\t\tif delta, ok := data[\"delta\"].(string); ok {\n\t\t\t\tbuf.WriteString(delta)\n\t\t\t}\n\t\tcase \"response.function_call_arguments.delta\":\n\t\t\tif delta, ok := data[\"delta\"].(string); ok {\n\t\t\t\tbuf.WriteString(delta)\n\t\t\t}\n\t\tcase \"response.reasoning_summary_text.delta\":\n\t\t\tif text, ok := data[\"text\"].(string); ok {\n\t\t\t\tbuf.WriteString(text)\n\t\t\t}\n\t\tcase \"response.output_json.delta\":\n\t\t\t// JSON 输出增量\n\t\t\tif delta, ok := data[\"delta\"].(string); ok {\n\t\t\t\tbuf.WriteString(delta)\n\t\t\t}\n\t\tcase \"response.content_part.delta\":\n\t\t\t// 内容块增量（通用）\n\t\t\tif delta, ok := data[\"delta\"].(string); ok {\n\t\t\t\tbuf.WriteString(delta)\n\t\t\t} else if text, ok := data[\"text\"].(string); ok {\n\t\t\t\tbuf.WriteString(text)\n\t\t\t}\n\t\tcase \"response.audio.delta\", \"response.audio_transcript.delta\":\n\t\t\t// 音频转录增量\n\t\t\tif delta, ok := data[\"delta\"].(string); ok {\n\t\t\t\tbuf.WriteString(delta)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// checkResponsesEventUsage 检测 Responses 事件是否包含 usage\nfunc checkResponsesEventUsage(event string, enableLog bool) (bool, bool, responsesStreamUsage) {\n\tlines := strings.Split(event, \"\\n\")\n\tfor _, line := range lines {\n\t\t// 支持 \"data:\" 和 \"data: \" 两种格式（有些上游不带空格）\n\t\tvar jsonStr string\n\t\tif strings.HasPrefix(line, \"data:\") {\n\t\t\tjsonStr = strings.TrimPrefix(line, \"data:\")\n\t\t\tjsonStr = strings.TrimPrefix(jsonStr, \" \") // 移除可能的前导空格\n\t\t} else {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar data map[string]interface{}\n\t\tif err := json.Unmarshal([]byte(jsonStr), &data); err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\teventType, _ := data[\"type\"].(string)\n\n\t\t// 检查 response.completed 事件中的 usage\n\t\tif eventType == \"response.completed\" {\n\t\t\tif response, ok := data[\"response\"].(map[string]interface{}); ok {\n\t\t\t\tif usage, ok := response[\"usage\"].(map[string]interface{}); ok {\n\t\t\t\t\tusageData := extractResponsesUsageFromMap(usage)\n\t\t\t\t\tneedPatch := usageData.InputTokens <= 1 || usageData.OutputTokens <= 1\n\n\t\t\t\t\t// 仅当检测到 Claude 原生缓存字段时，才跳过 input_tokens 补全\n\t\t\t\t\t// OpenAI 的 input_tokens_details.cached_tokens 不应阻止补全\n\t\t\t\t\tif usageData.HasClaudeCache && usageData.InputTokens <= 1 {\n\t\t\t\t\t\tneedPatch = usageData.OutputTokens <= 1 // 有 Claude 缓存时只检查 output\n\t\t\t\t\t}\n\n\t\t\t\t\t// 检查 total_tokens 是否需要补全（有效 input/output 但 total=0）\n\t\t\t\t\tif !needPatch && usageData.TotalTokens == 0 && (usageData.InputTokens > 0 || usageData.OutputTokens > 0) {\n\t\t\t\t\t\tneedPatch = true\n\t\t\t\t\t}\n\n\t\t\t\t\tif enableLog {\n\t\t\t\t\t\tlog.Printf(\"[Responses-Stream-Token] response.completed: InputTokens=%d, OutputTokens=%d, TotalTokens=%d, CacheCreation=%d, CacheRead=%d, HasClaudeCache=%v, 需补全=%v\",\n\t\t\t\t\t\t\tusageData.InputTokens, usageData.OutputTokens, usageData.TotalTokens, usageData.CacheCreationInputTokens, usageData.CacheReadInputTokens, usageData.HasClaudeCache, needPatch)\n\t\t\t\t\t}\n\t\t\t\t\treturn true, needPatch, usageData\n\t\t\t\t} else if enableLog {\n\t\t\t\t\tlog.Printf(\"[Responses-Stream-Token] response.completed 事件中无 usage 字段\")\n\t\t\t\t}\n\t\t\t} else if enableLog {\n\t\t\t\tlog.Printf(\"[Responses-Stream-Token] response.completed 事件中无 response 字段\")\n\t\t\t}\n\t\t}\n\t}\n\treturn false, false, responsesStreamUsage{}\n}\n\n// extractResponsesUsageFromMap 从 usage map 中提取数据\nfunc extractResponsesUsageFromMap(usage map[string]interface{}) responsesStreamUsage {\n\tvar data responsesStreamUsage\n\n\tif v, ok := usage[\"input_tokens\"].(float64); ok {\n\t\tdata.InputTokens = int(v)\n\t}\n\tif v, ok := usage[\"output_tokens\"].(float64); ok {\n\t\tdata.OutputTokens = int(v)\n\t}\n\tif v, ok := usage[\"total_tokens\"].(float64); ok {\n\t\tdata.TotalTokens = int(v)\n\t}\n\tif v, ok := usage[\"cache_creation_input_tokens\"].(float64); ok {\n\t\tdata.CacheCreationInputTokens = int(v)\n\t\tif v > 0 {\n\t\t\tdata.HasClaudeCache = true\n\t\t}\n\t}\n\tif v, ok := usage[\"cache_read_input_tokens\"].(float64); ok {\n\t\tdata.CacheReadInputTokens = int(v)\n\t\tif v > 0 {\n\t\t\tdata.HasClaudeCache = true\n\t\t}\n\t}\n\tif v, ok := usage[\"cache_creation_5m_input_tokens\"].(float64); ok {\n\t\tdata.CacheCreation5mInputTokens = int(v)\n\t\tif v > 0 {\n\t\t\tdata.HasClaudeCache = true\n\t\t}\n\t}\n\tif v, ok := usage[\"cache_creation_1h_input_tokens\"].(float64); ok {\n\t\tdata.CacheCreation1hInputTokens = int(v)\n\t\tif v > 0 {\n\t\t\tdata.HasClaudeCache = true\n\t\t}\n\t}\n\n\t// 检查 input_tokens_details.cached_tokens (OpenAI 格式，不设置 HasClaudeCache)\n\tif details, ok := usage[\"input_tokens_details\"].(map[string]interface{}); ok {\n\t\tif cached, ok := details[\"cached_tokens\"].(float64); ok && cached > 0 {\n\t\t\t// 仅当 CacheReadInputTokens 未被设置时才使用 OpenAI 的 cached_tokens\n\t\t\tif data.CacheReadInputTokens == 0 {\n\t\t\t\tdata.CacheReadInputTokens = int(cached)\n\t\t\t}\n\t\t\t// 注意：不设置 HasClaudeCache，因为这是 OpenAI 格式\n\t\t}\n\t}\n\n\t// 设置 CacheTTL\n\tvar has5m, has1h bool\n\tif data.CacheCreation5mInputTokens > 0 {\n\t\thas5m = true\n\t}\n\tif data.CacheCreation1hInputTokens > 0 {\n\t\thas1h = true\n\t}\n\tif has5m && has1h {\n\t\tdata.CacheTTL = \"mixed\"\n\t} else if has1h {\n\t\tdata.CacheTTL = \"1h\"\n\t} else if has5m {\n\t\tdata.CacheTTL = \"5m\"\n\t}\n\n\treturn data\n}\n\n// updateResponsesStreamUsage 更新收集的 usage 数据\nfunc updateResponsesStreamUsage(collected *responsesStreamUsage, usageData responsesStreamUsage) {\n\tif usageData.InputTokens > collected.InputTokens {\n\t\tcollected.InputTokens = usageData.InputTokens\n\t}\n\tif usageData.OutputTokens > collected.OutputTokens {\n\t\tcollected.OutputTokens = usageData.OutputTokens\n\t}\n\tif usageData.TotalTokens > collected.TotalTokens {\n\t\tcollected.TotalTokens = usageData.TotalTokens\n\t}\n\tif usageData.CacheCreationInputTokens > 0 {\n\t\tcollected.CacheCreationInputTokens = usageData.CacheCreationInputTokens\n\t}\n\tif usageData.CacheReadInputTokens > 0 {\n\t\tcollected.CacheReadInputTokens = usageData.CacheReadInputTokens\n\t}\n\tif usageData.CacheCreation5mInputTokens > 0 {\n\t\tcollected.CacheCreation5mInputTokens = usageData.CacheCreation5mInputTokens\n\t}\n\tif usageData.CacheCreation1hInputTokens > 0 {\n\t\tcollected.CacheCreation1hInputTokens = usageData.CacheCreation1hInputTokens\n\t}\n\tif usageData.CacheTTL != \"\" {\n\t\tcollected.CacheTTL = usageData.CacheTTL\n\t}\n\t// 传播 HasClaudeCache 标志\n\tif usageData.HasClaudeCache {\n\t\tcollected.HasClaudeCache = true\n\t}\n}\n\n// isResponsesCompletedEvent 检测是否为 response.completed 事件\nfunc isResponsesCompletedEvent(event string) bool {\n\treturn strings.Contains(event, `\"type\":\"response.completed\"`) ||\n\t\tstrings.Contains(event, `\"type\": \"response.completed\"`)\n}\n\n// isClientDisconnectError 判断是否为客户端断开连接错误\nfunc isClientDisconnectError(err error) bool {\n\tmsg := err.Error()\n\treturn strings.Contains(msg, \"broken pipe\") || strings.Contains(msg, \"connection reset\")\n}\n\n// injectResponsesUsageToCompletedEvent 向 response.completed 事件注入 usage\n// 返回: 修改后的事件字符串, 估算的 inputTokens, 估算的 outputTokens\nfunc injectResponsesUsageToCompletedEvent(event string, requestBody []byte, outputText string, envCfg *config.EnvConfig) (string, int, int) {\n\tinputTokens := utils.EstimateResponsesRequestTokens(requestBody)\n\toutputTokens := utils.EstimateTokens(outputText)\n\ttotalTokens := inputTokens + outputTokens\n\n\t// 调试日志：记录估算开始\n\tif envCfg.EnableResponseLogs && envCfg.ShouldLog(\"debug\") {\n\t\tlog.Printf(\"[Responses-Stream-Token] injectUsage 开始: inputTokens=%d, outputTokens=%d, event长度=%d\",\n\t\t\tinputTokens, outputTokens, len(event))\n\t}\n\n\tvar result strings.Builder\n\tlines := strings.Split(event, \"\\n\")\n\tinjected := false\n\n\tfor _, line := range lines {\n\t\t// 跳过 event: 行，但保留它\n\t\tif strings.HasPrefix(line, \"event:\") {\n\t\t\tresult.WriteString(line)\n\t\t\tresult.WriteString(\"\\n\")\n\t\t\tcontinue\n\t\t}\n\n\t\t// 支持 \"data:\" 和 \"data: \" 两种格式（有些上游不带空格）\n\t\tvar jsonStr string\n\t\tif strings.HasPrefix(line, \"data:\") {\n\t\t\tjsonStr = strings.TrimPrefix(line, \"data:\")\n\t\t\tjsonStr = strings.TrimPrefix(jsonStr, \" \") // 移除可能的前导空格\n\t\t} else {\n\t\t\tresult.WriteString(line)\n\t\t\tresult.WriteString(\"\\n\")\n\t\t\tcontinue\n\t\t}\n\t\tvar data map[string]interface{}\n\t\tif err := json.Unmarshal([]byte(jsonStr), &data); err != nil {\n\t\t\t// 调试日志：JSON 解析失败\n\t\t\tif envCfg.EnableResponseLogs && envCfg.ShouldLog(\"debug\") {\n\t\t\t\tlog.Printf(\"[Responses-Stream-Token] JSON解析失败: %v, 内容前200字符: %.200s\", err, jsonStr)\n\t\t\t}\n\t\t\tresult.WriteString(line)\n\t\t\tresult.WriteString(\"\\n\")\n\t\t\tcontinue\n\t\t}\n\n\t\teventType, _ := data[\"type\"].(string)\n\n\t\tif eventType == \"response.completed\" {\n\t\t\tresponse, ok := data[\"response\"].(map[string]interface{})\n\t\t\tif !ok {\n\t\t\t\t// response 字段缺失或类型错误，创建一个新的\n\t\t\t\tif envCfg.EnableResponseLogs && envCfg.ShouldLog(\"debug\") {\n\t\t\t\t\tlog.Printf(\"[Responses-Stream-Token] response字段缺失, 创建新的response对象\")\n\t\t\t\t}\n\t\t\t\tresponse = make(map[string]interface{})\n\t\t\t\tdata[\"response\"] = response\n\t\t\t}\n\n\t\t\tresponse[\"usage\"] = map[string]interface{}{\n\t\t\t\t\"input_tokens\":  inputTokens,\n\t\t\t\t\"output_tokens\": outputTokens,\n\t\t\t\t\"total_tokens\":  totalTokens,\n\t\t\t}\n\t\t\tinjected = true\n\n\t\t\tpatchedJSON, err := json.Marshal(data)\n\t\t\tif err != nil {\n\t\t\t\tif envCfg.EnableResponseLogs && envCfg.ShouldLog(\"debug\") {\n\t\t\t\t\tlog.Printf(\"[Responses-Stream-Token] JSON序列化失败: %v\", err)\n\t\t\t\t}\n\t\t\t\tresult.WriteString(line)\n\t\t\t\tresult.WriteString(\"\\n\")\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif envCfg.EnableResponseLogs && envCfg.ShouldLog(\"debug\") {\n\t\t\t\tlog.Printf(\"[Responses-Stream-Token] 注入本地估算成功: InputTokens=%d, OutputTokens=%d, TotalTokens=%d\",\n\t\t\t\t\tinputTokens, outputTokens, totalTokens)\n\t\t\t}\n\n\t\t\tresult.WriteString(\"data: \")\n\t\t\tresult.Write(patchedJSON)\n\t\t\tresult.WriteString(\"\\n\")\n\t\t} else {\n\t\t\tresult.WriteString(line)\n\t\t\tresult.WriteString(\"\\n\")\n\t\t}\n\t}\n\n\t// 如果没有成功注入，可能是 SSE 格式不同，尝试直接在整个 event 中查找并替换\n\tif !injected {\n\t\tif envCfg.EnableResponseLogs && envCfg.ShouldLog(\"debug\") {\n\t\t\tlog.Printf(\"[Responses-Stream-Token] 逐行解析未找到, 尝试整体解析 event\")\n\t\t}\n\n\t\t// 尝试从 event 中提取 JSON 部分（可能是多行格式）\n\t\tvar jsonStart, jsonEnd int\n\t\tfor i, line := range lines {\n\t\t\tif strings.HasPrefix(line, \"data:\") {\n\t\t\t\tjsonStart = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\t// 合并所有 data: 行（支持 \"data:\" 和 \"data: \" 两种格式）\n\t\tvar jsonBuilder strings.Builder\n\t\tfor i := jsonStart; i < len(lines); i++ {\n\t\t\tline := lines[i]\n\t\t\tif strings.HasPrefix(line, \"data:\") {\n\t\t\t\tjsonData := strings.TrimPrefix(line, \"data:\")\n\t\t\t\tjsonData = strings.TrimPrefix(jsonData, \" \") // 移除可能的前导空格\n\t\t\t\tjsonBuilder.WriteString(jsonData)\n\t\t\t} else if line == \"\" {\n\t\t\t\tjsonEnd = i\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tfullJSON := jsonBuilder.String()\n\t\tif fullJSON != \"\" {\n\t\t\tvar data map[string]interface{}\n\t\t\tif err := json.Unmarshal([]byte(fullJSON), &data); err == nil {\n\t\t\t\teventType, _ := data[\"type\"].(string)\n\t\t\t\tif eventType == \"response.completed\" {\n\t\t\t\t\tresponse, ok := data[\"response\"].(map[string]interface{})\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\tresponse = make(map[string]interface{})\n\t\t\t\t\t\tdata[\"response\"] = response\n\t\t\t\t\t}\n\n\t\t\t\t\tresponse[\"usage\"] = map[string]interface{}{\n\t\t\t\t\t\t\"input_tokens\":  inputTokens,\n\t\t\t\t\t\t\"output_tokens\": outputTokens,\n\t\t\t\t\t\t\"total_tokens\":  totalTokens,\n\t\t\t\t\t}\n\n\t\t\t\t\tpatchedJSON, err := json.Marshal(data)\n\t\t\t\t\tif err == nil {\n\t\t\t\t\t\tinjected = true\n\t\t\t\t\t\t// 重建 event\n\t\t\t\t\t\tresult.Reset()\n\t\t\t\t\t\tfor i := 0; i < jsonStart; i++ {\n\t\t\t\t\t\t\tresult.WriteString(lines[i])\n\t\t\t\t\t\t\tresult.WriteString(\"\\n\")\n\t\t\t\t\t\t}\n\t\t\t\t\t\tresult.WriteString(\"data: \")\n\t\t\t\t\t\tresult.Write(patchedJSON)\n\t\t\t\t\t\tresult.WriteString(\"\\n\")\n\t\t\t\t\t\tfor i := jsonEnd; i < len(lines); i++ {\n\t\t\t\t\t\t\tresult.WriteString(lines[i])\n\t\t\t\t\t\t\tresult.WriteString(\"\\n\")\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif envCfg.EnableResponseLogs && envCfg.ShouldLog(\"debug\") {\n\t\t\t\t\t\t\tlog.Printf(\"[Responses-Stream-Token] 整体解析注入成功: InputTokens=%d, OutputTokens=%d\",\n\t\t\t\t\t\t\t\tinputTokens, outputTokens)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// 如果仍然没有成功注入，记录警告并打印 event 内容\n\tif !injected {\n\t\tif envCfg.EnableResponseLogs && envCfg.ShouldLog(\"debug\") {\n\t\t\t// 打印 event 的前500个字符帮助调试\n\t\t\teventPreview := event\n\t\t\tif len(eventPreview) > 500 {\n\t\t\t\teventPreview = eventPreview[:500] + \"...\"\n\t\t\t}\n\t\t\tlog.Printf(\"[Responses-Stream-Token] 警告: 未找到 response.completed 事件进行注入, event内容: %s\", eventPreview)\n\t\t}\n\t\treturn event, inputTokens, outputTokens\n\t}\n\n\treturn result.String(), inputTokens, outputTokens\n}\n\n// patchResponsesCompletedEventUsage 修补 response.completed 事件中的 usage\nfunc patchResponsesCompletedEventUsage(event string, requestBody []byte, outputText string, collected *responsesStreamUsage, envCfg *config.EnvConfig) string {\n\tvar result strings.Builder\n\tlines := strings.Split(event, \"\\n\")\n\n\tfor _, line := range lines {\n\t\t// 支持 \"data:\" 和 \"data: \" 两种格式（有些上游不带空格）\n\t\tvar jsonStr string\n\t\tif strings.HasPrefix(line, \"data:\") {\n\t\t\tjsonStr = strings.TrimPrefix(line, \"data:\")\n\t\t\tjsonStr = strings.TrimPrefix(jsonStr, \" \") // 移除可能的前导空格\n\t\t} else {\n\t\t\tresult.WriteString(line)\n\t\t\tresult.WriteString(\"\\n\")\n\t\t\tcontinue\n\t\t}\n\t\tvar data map[string]interface{}\n\t\tif err := json.Unmarshal([]byte(jsonStr), &data); err != nil {\n\t\t\tresult.WriteString(line)\n\t\t\tresult.WriteString(\"\\n\")\n\t\t\tcontinue\n\t\t}\n\n\t\tif data[\"type\"] == \"response.completed\" {\n\t\t\tif response, ok := data[\"response\"].(map[string]interface{}); ok {\n\t\t\t\tif usage, ok := response[\"usage\"].(map[string]interface{}); ok {\n\t\t\t\t\toriginalInput := collected.InputTokens\n\t\t\t\t\toriginalOutput := collected.OutputTokens\n\t\t\t\t\tpatched := false\n\n\t\t\t\t\t// 修补 input_tokens（仅当没有 Claude 原生缓存时）\n\t\t\t\t\t// OpenAI 的 cached_tokens 不应阻止 input_tokens 补全\n\t\t\t\t\tif collected.InputTokens <= 1 && !collected.HasClaudeCache {\n\t\t\t\t\t\testimatedInput := utils.EstimateResponsesRequestTokens(requestBody)\n\t\t\t\t\t\tusage[\"input_tokens\"] = estimatedInput\n\t\t\t\t\t\tcollected.InputTokens = estimatedInput\n\t\t\t\t\t\tpatched = true\n\t\t\t\t\t}\n\n\t\t\t\t\t// 修补 output_tokens\n\t\t\t\t\tif collected.OutputTokens <= 1 {\n\t\t\t\t\t\testimatedOutput := utils.EstimateTokens(outputText)\n\t\t\t\t\t\tusage[\"output_tokens\"] = estimatedOutput\n\t\t\t\t\t\tcollected.OutputTokens = estimatedOutput\n\t\t\t\t\t\tpatched = true\n\t\t\t\t\t}\n\n\t\t\t\t\t// 重新计算 total_tokens（修补时或 total_tokens 为 0 但 input/output 有效时）\n\t\t\t\t\tcurrentTotal := 0\n\t\t\t\t\tif t, ok := usage[\"total_tokens\"].(float64); ok {\n\t\t\t\t\t\tcurrentTotal = int(t)\n\t\t\t\t\t}\n\t\t\t\t\tif patched || (currentTotal == 0 && (collected.InputTokens > 0 || collected.OutputTokens > 0)) {\n\t\t\t\t\t\tusage[\"total_tokens\"] = collected.InputTokens + collected.OutputTokens\n\t\t\t\t\t}\n\n\t\t\t\t\tif envCfg.EnableResponseLogs && envCfg.ShouldLog(\"debug\") && patched {\n\t\t\t\t\t\tlog.Printf(\"[Responses-Stream-Token] 虚假值修补: InputTokens=%d->%d, OutputTokens=%d->%d\",\n\t\t\t\t\t\t\toriginalInput, collected.InputTokens, originalOutput, collected.OutputTokens)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tpatchedJSON, err := json.Marshal(data)\n\t\t\tif err != nil {\n\t\t\t\tresult.WriteString(line)\n\t\t\t\tresult.WriteString(\"\\n\")\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tresult.WriteString(\"data: \")\n\t\t\tresult.Write(patchedJSON)\n\t\t\tresult.WriteString(\"\\n\")\n\t\t} else {\n\t\t\tresult.WriteString(line)\n\t\t\tresult.WriteString(\"\\n\")\n\t\t}\n\t}\n\n\treturn result.String()\n}\n\n// parseInputToItems 解析 input 为 ResponsesItem 数组\nfunc parseInputToItems(input interface{}) ([]types.ResponsesItem, error) {\n\tswitch v := input.(type) {\n\tcase string:\n\t\treturn []types.ResponsesItem{{Type: \"text\", Content: v}}, nil\n\tcase []interface{}:\n\t\titems := []types.ResponsesItem{}\n\t\tfor _, item := range v {\n\t\t\titemMap, ok := item.(map[string]interface{})\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\titemType, _ := itemMap[\"type\"].(string)\n\t\t\tcontent := itemMap[\"content\"]\n\t\t\titems = append(items, types.ResponsesItem{Type: itemType, Content: content})\n\t\t}\n\t\treturn items, nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported input type\")\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/handlers/settings.go",
    "content": "// Package handlers 提供 HTTP 处理器\npackage handlers\n\nimport (\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// GetFuzzyMode 获取 Fuzzy 模式状态\nfunc GetFuzzyMode(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tc.JSON(200, gin.H{\n\t\t\t\"fuzzyModeEnabled\": cfgManager.GetFuzzyModeEnabled(),\n\t\t})\n\t}\n}\n\n// SetFuzzyMode 设置 Fuzzy 模式状态\nfunc SetFuzzyMode(cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tEnabled bool `json:\"enabled\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(400, gin.H{\"error\": \"Invalid request body\"})\n\t\t\treturn\n\t\t}\n\n\t\tif err := cfgManager.SetFuzzyModeEnabled(req.Enabled); err != nil {\n\t\t\tc.JSON(500, gin.H{\"error\": \"Failed to save config\"})\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"success\":          true,\n\t\t\t\"fuzzyModeEnabled\": req.Enabled,\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/httpclient/client.go",
    "content": "package httpclient\n\nimport (\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n)\n\n// ClientManager HTTP 客户端管理器\ntype ClientManager struct {\n\tmu      sync.RWMutex\n\tclients map[string]*http.Client\n}\n\nvar globalManager = &ClientManager{\n\tclients: make(map[string]*http.Client),\n}\n\n// GetManager 获取全局客户端管理器\nfunc GetManager() *ClientManager {\n\treturn globalManager\n}\n\n// GetStandardClient 获取标准客户端（有超时，用于普通请求）\n// 注意：启用自动压缩让Go处理gzip，配合请求头清理确保正确解压\nfunc (cm *ClientManager) GetStandardClient(timeout time.Duration, insecure bool) *http.Client {\n\t// 从配置获取响应头超时时间\n\tenvConfig := config.NewEnvConfig()\n\tresponseHeaderTimeout := time.Duration(envConfig.ResponseHeaderTimeout) * time.Second\n\n\tkey := fmt.Sprintf(\"standard-%d-%t-%d\", timeout, insecure, envConfig.ResponseHeaderTimeout)\n\n\tcm.mu.RLock()\n\tif client, ok := cm.clients[key]; ok {\n\t\tcm.mu.RUnlock()\n\t\treturn client\n\t}\n\tcm.mu.RUnlock()\n\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\t// 双重检查，避免重复创建\n\tif client, ok := cm.clients[key]; ok {\n\t\treturn client\n\t}\n\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:          100,\n\t\tMaxIdleConnsPerHost:   10,\n\t\tIdleConnTimeout:       90 * time.Second,\n\t\tDisableCompression:    false, // 启用自动压缩，让Go处理gzip\n\t\tTLSHandshakeTimeout:   10 * time.Second,\n\t\tResponseHeaderTimeout: responseHeaderTimeout,\n\t\tExpectContinueTimeout: 1 * time.Second,\n\t\tForceAttemptHTTP2:     true,\n\t}\n\n\tif insecure {\n\t\ttransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}\n\t}\n\n\tclient := &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   timeout,\n\t}\n\n\tcm.clients[key] = client\n\treturn client\n}\n\n// GetStreamClient 获取流式客户端（无超时，用于 SSE 流式响应）\nfunc (cm *ClientManager) GetStreamClient(insecure bool) *http.Client {\n\t// 从配置获取响应头超时时间\n\tenvConfig := config.NewEnvConfig()\n\tresponseHeaderTimeout := time.Duration(envConfig.ResponseHeaderTimeout) * time.Second\n\n\tkey := fmt.Sprintf(\"stream-%t-%d\", insecure, envConfig.ResponseHeaderTimeout)\n\n\tcm.mu.RLock()\n\tif client, ok := cm.clients[key]; ok {\n\t\tcm.mu.RUnlock()\n\t\treturn client\n\t}\n\tcm.mu.RUnlock()\n\n\tcm.mu.Lock()\n\tdefer cm.mu.Unlock()\n\n\t// 双重检查\n\tif client, ok := cm.clients[key]; ok {\n\t\treturn client\n\t}\n\n\ttransport := &http.Transport{\n\t\tMaxIdleConns:          200, // 流式连接池更大\n\t\tMaxIdleConnsPerHost:   20,\n\t\tIdleConnTimeout:       120 * time.Second,\n\t\tDisableCompression:    true, // 流式响应禁用压缩\n\t\tTLSHandshakeTimeout:   10 * time.Second,\n\t\tResponseHeaderTimeout: responseHeaderTimeout,\n\t\tExpectContinueTimeout: 1 * time.Second,\n\t\tForceAttemptHTTP2:     true,\n\t}\n\n\tif insecure {\n\t\ttransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}\n\t}\n\n\tclient := &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   0, // 流式请求无超时\n\t}\n\n\tcm.clients[key] = client\n\treturn client\n}\n"
  },
  {
    "path": "backend-go/internal/logger/logger.go",
    "content": "package logger\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"gopkg.in/natefinch/lumberjack.v2\"\n)\n\n// Config 日志配置\ntype Config struct {\n\t// 日志目录\n\tLogDir string\n\t// 日志文件名\n\tLogFile string\n\t// 单个日志文件最大大小 (MB)\n\tMaxSize int\n\t// 保留的旧日志文件最大数量\n\tMaxBackups int\n\t// 保留的旧日志文件最大天数\n\tMaxAge int\n\t// 是否压缩旧日志文件\n\tCompress bool\n\t// 是否同时输出到控制台\n\tConsole bool\n}\n\n// DefaultConfig 返回默认配置\nfunc DefaultConfig() *Config {\n\treturn &Config{\n\t\tLogDir:     \"logs\",\n\t\tLogFile:    \"app.log\",\n\t\tMaxSize:    100, // 100MB\n\t\tMaxBackups: 10,\n\t\tMaxAge:     30, // 30 days\n\t\tCompress:   true,\n\t\tConsole:    true,\n\t}\n}\n\n// Setup 初始化日志系统\nfunc Setup(cfg *Config) error {\n\tif cfg == nil {\n\t\tcfg = DefaultConfig()\n\t}\n\n\t// 确保日志目录存在\n\tif err := os.MkdirAll(cfg.LogDir, 0755); err != nil {\n\t\treturn fmt.Errorf(\"创建日志目录失败: %w\", err)\n\t}\n\n\tlogPath := filepath.Join(cfg.LogDir, cfg.LogFile)\n\n\t// 配置 lumberjack 日志轮转\n\tlumberLogger := &lumberjack.Logger{\n\t\tFilename:   logPath,\n\t\tMaxSize:    cfg.MaxSize,\n\t\tMaxBackups: cfg.MaxBackups,\n\t\tMaxAge:     cfg.MaxAge,\n\t\tCompress:   cfg.Compress,\n\t\tLocalTime:  true,\n\t}\n\n\tvar writer io.Writer\n\tif cfg.Console {\n\t\t// 同时输出到控制台和文件\n\t\twriter = io.MultiWriter(os.Stdout, lumberLogger)\n\t} else {\n\t\t// 仅输出到文件\n\t\twriter = lumberLogger\n\t}\n\n\t// 设置标准库 log 的输出\n\tlog.SetOutput(writer)\n\tlog.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)\n\n\tlog.Printf(\"[Logger-Init] 日志系统已初始化\")\n\tlog.Printf(\"[Logger-Init] 日志文件: %s\", logPath)\n\tlog.Printf(\"[Logger-Init] 轮转配置: 最大 %dMB, 保留 %d 个备份, %d 天\", cfg.MaxSize, cfg.MaxBackups, cfg.MaxAge)\n\n\treturn nil\n}\n"
  },
  {
    "path": "backend-go/internal/metrics/channel_metrics.go",
    "content": "package metrics\n\nimport (\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"log\"\n\t\"sort\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n\t\"github.com/BenedictKing/claude-proxy/internal/utils\"\n)\n\n// RequestRecord 带时间戳的请求记录（扩展版，支持 Token 和 Cache 数据）\ntype RequestRecord struct {\n\tTimestamp                time.Time\n\tSuccess                  bool\n\tInputTokens              int64\n\tOutputTokens             int64\n\tCacheCreationInputTokens int64\n\tCacheReadInputTokens     int64\n}\n\n// KeyMetrics 单个 Key 的指标（绑定到 BaseURL + Key 组合）\ntype KeyMetrics struct {\n\tMetricsKey          string     `json:\"metricsKey\"`          // hash(baseURL + apiKey)\n\tBaseURL             string     `json:\"baseUrl\"`             // 用于显示\n\tKeyMask             string     `json:\"keyMask\"`             // 脱敏的 key（用于显示）\n\tRequestCount        int64      `json:\"requestCount\"`        // 总请求数\n\tSuccessCount        int64      `json:\"successCount\"`        // 成功数\n\tFailureCount        int64      `json:\"failureCount\"`        // 失败数\n\tConsecutiveFailures int64      `json:\"consecutiveFailures\"` // 连续失败数\n\tActiveRequests      int64      `json:\"activeRequests\"`      // 进行中的请求数\n\tLastSuccessAt       *time.Time `json:\"lastSuccessAt,omitempty\"`\n\tLastFailureAt       *time.Time `json:\"lastFailureAt,omitempty\"`\n\tCircuitBrokenAt     *time.Time `json:\"circuitBrokenAt,omitempty\"` // 熔断开始时间\n\t// 滑动窗口记录（最近 N 次请求的结果）\n\trecentResults []bool // true=success, false=failure\n\t// 带时间戳的请求记录（用于分时段统计，保留24小时）\n\trequestHistory []RequestRecord\n\t// 进行中请求在 requestHistory 中的索引（用于“连接即计数”，结束后回写成功/失败与 token）\n\tpendingHistoryIdx map[uint64]int\n}\n\n// ChannelMetrics 渠道聚合指标（用于 API 返回，兼容旧结构）\ntype ChannelMetrics struct {\n\tChannelIndex        int        `json:\"channelIndex\"`\n\tRequestCount        int64      `json:\"requestCount\"`\n\tSuccessCount        int64      `json:\"successCount\"`\n\tFailureCount        int64      `json:\"failureCount\"`\n\tConsecutiveFailures int64      `json:\"consecutiveFailures\"`\n\tLastSuccessAt       *time.Time `json:\"lastSuccessAt,omitempty\"`\n\tLastFailureAt       *time.Time `json:\"lastFailureAt,omitempty\"`\n\tCircuitBrokenAt     *time.Time `json:\"circuitBrokenAt,omitempty\"`\n\t// 滑动窗口记录（兼容旧代码）\n\trecentResults []bool\n\t// 带时间戳的请求记录\n\trequestHistory []RequestRecord\n}\n\n// TimeWindowStats 分时段统计\ntype TimeWindowStats struct {\n\tRequestCount int64   `json:\"requestCount\"`\n\tSuccessCount int64   `json:\"successCount\"`\n\tFailureCount int64   `json:\"failureCount\"`\n\tSuccessRate  float64 `json:\"successRate\"`\n\t// Token 统计（按时间窗口聚合）\n\tInputTokens         int64 `json:\"inputTokens,omitempty\"`\n\tOutputTokens        int64 `json:\"outputTokens,omitempty\"`\n\tCacheCreationTokens int64 `json:\"cacheCreationTokens,omitempty\"`\n\tCacheReadTokens     int64 `json:\"cacheReadTokens,omitempty\"`\n\t// CacheHitRate 缓存命中率（Token口径），范围 0-100\n\t// 定义：cacheReadTokens / (cacheReadTokens + inputTokens) * 100\n\tCacheHitRate float64 `json:\"cacheHitRate,omitempty\"`\n}\n\n// MetricsManager 指标管理器\ntype MetricsManager struct {\n\tmu                  sync.RWMutex\n\tkeyMetrics          map[string]*KeyMetrics // key: hash(baseURL + apiKey)\n\twindowSize          int                    // 滑动窗口大小\n\tfailureThreshold    float64                // 失败率阈值\n\tcircuitRecoveryTime time.Duration          // 熔断恢复时间\n\tstopCh              chan struct{}          // 用于停止清理 goroutine\n\tnextRequestID       uint64                 // 单进程递增请求ID（用于 pendingHistoryIdx）\n\n\t// 持久化存储（可选）\n\tstore   PersistenceStore\n\tapiType string // \"messages\"、\"responses\" 或 \"gemini\"\n}\n\n// NewMetricsManager 创建指标管理器\nfunc NewMetricsManager() *MetricsManager {\n\tm := &MetricsManager{\n\t\tkeyMetrics:          make(map[string]*KeyMetrics),\n\t\twindowSize:          10,               // 默认基于最近 10 次请求计算失败率\n\t\tfailureThreshold:    0.5,              // 默认 50% 失败率阈值\n\t\tcircuitRecoveryTime: 15 * time.Minute, // 默认 15 分钟自动恢复\n\t\tstopCh:              make(chan struct{}),\n\t}\n\t// 启动后台熔断恢复任务\n\tgo m.cleanupCircuitBreakers()\n\treturn m\n}\n\n// NewMetricsManagerWithConfig 创建带配置的指标管理器\nfunc NewMetricsManagerWithConfig(windowSize int, failureThreshold float64) *MetricsManager {\n\tif windowSize < 3 {\n\t\twindowSize = 3 // 最小 3\n\t}\n\tif failureThreshold <= 0 || failureThreshold > 1 {\n\t\tfailureThreshold = 0.5\n\t}\n\tm := &MetricsManager{\n\t\tkeyMetrics:          make(map[string]*KeyMetrics),\n\t\twindowSize:          windowSize,\n\t\tfailureThreshold:    failureThreshold,\n\t\tcircuitRecoveryTime: 15 * time.Minute,\n\t\tstopCh:              make(chan struct{}),\n\t}\n\t// 启动后台熔断恢复任务\n\tgo m.cleanupCircuitBreakers()\n\treturn m\n}\n\n// NewMetricsManagerWithPersistence 创建带持久化的指标管理器\nfunc NewMetricsManagerWithPersistence(windowSize int, failureThreshold float64, store PersistenceStore, apiType string) *MetricsManager {\n\tif windowSize < 3 {\n\t\twindowSize = 3\n\t}\n\tif failureThreshold <= 0 || failureThreshold > 1 {\n\t\tfailureThreshold = 0.5\n\t}\n\tm := &MetricsManager{\n\t\tkeyMetrics:          make(map[string]*KeyMetrics),\n\t\twindowSize:          windowSize,\n\t\tfailureThreshold:    failureThreshold,\n\t\tcircuitRecoveryTime: 15 * time.Minute,\n\t\tstopCh:              make(chan struct{}),\n\t\tstore:               store,\n\t\tapiType:             apiType,\n\t}\n\n\t// 从持久化存储加载历史数据\n\tif store != nil {\n\t\tif err := m.loadFromStore(); err != nil {\n\t\t\tlog.Printf(\"[Metrics-Load] 警告: [%s] 加载历史指标数据失败: %v\", apiType, err)\n\t\t}\n\t}\n\n\t// 启动后台熔断恢复任务\n\tgo m.cleanupCircuitBreakers()\n\treturn m\n}\n\n// loadFromStore 从持久化存储加载数据\nfunc (m *MetricsManager) loadFromStore() error {\n\tif m.store == nil {\n\t\treturn nil\n\t}\n\n\t// 加载最近 24 小时的数据\n\tsince := time.Now().Add(-24 * time.Hour)\n\trecords, err := m.store.LoadRecords(since, m.apiType)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif len(records) == 0 {\n\t\tlog.Printf(\"[Metrics-Load] [%s] 无历史指标数据需要加载\", m.apiType)\n\t\treturn nil\n\t}\n\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\t// 重建内存中的 KeyMetrics\n\tfor _, r := range records {\n\t\tmetrics := m.getOrCreateKeyLocked(r.BaseURL, r.MetricsKey, r.KeyMask)\n\n\t\t// 重建请求历史\n\t\tmetrics.requestHistory = append(metrics.requestHistory, RequestRecord{\n\t\t\tTimestamp:                r.Timestamp,\n\t\t\tSuccess:                  r.Success,\n\t\t\tInputTokens:              r.InputTokens,\n\t\t\tOutputTokens:             r.OutputTokens,\n\t\t\tCacheCreationInputTokens: r.CacheCreationTokens,\n\t\t\tCacheReadInputTokens:     r.CacheReadTokens,\n\t\t})\n\n\t\t// 更新聚合计数\n\t\tmetrics.RequestCount++\n\t\tif r.Success {\n\t\t\tmetrics.SuccessCount++\n\t\t\tif metrics.LastSuccessAt == nil || r.Timestamp.After(*metrics.LastSuccessAt) {\n\t\t\t\tt := r.Timestamp\n\t\t\t\tmetrics.LastSuccessAt = &t\n\t\t\t}\n\t\t} else {\n\t\t\tmetrics.FailureCount++\n\t\t\tif metrics.LastFailureAt == nil || r.Timestamp.After(*metrics.LastFailureAt) {\n\t\t\t\tt := r.Timestamp\n\t\t\t\tmetrics.LastFailureAt = &t\n\t\t\t}\n\t\t}\n\t}\n\n\t// 重建滑动窗口（只从最近 15 分钟的记录中取最近 windowSize 条）\n\t// 避免历史失败记录导致渠道长期处于不健康状态\n\twindowCutoff := time.Now().Add(-15 * time.Minute)\n\tfor _, metrics := range m.keyMetrics {\n\t\tmetrics.recentResults = make([]bool, 0, m.windowSize)\n\t\t// 从历史记录中筛选最近 15 分钟内的记录\n\t\tvar recentRecords []bool\n\t\tfor _, record := range metrics.requestHistory {\n\t\t\tif record.Timestamp.After(windowCutoff) {\n\t\t\t\trecentRecords = append(recentRecords, record.Success)\n\t\t\t}\n\t\t}\n\t\t// 取最近 windowSize 条\n\t\tn := len(recentRecords)\n\t\tstart := 0\n\t\tif n > m.windowSize {\n\t\t\tstart = n - m.windowSize\n\t\t}\n\t\tfor i := start; i < n; i++ {\n\t\t\tmetrics.recentResults = append(metrics.recentResults, recentRecords[i])\n\t\t}\n\t}\n\n\tlog.Printf(\"[Metrics-Load] [%s] 已从持久化存储加载 %d 条历史记录，重建 %d 个 Key 指标\",\n\t\tm.apiType, len(records), len(m.keyMetrics))\n\treturn nil\n}\n\n// getOrCreateKeyLocked 获取或创建 Key 指标（用于加载时，已知 metricsKey 和 keyMask）\nfunc (m *MetricsManager) getOrCreateKeyLocked(baseURL, metricsKey, keyMask string) *KeyMetrics {\n\tif metrics, exists := m.keyMetrics[metricsKey]; exists {\n\t\treturn metrics\n\t}\n\tmetrics := &KeyMetrics{\n\t\tMetricsKey:        metricsKey,\n\t\tBaseURL:           baseURL,\n\t\tKeyMask:           keyMask,\n\t\trecentResults:     make([]bool, 0, m.windowSize),\n\t\tpendingHistoryIdx: make(map[uint64]int),\n\t}\n\tm.keyMetrics[metricsKey] = metrics\n\treturn metrics\n}\n\n// generateMetricsKey 生成指标键 hash(baseURL + apiKey)（内部使用）\nfunc generateMetricsKey(baseURL, apiKey string) string {\n\th := sha256.New()\n\th.Write([]byte(baseURL + \"|\" + apiKey))\n\treturn hex.EncodeToString(h.Sum(nil))[:16] // 取前16位作为键\n}\n\n// GenerateMetricsKey 生成指标键 hash(baseURL + apiKey)（导出供外部使用）\nfunc GenerateMetricsKey(baseURL, apiKey string) string {\n\treturn generateMetricsKey(baseURL, apiKey)\n}\n\n// getOrCreateKey 获取或创建 Key 指标\nfunc (m *MetricsManager) getOrCreateKey(baseURL, apiKey string) *KeyMetrics {\n\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\tif metrics, exists := m.keyMetrics[metricsKey]; exists {\n\t\treturn metrics\n\t}\n\tmetrics := &KeyMetrics{\n\t\tMetricsKey:        metricsKey,\n\t\tBaseURL:           baseURL,\n\t\tKeyMask:           utils.MaskAPIKey(apiKey),\n\t\trecentResults:     make([]bool, 0, m.windowSize),\n\t\tpendingHistoryIdx: make(map[uint64]int),\n\t}\n\tm.keyMetrics[metricsKey] = metrics\n\treturn metrics\n}\n\n// RecordSuccess 记录成功请求（新方法，使用 baseURL + apiKey）\nfunc (m *MetricsManager) RecordSuccess(baseURL, apiKey string) {\n\tm.RecordSuccessWithUsage(baseURL, apiKey, nil)\n}\n\n// RecordSuccessWithUsage 记录成功请求（带 Usage 数据）\nfunc (m *MetricsManager) RecordSuccessWithUsage(baseURL, apiKey string, usage *types.Usage) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tm.recordSuccessWithUsageLocked(baseURL, apiKey, usage, time.Now())\n}\n\nfunc (m *MetricsManager) recordSuccessWithUsageLocked(baseURL, apiKey string, usage *types.Usage, now time.Time) {\n\tmetrics := m.getOrCreateKey(baseURL, apiKey)\n\tmetrics.RequestCount++\n\tmetrics.SuccessCount++\n\tmetrics.ConsecutiveFailures = 0\n\n\tmetrics.LastSuccessAt = &now\n\n\t// 成功后清除熔断标记\n\tif metrics.CircuitBrokenAt != nil {\n\t\tmetrics.CircuitBrokenAt = nil\n\t\tlog.Printf(\"[Metrics-Circuit] Key [%s] (%s) 因请求成功退出熔断状态\", metrics.KeyMask, metrics.BaseURL)\n\t}\n\n\t// 更新滑动窗口\n\tm.appendToWindowKey(metrics, true)\n\n\t// 提取 Token 数据（如果有）\n\tvar inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens int64\n\tif usage != nil {\n\t\tinputTokens = int64(usage.InputTokens)\n\t\toutputTokens = int64(usage.OutputTokens)\n\t\t// cache_creation_input_tokens 有时不会返回（只返回 5m/1h 细分字段），这里做兜底汇总。\n\t\tcacheCreationTokens = int64(usage.CacheCreationInputTokens)\n\t\tif cacheCreationTokens <= 0 {\n\t\t\tcacheCreationTokens = int64(usage.CacheCreation5mInputTokens + usage.CacheCreation1hInputTokens)\n\t\t}\n\t\tcacheReadTokens = int64(usage.CacheReadInputTokens)\n\t}\n\n\t// 记录带时间戳的请求\n\tm.appendToHistoryKeyWithUsage(metrics, now, true, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens)\n\n\t// 写入持久化存储（异步，不阻塞）\n\tif m.store != nil {\n\t\tm.store.AddRecord(PersistentRecord{\n\t\t\tMetricsKey:          metrics.MetricsKey,\n\t\t\tBaseURL:             baseURL,\n\t\t\tKeyMask:             metrics.KeyMask,\n\t\t\tTimestamp:           now,\n\t\t\tSuccess:             true,\n\t\t\tInputTokens:         inputTokens,\n\t\t\tOutputTokens:        outputTokens,\n\t\t\tCacheCreationTokens: cacheCreationTokens,\n\t\t\tCacheReadTokens:     cacheReadTokens,\n\t\t\tAPIType:             m.apiType,\n\t\t})\n\t}\n}\n\n// RecordFailure 记录失败请求（新方法，使用 baseURL + apiKey）\nfunc (m *MetricsManager) RecordFailure(baseURL, apiKey string) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tm.recordFailureLocked(baseURL, apiKey, time.Now())\n}\n\nfunc (m *MetricsManager) recordFailureLocked(baseURL, apiKey string, now time.Time) {\n\tmetrics := m.getOrCreateKey(baseURL, apiKey)\n\tmetrics.RequestCount++\n\tmetrics.FailureCount++\n\tmetrics.ConsecutiveFailures++\n\n\tmetrics.LastFailureAt = &now\n\n\t// 更新滑动窗口\n\tm.appendToWindowKey(metrics, false)\n\n\t// 检查是否刚进入熔断状态\n\tif metrics.CircuitBrokenAt == nil && m.isKeyCircuitBroken(metrics) {\n\t\tmetrics.CircuitBrokenAt = &now\n\t\tlog.Printf(\"[Metrics-Circuit] Key [%s] (%s) 进入熔断状态（失败率: %.1f%%）\", metrics.KeyMask, metrics.BaseURL, m.calculateKeyFailureRateInternal(metrics)*100)\n\t}\n\n\t// 记录带时间戳的请求\n\tm.appendToHistoryKey(metrics, now, false)\n\n\t// 写入持久化存储（异步，不阻塞）\n\tif m.store != nil {\n\t\tm.store.AddRecord(PersistentRecord{\n\t\t\tMetricsKey:          metrics.MetricsKey,\n\t\t\tBaseURL:             baseURL,\n\t\t\tKeyMask:             metrics.KeyMask,\n\t\t\tTimestamp:           now,\n\t\t\tSuccess:             false,\n\t\t\tInputTokens:         0,\n\t\t\tOutputTokens:        0,\n\t\t\tCacheCreationTokens: 0,\n\t\t\tCacheReadTokens:     0,\n\t\t\tAPIType:             m.apiType,\n\t\t})\n\t}\n}\n\n// RecordRequestConnected 记录“开始发起上游请求（TCP 建连阶段）”的请求（用于更实时的活跃度统计）。\n// 返回 requestID，用于后续在请求结束时回写成功/失败与 token。\nfunc (m *MetricsManager) RecordRequestConnected(baseURL, apiKey string) uint64 {\n\treturn m.RecordRequestConnectedAt(baseURL, apiKey, time.Now())\n}\n\n// RecordRequestConnectedAt 与 RecordRequestConnected 相同，但允许注入时间戳（用于测试）。\nfunc (m *MetricsManager) RecordRequestConnectedAt(baseURL, apiKey string, timestamp time.Time) uint64 {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tmetrics := m.getOrCreateKey(baseURL, apiKey)\n\t// RequestCount 改为在 finalize 阶段统一增加，避免 fallback 路径二次计数\n\n\tm.nextRequestID++\n\trequestID := m.nextRequestID\n\n\tif metrics.pendingHistoryIdx == nil {\n\t\tmetrics.pendingHistoryIdx = make(map[uint64]int)\n\t}\n\n\tmetrics.requestHistory = append(metrics.requestHistory, RequestRecord{\n\t\tTimestamp: timestamp,\n\t\tSuccess:   true, // 先按成功计数；结束时会回写真实结果\n\t})\n\tmetrics.pendingHistoryIdx[requestID] = len(metrics.requestHistory) - 1\n\n\t// 清理历史并同步修正索引\n\tm.cleanupHistoryLocked(metrics)\n\n\treturn requestID\n}\n\n// RecordRequestFinalizeSuccess 回写成功结果与 token（requestID 来自 RecordRequestConnected）。\nfunc (m *MetricsManager) RecordRequestFinalizeSuccess(baseURL, apiKey string, requestID uint64, usage *types.Usage) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\tmetrics, exists := m.keyMetrics[metricsKey]\n\tif !exists {\n\t\tm.recordSuccessWithUsageLocked(baseURL, apiKey, usage, time.Now())\n\t\treturn\n\t}\n\n\tidx, ok := metrics.pendingHistoryIdx[requestID]\n\tif !ok || idx < 0 || idx >= len(metrics.requestHistory) {\n\t\tm.recordSuccessWithUsageLocked(baseURL, apiKey, usage, time.Now())\n\t\treturn\n\t}\n\tdelete(metrics.pendingHistoryIdx, requestID)\n\n\t// 正常路径：在此统一增加 RequestCount\n\tmetrics.RequestCount++\n\tmetrics.SuccessCount++\n\tmetrics.ConsecutiveFailures = 0\n\n\tnow := time.Now()\n\tmetrics.LastSuccessAt = &now\n\n\t// 成功后清除熔断标记\n\tif metrics.CircuitBrokenAt != nil {\n\t\tmetrics.CircuitBrokenAt = nil\n\t\tlog.Printf(\"[Metrics-Circuit] Key [%s] (%s) 因请求成功退出熔断状态\", metrics.KeyMask, metrics.BaseURL)\n\t}\n\n\t// 更新滑动窗口\n\tm.appendToWindowKey(metrics, true)\n\n\t// 提取 Token 数据（如果有）\n\tvar inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens int64\n\tif usage != nil {\n\t\tinputTokens = int64(usage.InputTokens)\n\t\toutputTokens = int64(usage.OutputTokens)\n\t\t// cache_creation_input_tokens 有时不会返回（只返回 5m/1h 细分字段），这里做兜底汇总。\n\t\tcacheCreationTokens = int64(usage.CacheCreationInputTokens)\n\t\tif cacheCreationTokens <= 0 {\n\t\t\tcacheCreationTokens = int64(usage.CacheCreation5mInputTokens + usage.CacheCreation1hInputTokens)\n\t\t}\n\t\tcacheReadTokens = int64(usage.CacheReadInputTokens)\n\t}\n\n\t// 回写历史记录（时间戳保持为“请求开始（TCP 建连阶段）”时刻）\n\trecord := &metrics.requestHistory[idx]\n\trecord.Success = true\n\trecord.InputTokens = inputTokens\n\trecord.OutputTokens = outputTokens\n\trecord.CacheCreationInputTokens = cacheCreationTokens\n\trecord.CacheReadInputTokens = cacheReadTokens\n\n\t// 写入持久化存储（异步，不阻塞）\n\tif m.store != nil {\n\t\tm.store.AddRecord(PersistentRecord{\n\t\t\tMetricsKey:          metrics.MetricsKey,\n\t\t\tBaseURL:             baseURL,\n\t\t\tKeyMask:             metrics.KeyMask,\n\t\t\tTimestamp:           record.Timestamp,\n\t\t\tSuccess:             true,\n\t\t\tInputTokens:         inputTokens,\n\t\t\tOutputTokens:        outputTokens,\n\t\t\tCacheCreationTokens: cacheCreationTokens,\n\t\t\tCacheReadTokens:     cacheReadTokens,\n\t\t\tAPIType:             m.apiType,\n\t\t})\n\t}\n}\n\n// RecordRequestFinalizeFailure 回写失败结果（requestID 来自 RecordRequestConnected）。\nfunc (m *MetricsManager) RecordRequestFinalizeFailure(baseURL, apiKey string, requestID uint64) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\tmetrics, exists := m.keyMetrics[metricsKey]\n\tif !exists {\n\t\tm.recordFailureLocked(baseURL, apiKey, time.Now())\n\t\treturn\n\t}\n\n\tidx, ok := metrics.pendingHistoryIdx[requestID]\n\tif !ok || idx < 0 || idx >= len(metrics.requestHistory) {\n\t\tm.recordFailureLocked(baseURL, apiKey, time.Now())\n\t\treturn\n\t}\n\tdelete(metrics.pendingHistoryIdx, requestID)\n\n\t// 正常路径：在此统一增加 RequestCount\n\tmetrics.RequestCount++\n\tmetrics.FailureCount++\n\tmetrics.ConsecutiveFailures++\n\n\tnow := time.Now()\n\tmetrics.LastFailureAt = &now\n\n\t// 更新滑动窗口\n\tm.appendToWindowKey(metrics, false)\n\n\t// 检查是否刚进入熔断状态\n\tif metrics.CircuitBrokenAt == nil && m.isKeyCircuitBroken(metrics) {\n\t\tmetrics.CircuitBrokenAt = &now\n\t\tlog.Printf(\"[Metrics-Circuit] Key [%s] (%s) 进入熔断状态（失败率: %.1f%%）\", metrics.KeyMask, metrics.BaseURL, m.calculateKeyFailureRateInternal(metrics)*100)\n\t}\n\n\t// 回写历史记录（时间戳保持为“请求开始（TCP 建连阶段）”时刻）\n\trecord := &metrics.requestHistory[idx]\n\trecord.Success = false\n\trecord.InputTokens = 0\n\trecord.OutputTokens = 0\n\trecord.CacheCreationInputTokens = 0\n\trecord.CacheReadInputTokens = 0\n\n\t// 写入持久化存储（异步，不阻塞）\n\tif m.store != nil {\n\t\tm.store.AddRecord(PersistentRecord{\n\t\t\tMetricsKey:          metrics.MetricsKey,\n\t\t\tBaseURL:             baseURL,\n\t\t\tKeyMask:             metrics.KeyMask,\n\t\t\tTimestamp:           record.Timestamp,\n\t\t\tSuccess:             false,\n\t\t\tInputTokens:         0,\n\t\t\tOutputTokens:        0,\n\t\t\tCacheCreationTokens: 0,\n\t\t\tCacheReadTokens:     0,\n\t\t\tAPIType:             m.apiType,\n\t\t})\n\t}\n}\n\n// RecordRequestFinalizeClientCancel 记录客户端取消的请求（计入总请求数但不计入失败）\nfunc (m *MetricsManager) RecordRequestFinalizeClientCancel(baseURL, apiKey string, requestID uint64) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\tmetrics, exists := m.keyMetrics[metricsKey]\n\tif !exists {\n\t\treturn\n\t}\n\n\tidx, ok := metrics.pendingHistoryIdx[requestID]\n\tif !ok || idx < 0 || idx >= len(metrics.requestHistory) {\n\t\treturn\n\t}\n\tdelete(metrics.pendingHistoryIdx, requestID)\n\n\t// 仅计入总请求数，不计入失败数\n\tmetrics.RequestCount++\n\t// 注意：不重置 ConsecutiveFailures，客户端取消不应影响连续失败计数\n\n\t// 不更新滑动窗口（不影响失败率计算）\n\t// 不检查熔断状态（客户端取消不应触发熔断）\n\n\t// 从历史记录中移除（客户端取消不记录）\n\tmetrics.requestHistory = append(metrics.requestHistory[:idx], metrics.requestHistory[idx+1:]...)\n\t// 更新后续索引\n\tfor rid, ridx := range metrics.pendingHistoryIdx {\n\t\tif ridx > idx {\n\t\t\tmetrics.pendingHistoryIdx[rid] = ridx - 1\n\t\t}\n\t}\n}\n\n// RecordRequestStart 记录请求开始（增加进行中计数）\nfunc (m *MetricsManager) RecordRequestStart(baseURL, apiKey string) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tmetrics := m.getOrCreateKey(baseURL, apiKey)\n\tmetrics.ActiveRequests++\n}\n\n// RecordRequestEnd 记录请求结束（减少进行中计数）\nfunc (m *MetricsManager) RecordRequestEnd(baseURL, apiKey string) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\tif metrics, exists := m.keyMetrics[metricsKey]; exists {\n\t\tif metrics.ActiveRequests > 0 {\n\t\t\tmetrics.ActiveRequests--\n\t\t}\n\t}\n}\n\n// isKeyCircuitBroken 判断 Key 是否达到熔断条件（内部方法，调用前需持有锁）\nfunc (m *MetricsManager) isKeyCircuitBroken(metrics *KeyMetrics) bool {\n\t// 最小请求数保护：至少 max(3, windowSize/2) 次请求才判断熔断\n\tminRequests := max(3, m.windowSize/2)\n\tif len(metrics.recentResults) < minRequests {\n\t\treturn false\n\t}\n\treturn m.calculateKeyFailureRateInternal(metrics) >= m.failureThreshold\n}\n\n// calculateKeyFailureRateInternal 计算 Key 失败率（内部方法，调用前需持有锁）\nfunc (m *MetricsManager) calculateKeyFailureRateInternal(metrics *KeyMetrics) float64 {\n\tif len(metrics.recentResults) == 0 {\n\t\treturn 0\n\t}\n\tfailures := 0\n\tfor _, success := range metrics.recentResults {\n\t\tif !success {\n\t\t\tfailures++\n\t\t}\n\t}\n\treturn float64(failures) / float64(len(metrics.recentResults))\n}\n\n// appendToWindowKey 向 Key 滑动窗口添加记录\nfunc (m *MetricsManager) appendToWindowKey(metrics *KeyMetrics, success bool) {\n\tmetrics.recentResults = append(metrics.recentResults, success)\n\t// 保持窗口大小\n\tif len(metrics.recentResults) > m.windowSize {\n\t\tmetrics.recentResults = metrics.recentResults[1:]\n\t}\n}\n\n// appendToHistoryKey 向 Key 历史记录添加请求（保留24小时）\nfunc (m *MetricsManager) appendToHistoryKey(metrics *KeyMetrics, timestamp time.Time, success bool) {\n\tm.appendToHistoryKeyWithUsage(metrics, timestamp, success, 0, 0, 0, 0)\n}\n\n// cleanupHistoryLocked 清理超过 24 小时的历史记录，并同步修正 pendingHistoryIdx 索引。\n// 注意：调用方需要持有写锁。\nfunc (m *MetricsManager) cleanupHistoryLocked(metrics *KeyMetrics) {\n\tif metrics == nil || len(metrics.requestHistory) == 0 {\n\t\treturn\n\t}\n\n\tcutoff := time.Now().Add(-24 * time.Hour)\n\n\tnewStart := -1\n\tfor i, record := range metrics.requestHistory {\n\t\tif record.Timestamp.After(cutoff) {\n\t\t\tnewStart = i\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif newStart > 0 {\n\t\tmetrics.requestHistory = metrics.requestHistory[newStart:]\n\t\t// 索引平移：老数据被切走后，pending 索引需要整体减去 newStart\n\t\tif metrics.pendingHistoryIdx != nil && len(metrics.pendingHistoryIdx) > 0 {\n\t\t\tfor id, idx := range metrics.pendingHistoryIdx {\n\t\t\t\tif idx < newStart {\n\t\t\t\t\tdelete(metrics.pendingHistoryIdx, id)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tmetrics.pendingHistoryIdx[id] = idx - newStart\n\t\t\t}\n\t\t}\n\t\treturn\n\t}\n\n\tif newStart == -1 {\n\t\t// 所有记录都过期，清空切片\n\t\tmetrics.requestHistory = metrics.requestHistory[:0]\n\t\tif metrics.pendingHistoryIdx != nil {\n\t\t\tfor id := range metrics.pendingHistoryIdx {\n\t\t\t\tdelete(metrics.pendingHistoryIdx, id)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// appendToHistoryKeyWithUsage 向 Key 历史记录添加请求（带 Usage 数据）\nfunc (m *MetricsManager) appendToHistoryKeyWithUsage(metrics *KeyMetrics, timestamp time.Time, success bool, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens int64) {\n\tmetrics.requestHistory = append(metrics.requestHistory, RequestRecord{\n\t\tTimestamp:                timestamp,\n\t\tSuccess:                  success,\n\t\tInputTokens:              inputTokens,\n\t\tOutputTokens:             outputTokens,\n\t\tCacheCreationInputTokens: cacheCreationTokens,\n\t\tCacheReadInputTokens:     cacheReadTokens,\n\t})\n\n\t// 清理超过 24 小时的记录\n\tm.cleanupHistoryLocked(metrics)\n}\n\n// IsKeyHealthy 判断单个 Key 是否健康\nfunc (m *MetricsManager) IsKeyHealthy(baseURL, apiKey string) bool {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\tmetrics, exists := m.keyMetrics[metricsKey]\n\tif !exists || len(metrics.recentResults) == 0 {\n\t\treturn true // 没有记录，默认健康\n\t}\n\n\treturn m.calculateKeyFailureRateInternal(metrics) < m.failureThreshold\n}\n\n// IsChannelHealthy 判断渠道是否健康（基于当前活跃 Keys 聚合计算）\n// activeKeys: 当前渠道配置的所有活跃 API Keys\nfunc (m *MetricsManager) IsChannelHealthyWithKeys(baseURL string, activeKeys []string) bool {\n\tif len(activeKeys) == 0 {\n\t\treturn false // 没有 Key，不健康\n\t}\n\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\t// 聚合所有活跃 Key 的指标\n\tvar totalResults []bool\n\tfor _, apiKey := range activeKeys {\n\t\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\t\tif metrics, exists := m.keyMetrics[metricsKey]; exists {\n\t\t\ttotalResults = append(totalResults, metrics.recentResults...)\n\t\t}\n\t}\n\n\t// 没有任何记录，默认健康\n\tif len(totalResults) == 0 {\n\t\treturn true\n\t}\n\n\t// 最小请求数保护：至少 max(3, windowSize/2) 次请求才判断健康状态\n\tminRequests := max(3, m.windowSize/2)\n\tif len(totalResults) < minRequests {\n\t\treturn true // 请求数不足，默认健康\n\t}\n\n\t// 计算聚合失败率\n\tfailures := 0\n\tfor _, success := range totalResults {\n\t\tif !success {\n\t\t\tfailures++\n\t\t}\n\t}\n\tfailureRate := float64(failures) / float64(len(totalResults))\n\n\treturn failureRate < m.failureThreshold\n}\n\n// CalculateKeyFailureRate 计算单个 Key 的失败率\nfunc (m *MetricsManager) CalculateKeyFailureRate(baseURL, apiKey string) float64 {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\tmetrics, exists := m.keyMetrics[metricsKey]\n\tif !exists || len(metrics.recentResults) == 0 {\n\t\treturn 0\n\t}\n\n\treturn m.calculateKeyFailureRateInternal(metrics)\n}\n\n// CalculateChannelFailureRate 计算渠道聚合失败率\nfunc (m *MetricsManager) CalculateChannelFailureRate(baseURL string, activeKeys []string) float64 {\n\tif len(activeKeys) == 0 {\n\t\treturn 0\n\t}\n\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tvar totalResults []bool\n\tfor _, apiKey := range activeKeys {\n\t\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\t\tif metrics, exists := m.keyMetrics[metricsKey]; exists {\n\t\t\ttotalResults = append(totalResults, metrics.recentResults...)\n\t\t}\n\t}\n\n\tif len(totalResults) == 0 {\n\t\treturn 0\n\t}\n\n\tfailures := 0\n\tfor _, success := range totalResults {\n\t\tif !success {\n\t\t\tfailures++\n\t\t}\n\t}\n\n\treturn float64(failures) / float64(len(totalResults))\n}\n\n// GetKeyMetrics 获取单个 Key 的指标\nfunc (m *MetricsManager) GetKeyMetrics(baseURL, apiKey string) *KeyMetrics {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\tif metrics, exists := m.keyMetrics[metricsKey]; exists {\n\t\t// 返回副本\n\t\treturn &KeyMetrics{\n\t\t\tMetricsKey:          metrics.MetricsKey,\n\t\t\tBaseURL:             metrics.BaseURL,\n\t\t\tKeyMask:             metrics.KeyMask,\n\t\t\tRequestCount:        metrics.RequestCount,\n\t\t\tSuccessCount:        metrics.SuccessCount,\n\t\t\tFailureCount:        metrics.FailureCount,\n\t\t\tConsecutiveFailures: metrics.ConsecutiveFailures,\n\t\t\tLastSuccessAt:       metrics.LastSuccessAt,\n\t\t\tLastFailureAt:       metrics.LastFailureAt,\n\t\t\tCircuitBrokenAt:     metrics.CircuitBrokenAt,\n\t\t}\n\t}\n\treturn nil\n}\n\n// GetChannelAggregatedMetrics 获取渠道聚合指标（基于活跃 Keys）\nfunc (m *MetricsManager) GetChannelAggregatedMetrics(channelIndex int, baseURL string, activeKeys []string) *ChannelMetrics {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\taggregated := &ChannelMetrics{\n\t\tChannelIndex: channelIndex,\n\t}\n\n\tvar latestSuccess, latestFailure, latestCircuitBroken *time.Time\n\tvar maxConsecutiveFailures int64\n\n\tfor _, apiKey := range activeKeys {\n\t\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\t\tif metrics, exists := m.keyMetrics[metricsKey]; exists {\n\t\t\taggregated.RequestCount += metrics.RequestCount\n\t\t\taggregated.SuccessCount += metrics.SuccessCount\n\t\t\taggregated.FailureCount += metrics.FailureCount\n\t\t\tif metrics.ConsecutiveFailures > maxConsecutiveFailures {\n\t\t\t\tmaxConsecutiveFailures = metrics.ConsecutiveFailures\n\t\t\t}\n\t\t\taggregated.recentResults = append(aggregated.recentResults, metrics.recentResults...)\n\t\t\taggregated.requestHistory = append(aggregated.requestHistory, metrics.requestHistory...)\n\n\t\t\t// 取最新的时间戳\n\t\t\tif metrics.LastSuccessAt != nil && (latestSuccess == nil || metrics.LastSuccessAt.After(*latestSuccess)) {\n\t\t\t\tlatestSuccess = metrics.LastSuccessAt\n\t\t\t}\n\t\t\tif metrics.LastFailureAt != nil && (latestFailure == nil || metrics.LastFailureAt.After(*latestFailure)) {\n\t\t\t\tlatestFailure = metrics.LastFailureAt\n\t\t\t}\n\t\t\tif metrics.CircuitBrokenAt != nil && (latestCircuitBroken == nil || metrics.CircuitBrokenAt.After(*latestCircuitBroken)) {\n\t\t\t\tlatestCircuitBroken = metrics.CircuitBrokenAt\n\t\t\t}\n\t\t}\n\t}\n\n\taggregated.LastSuccessAt = latestSuccess\n\taggregated.LastFailureAt = latestFailure\n\taggregated.CircuitBrokenAt = latestCircuitBroken\n\taggregated.ConsecutiveFailures = maxConsecutiveFailures\n\n\treturn aggregated\n}\n\n// KeyUsageInfo Key 使用信息（用于排序筛选）\ntype KeyUsageInfo struct {\n\tAPIKey       string\n\tKeyMask      string\n\tRequestCount int64\n\tLastUsedAt   *time.Time\n}\n\n// GetChannelKeyUsageInfo 获取渠道下所有 Key 的使用信息（用于排序筛选）\n// 返回的 keys 已按最近使用时间排序\nfunc (m *MetricsManager) GetChannelKeyUsageInfo(baseURL string, apiKeys []string) []KeyUsageInfo {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tinfos := make([]KeyUsageInfo, 0, len(apiKeys))\n\n\tfor _, apiKey := range apiKeys {\n\t\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\t\tmetrics, exists := m.keyMetrics[metricsKey]\n\n\t\tvar keyMask string\n\t\tvar requestCount int64\n\t\tvar lastUsedAt *time.Time\n\n\t\tif exists {\n\t\t\tkeyMask = metrics.KeyMask\n\t\t\trequestCount = metrics.RequestCount\n\t\t\tlastUsedAt = metrics.LastSuccessAt\n\t\t\tif lastUsedAt == nil {\n\t\t\t\tlastUsedAt = metrics.LastFailureAt\n\t\t\t}\n\t\t} else {\n\t\t\t// Key 还没有指标记录，使用默认脱敏\n\t\t\tkeyMask = utils.MaskAPIKey(apiKey)\n\t\t\trequestCount = 0\n\t\t}\n\n\t\tinfos = append(infos, KeyUsageInfo{\n\t\t\tAPIKey:       apiKey,\n\t\t\tKeyMask:      keyMask,\n\t\t\tRequestCount: requestCount,\n\t\t\tLastUsedAt:   lastUsedAt,\n\t\t})\n\t}\n\n\t// 按最近使用时间排序（最近的在前面）\n\tsort.Slice(infos, func(i, j int) bool {\n\t\tif infos[i].LastUsedAt == nil && infos[j].LastUsedAt == nil {\n\t\t\treturn infos[i].RequestCount > infos[j].RequestCount // 都未使用时，按访问量排序\n\t\t}\n\t\tif infos[i].LastUsedAt == nil {\n\t\t\treturn false // i 未使用，排后面\n\t\t}\n\t\tif infos[j].LastUsedAt == nil {\n\t\t\treturn true // j 未使用，i 排前面\n\t\t}\n\t\treturn infos[i].LastUsedAt.After(*infos[j].LastUsedAt)\n\t})\n\n\treturn infos\n}\n\n// GetChannelKeyUsageInfoMultiURL 获取渠道 Key 使用信息（支持多 URL 聚合）\nfunc (m *MetricsManager) GetChannelKeyUsageInfoMultiURL(baseURLs []string, apiKeys []string) []KeyUsageInfo {\n\tif len(baseURLs) == 0 {\n\t\treturn []KeyUsageInfo{}\n\t}\n\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tinfos := make([]KeyUsageInfo, 0, len(apiKeys))\n\n\tfor _, apiKey := range apiKeys {\n\t\tvar keyMask string\n\t\tvar requestCount int64\n\t\tvar lastUsedAt *time.Time\n\t\thasMetrics := false\n\n\t\t// 遍历所有 BaseURL 聚合同一 Key 的指标\n\t\tfor _, baseURL := range baseURLs {\n\t\t\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\t\t\tif metrics, exists := m.keyMetrics[metricsKey]; exists {\n\t\t\t\thasMetrics = true\n\t\t\t\tif keyMask == \"\" {\n\t\t\t\t\tkeyMask = metrics.KeyMask\n\t\t\t\t}\n\t\t\t\trequestCount += metrics.RequestCount\n\n\t\t\t\t// 取最近的使用时间\n\t\t\t\tvar usedAt *time.Time\n\t\t\t\tif metrics.LastSuccessAt != nil {\n\t\t\t\t\tusedAt = metrics.LastSuccessAt\n\t\t\t\t}\n\t\t\t\tif usedAt == nil {\n\t\t\t\t\tusedAt = metrics.LastFailureAt\n\t\t\t\t}\n\t\t\t\tif usedAt != nil && (lastUsedAt == nil || usedAt.After(*lastUsedAt)) {\n\t\t\t\t\tlastUsedAt = usedAt\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif !hasMetrics {\n\t\t\t// Key 还没有指标记录，使用默认脱敏\n\t\t\tkeyMask = utils.MaskAPIKey(apiKey)\n\t\t\trequestCount = 0\n\t\t}\n\n\t\tinfos = append(infos, KeyUsageInfo{\n\t\t\tAPIKey:       apiKey,\n\t\t\tKeyMask:      keyMask,\n\t\t\tRequestCount: requestCount,\n\t\t\tLastUsedAt:   lastUsedAt,\n\t\t})\n\t}\n\n\t// 按最近使用时间排序（最近的在前面）\n\tsort.Slice(infos, func(i, j int) bool {\n\t\tif infos[i].LastUsedAt == nil && infos[j].LastUsedAt == nil {\n\t\t\treturn infos[i].RequestCount > infos[j].RequestCount // 都未使用时，按访问量排序\n\t\t}\n\t\tif infos[i].LastUsedAt == nil {\n\t\t\treturn false // i 未使用，排后面\n\t\t}\n\t\tif infos[j].LastUsedAt == nil {\n\t\t\treturn true // j 未使用，i 排前面\n\t\t}\n\t\treturn infos[i].LastUsedAt.After(*infos[j].LastUsedAt)\n\t})\n\n\treturn infos\n}\n\n// SelectTopKeys 筛选展示的 Key\n// 策略：先取最近使用的 5 个，再从其他 Key 中按访问量补全到 10 个\nfunc SelectTopKeys(infos []KeyUsageInfo, maxDisplay int) []KeyUsageInfo {\n\tif len(infos) <= maxDisplay {\n\t\treturn infos\n\t}\n\n\t// 分离：最近使用的和未使用的\n\tvar recentKeys []KeyUsageInfo\n\tvar otherKeys []KeyUsageInfo\n\n\tfor i, info := range infos {\n\t\tif i < 5 {\n\t\t\trecentKeys = append(recentKeys, info)\n\t\t} else {\n\t\t\totherKeys = append(otherKeys, info)\n\t\t}\n\t}\n\n\t// 其他 Key 按访问量排序（降序）\n\tsort.Slice(otherKeys, func(i, j int) bool {\n\t\treturn otherKeys[i].RequestCount > otherKeys[j].RequestCount\n\t})\n\n\t// 补全到 maxDisplay 个\n\tresult := make([]KeyUsageInfo, 0, maxDisplay)\n\tresult = append(result, recentKeys...)\n\n\tneedCount := maxDisplay - len(recentKeys)\n\tif needCount > 0 && len(otherKeys) > 0 {\n\t\tif len(otherKeys) > needCount {\n\t\t\totherKeys = otherKeys[:needCount]\n\t\t}\n\t\tresult = append(result, otherKeys...)\n\t}\n\n\treturn result\n}\n\n// GetAllKeyMetrics 获取所有 Key 的指标\nfunc (m *MetricsManager) GetAllKeyMetrics() []*KeyMetrics {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tresult := make([]*KeyMetrics, 0, len(m.keyMetrics))\n\tfor _, metrics := range m.keyMetrics {\n\t\tresult = append(result, &KeyMetrics{\n\t\t\tMetricsKey:          metrics.MetricsKey,\n\t\t\tBaseURL:             metrics.BaseURL,\n\t\t\tKeyMask:             metrics.KeyMask,\n\t\t\tRequestCount:        metrics.RequestCount,\n\t\t\tSuccessCount:        metrics.SuccessCount,\n\t\t\tFailureCount:        metrics.FailureCount,\n\t\t\tConsecutiveFailures: metrics.ConsecutiveFailures,\n\t\t\tLastSuccessAt:       metrics.LastSuccessAt,\n\t\t\tLastFailureAt:       metrics.LastFailureAt,\n\t\t\tCircuitBrokenAt:     metrics.CircuitBrokenAt,\n\t\t})\n\t}\n\treturn result\n}\n\n// GetTimeWindowStatsForKey 获取指定 Key 在时间窗口内的统计\nfunc (m *MetricsManager) GetTimeWindowStatsForKey(baseURL, apiKey string, duration time.Duration) TimeWindowStats {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\tmetrics, exists := m.keyMetrics[metricsKey]\n\tif !exists {\n\t\treturn TimeWindowStats{SuccessRate: 100}\n\t}\n\n\tcutoff := time.Now().Add(-duration)\n\tvar requestCount, successCount, failureCount int64\n\n\tfor _, record := range metrics.requestHistory {\n\t\tif record.Timestamp.After(cutoff) {\n\t\t\trequestCount++\n\t\t\tif record.Success {\n\t\t\t\tsuccessCount++\n\t\t\t} else {\n\t\t\t\tfailureCount++\n\t\t\t}\n\t\t}\n\t}\n\n\tsuccessRate := float64(100)\n\tif requestCount > 0 {\n\t\tsuccessRate = float64(successCount) / float64(requestCount) * 100\n\t}\n\n\treturn TimeWindowStats{\n\t\tRequestCount: requestCount,\n\t\tSuccessCount: successCount,\n\t\tFailureCount: failureCount,\n\t\tSuccessRate:  successRate,\n\t}\n}\n\n// GetAllTimeWindowStatsForKey 获取单个 Key 所有时间窗口的统计\nfunc (m *MetricsManager) GetAllTimeWindowStatsForKey(baseURL, apiKey string) map[string]TimeWindowStats {\n\treturn map[string]TimeWindowStats{\n\t\t\"15m\": m.GetTimeWindowStatsForKey(baseURL, apiKey, 15*time.Minute),\n\t\t\"1h\":  m.GetTimeWindowStatsForKey(baseURL, apiKey, 1*time.Hour),\n\t\t\"6h\":  m.GetTimeWindowStatsForKey(baseURL, apiKey, 6*time.Hour),\n\t\t\"24h\": m.GetTimeWindowStatsForKey(baseURL, apiKey, 24*time.Hour),\n\t}\n}\n\n// ResetKeyFailureState 重置单个 Key 的熔断/失败状态（保留历史统计与总量计数）。\n// 用于“恢复熔断”场景：清零连续失败、清空滑动窗口、解除熔断标记。\nfunc (m *MetricsManager) ResetKeyFailureState(baseURL, apiKey string) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\tif metrics, exists := m.keyMetrics[metricsKey]; exists {\n\t\tmetrics.ConsecutiveFailures = 0\n\t\tmetrics.recentResults = make([]bool, 0, m.windowSize)\n\t\tmetrics.CircuitBrokenAt = nil\n\t\tlog.Printf(\"[Metrics-Reset] Key [%s] (%s) 熔断状态已重置（保留历史统计）\", metrics.KeyMask, metrics.BaseURL)\n\t}\n}\n\n// ResetKey 重置单个 Key 的指标\nfunc (m *MetricsManager) ResetKey(baseURL, apiKey string) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\tif metrics, exists := m.keyMetrics[metricsKey]; exists {\n\t\t// 完全重置所有字段\n\t\tmetrics.RequestCount = 0\n\t\tmetrics.SuccessCount = 0\n\t\tmetrics.FailureCount = 0\n\t\tmetrics.ConsecutiveFailures = 0\n\t\tmetrics.ActiveRequests = 0\n\t\tmetrics.LastSuccessAt = nil\n\t\tmetrics.LastFailureAt = nil\n\t\tmetrics.CircuitBrokenAt = nil\n\t\tmetrics.recentResults = make([]bool, 0, m.windowSize)\n\t\tmetrics.requestHistory = nil\n\t\tif metrics.pendingHistoryIdx != nil {\n\t\t\tfor id := range metrics.pendingHistoryIdx {\n\t\t\t\tdelete(metrics.pendingHistoryIdx, id)\n\t\t\t}\n\t\t}\n\t\tlog.Printf(\"[Metrics-Reset] Key [%s] (%s) 指标已完全重置\", metrics.KeyMask, metrics.BaseURL)\n\t}\n}\n\n// ResetAll 重置所有指标\nfunc (m *MetricsManager) ResetAll() {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tm.keyMetrics = make(map[string]*KeyMetrics)\n}\n\n// Stop 停止后台清理任务\nfunc (m *MetricsManager) Stop() {\n\tclose(m.stopCh)\n}\n\n// DeleteKeysForChannel 删除指定渠道的所有内存指标\n// baseURLs: 渠道的所有 BaseURL（支持多端点 failover）\n// apiKeys: 渠道的所有 API Key\n// 返回所有可能的 metricsKey 列表（无论内存中是否存在，用于后续清理持久化数据）\nfunc (m *MetricsManager) DeleteKeysForChannel(baseURLs, apiKeys []string) []string {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tvar allKeys []string\n\tvar deletedFromMemory int\n\n\tfor _, baseURL := range baseURLs {\n\t\tfor _, apiKey := range apiKeys {\n\t\t\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\t\t\tallKeys = append(allKeys, metricsKey)\n\t\t\tif _, exists := m.keyMetrics[metricsKey]; exists {\n\t\t\t\tdelete(m.keyMetrics, metricsKey)\n\t\t\t\tdeletedFromMemory++\n\t\t\t}\n\t\t}\n\t}\n\n\tif deletedFromMemory > 0 {\n\t\tlog.Printf(\"[Metrics-Delete] 已删除 %d 个内存指标记录\", deletedFromMemory)\n\t}\n\n\treturn allKeys\n}\n\n// DeleteChannelMetrics 删除渠道的所有指标数据（内存 + 持久化）\n// baseURLs: 渠道的所有 BaseURL（支持多端点 failover）\n// apiKeys: 渠道的所有 API Key\n// 返回被删除的持久化记录数\nfunc (m *MetricsManager) DeleteChannelMetrics(baseURLs, apiKeys []string) int64 {\n\t// 1. 删除内存指标，获取 metricsKey 列表\n\tdeletedKeys := m.DeleteKeysForChannel(baseURLs, apiKeys)\n\n\t// 2. 删除持久化数据（使用内部 apiType，避免外部误传）\n\tif m.store != nil && len(deletedKeys) > 0 {\n\t\tdeleted, err := m.store.DeleteRecordsByMetricsKeys(deletedKeys, m.apiType)\n\t\tif err != nil {\n\t\t\tlog.Printf(\"[Metrics-Delete] 警告: 删除持久化指标记录失败: %v\", err)\n\t\t\treturn 0\n\t\t}\n\t\tif deleted > 0 {\n\t\t\tlog.Printf(\"[Metrics-Delete] 已删除 %d 条 %s 持久化指标记录\", deleted, m.apiType)\n\t\t}\n\t\treturn deleted\n\t}\n\n\treturn 0\n}\n\n// cleanupCircuitBreakers 后台任务：定期检查并恢复超时的熔断 Key，清理过期指标\nfunc (m *MetricsManager) cleanupCircuitBreakers() {\n\tticker := time.NewTicker(1 * time.Minute)\n\tdefer ticker.Stop()\n\n\t// 每小时清理一次过期 Key\n\tcleanupTicker := time.NewTicker(1 * time.Hour)\n\tdefer cleanupTicker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ticker.C:\n\t\t\tm.recoverExpiredCircuitBreakers()\n\t\tcase <-cleanupTicker.C:\n\t\t\tm.cleanupStaleKeys()\n\t\tcase <-m.stopCh:\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// recoverExpiredCircuitBreakers 恢复超时的熔断 Key\nfunc (m *MetricsManager) recoverExpiredCircuitBreakers() {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tnow := time.Now()\n\tfor _, metrics := range m.keyMetrics {\n\t\tif metrics.CircuitBrokenAt != nil {\n\t\t\telapsed := now.Sub(*metrics.CircuitBrokenAt)\n\t\t\tif elapsed > m.circuitRecoveryTime {\n\t\t\t\t// 重置熔断状态\n\t\t\t\tmetrics.ConsecutiveFailures = 0\n\t\t\t\tmetrics.recentResults = make([]bool, 0, m.windowSize)\n\t\t\t\tmetrics.CircuitBrokenAt = nil\n\t\t\t\tlog.Printf(\"[Metrics-Circuit] Key [%s] (%s) 熔断自动恢复（已超过 %v）\", metrics.KeyMask, metrics.BaseURL, m.circuitRecoveryTime)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// cleanupStaleKeys 清理过期的 Key 指标（超过 48 小时无活动）\nfunc (m *MetricsManager) cleanupStaleKeys() {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tnow := time.Now()\n\tstaleThreshold := 48 * time.Hour\n\tvar removed []string\n\n\tfor key, metrics := range m.keyMetrics {\n\t\t// 判断最后活动时间\n\t\tvar lastActivity time.Time\n\t\tif metrics.LastSuccessAt != nil {\n\t\t\tlastActivity = *metrics.LastSuccessAt\n\t\t}\n\t\tif metrics.LastFailureAt != nil && metrics.LastFailureAt.After(lastActivity) {\n\t\t\tlastActivity = *metrics.LastFailureAt\n\t\t}\n\n\t\t// 如果从未有活动或超过阈值，删除\n\t\tif lastActivity.IsZero() || now.Sub(lastActivity) > staleThreshold {\n\t\t\tdelete(m.keyMetrics, key)\n\t\t\tremoved = append(removed, metrics.KeyMask)\n\t\t}\n\t}\n\n\tif len(removed) > 0 {\n\t\tlog.Printf(\"[Metrics-Cleanup] 清理了 %d 个过期 Key 指标: %v\", len(removed), removed)\n\t}\n}\n\n// GetCircuitRecoveryTime 获取熔断恢复时间\nfunc (m *MetricsManager) GetCircuitRecoveryTime() time.Duration {\n\treturn m.circuitRecoveryTime\n}\n\n// GetFailureThreshold 获取失败率阈值\nfunc (m *MetricsManager) GetFailureThreshold() float64 {\n\treturn m.failureThreshold\n}\n\n// GetWindowSize 获取滑动窗口大小\nfunc (m *MetricsManager) GetWindowSize() int {\n\treturn m.windowSize\n}\n\n// ============ 兼容旧 API 的方法（基于 channelIndex，需要调用方提供 baseURL 和 keys）============\n\n// MetricsResponse API 响应结构\ntype MetricsResponse struct {\n\tChannelIndex        int                        `json:\"channelIndex\"`\n\tRequestCount        int64                      `json:\"requestCount\"`\n\tSuccessCount        int64                      `json:\"successCount\"`\n\tFailureCount        int64                      `json:\"failureCount\"`\n\tSuccessRate         float64                    `json:\"successRate\"`\n\tErrorRate           float64                    `json:\"errorRate\"`\n\tConsecutiveFailures int64                      `json:\"consecutiveFailures\"`\n\tActiveRequests      int64                      `json:\"activeRequests\"` // 进行中请求数\n\tLatency             int64                      `json:\"latency\"`\n\tLastSuccessAt       *string                    `json:\"lastSuccessAt,omitempty\"`\n\tLastFailureAt       *string                    `json:\"lastFailureAt,omitempty\"`\n\tCircuitBrokenAt     *string                    `json:\"circuitBrokenAt,omitempty\"`\n\tTimeWindows         map[string]TimeWindowStats `json:\"timeWindows,omitempty\"`\n\tKeyMetrics          []*KeyMetricsResponse      `json:\"keyMetrics,omitempty\"` // 各 Key 的详细指标\n}\n\n// KeyMetricsResponse 单个 Key 的 API 响应\ntype KeyMetricsResponse struct {\n\tKeyMask             string  `json:\"keyMask\"`\n\tRequestCount        int64   `json:\"requestCount\"`\n\tSuccessCount        int64   `json:\"successCount\"`\n\tFailureCount        int64   `json:\"failureCount\"`\n\tSuccessRate         float64 `json:\"successRate\"`\n\tConsecutiveFailures int64   `json:\"consecutiveFailures\"`\n\tCircuitBroken       bool    `json:\"circuitBroken\"`\n}\n\n// ToResponseMultiURL 转换为 API 响应格式（支持多 BaseURL 聚合）\n// baseURLs: 渠道配置的所有 BaseURL（用于多端点 failover 场景）\n// historicalKeys: 历史 API Key（用于统计聚合，只计入总数不显示在 KeyMetrics 中）\nfunc (m *MetricsManager) ToResponseMultiURL(channelIndex int, baseURLs []string, activeKeys []string, latency int64, historicalKeys ...[]string) *MetricsResponse {\n\t// 如果没有配置 BaseURL，返回空响应\n\tif len(baseURLs) == 0 {\n\t\treturn &MetricsResponse{\n\t\t\tChannelIndex: channelIndex,\n\t\t\tLatency:      latency,\n\t\t\tSuccessRate:  100,\n\t\t\tErrorRate:    0,\n\t\t}\n\t}\n\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tresp := &MetricsResponse{\n\t\tChannelIndex: channelIndex,\n\t\tLatency:      latency,\n\t}\n\n\tif len(activeKeys) == 0 {\n\t\tresp.SuccessRate = 100\n\t\tresp.ErrorRate = 0\n\t\treturn resp\n\t}\n\n\t// 用于按 API Key 聚合的临时结构\n\ttype keyAggregation struct {\n\t\tkeyMask             string\n\t\trequestCount        int64\n\t\tsuccessCount        int64\n\t\tfailureCount        int64\n\t\tconsecutiveFailures int64\n\t\tcircuitBroken       bool\n\t}\n\tkeyAggMap := make(map[string]*keyAggregation) // key: apiKey\n\n\tvar latestSuccess, latestFailure, latestCircuitBroken *time.Time\n\tvar totalResults []bool\n\tvar maxConsecutiveFailures int64\n\n\t// 遍历所有 BaseURL 和 Key 的组合\n\tfor _, baseURL := range baseURLs {\n\t\tfor _, apiKey := range activeKeys {\n\t\t\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\t\t\tif metrics, exists := m.keyMetrics[metricsKey]; exists {\n\t\t\t\tresp.RequestCount += metrics.RequestCount\n\t\t\t\tresp.SuccessCount += metrics.SuccessCount\n\t\t\t\tresp.FailureCount += metrics.FailureCount\n\t\t\t\tresp.ActiveRequests += metrics.ActiveRequests\n\t\t\t\tif metrics.ConsecutiveFailures > maxConsecutiveFailures {\n\t\t\t\t\tmaxConsecutiveFailures = metrics.ConsecutiveFailures\n\t\t\t\t}\n\t\t\t\ttotalResults = append(totalResults, metrics.recentResults...)\n\n\t\t\t\t// 取最新的时间戳\n\t\t\t\tif metrics.LastSuccessAt != nil && (latestSuccess == nil || metrics.LastSuccessAt.After(*latestSuccess)) {\n\t\t\t\t\tlatestSuccess = metrics.LastSuccessAt\n\t\t\t\t}\n\t\t\t\tif metrics.LastFailureAt != nil && (latestFailure == nil || metrics.LastFailureAt.After(*latestFailure)) {\n\t\t\t\t\tlatestFailure = metrics.LastFailureAt\n\t\t\t\t}\n\t\t\t\tif metrics.CircuitBrokenAt != nil && (latestCircuitBroken == nil || metrics.CircuitBrokenAt.After(*latestCircuitBroken)) {\n\t\t\t\t\tlatestCircuitBroken = metrics.CircuitBrokenAt\n\t\t\t\t}\n\n\t\t\t\t// 按 API Key 聚合（同一 Key 在不同 URL 的指标合并）\n\t\t\t\tif agg, ok := keyAggMap[apiKey]; ok {\n\t\t\t\t\tagg.requestCount += metrics.RequestCount\n\t\t\t\t\tagg.successCount += metrics.SuccessCount\n\t\t\t\t\tagg.failureCount += metrics.FailureCount\n\t\t\t\t\tif metrics.ConsecutiveFailures > agg.consecutiveFailures {\n\t\t\t\t\t\tagg.consecutiveFailures = metrics.ConsecutiveFailures\n\t\t\t\t\t}\n\t\t\t\t\tif metrics.CircuitBrokenAt != nil {\n\t\t\t\t\t\tagg.circuitBroken = true\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tkeyAggMap[apiKey] = &keyAggregation{\n\t\t\t\t\t\tkeyMask:             metrics.KeyMask,\n\t\t\t\t\t\trequestCount:        metrics.RequestCount,\n\t\t\t\t\t\tsuccessCount:        metrics.SuccessCount,\n\t\t\t\t\t\tfailureCount:        metrics.FailureCount,\n\t\t\t\t\t\tconsecutiveFailures: metrics.ConsecutiveFailures,\n\t\t\t\t\t\tcircuitBroken:       metrics.CircuitBrokenAt != nil,\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// 聚合历史 Key 的指标（只计入总数，不显示在 KeyMetrics 中）\n\tif len(historicalKeys) > 0 && len(historicalKeys[0]) > 0 {\n\t\tfor _, baseURL := range baseURLs {\n\t\t\tfor _, apiKey := range historicalKeys[0] {\n\t\t\t\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\t\t\t\tif metrics, exists := m.keyMetrics[metricsKey]; exists {\n\t\t\t\t\tresp.RequestCount += metrics.RequestCount\n\t\t\t\t\tresp.SuccessCount += metrics.SuccessCount\n\t\t\t\t\tresp.FailureCount += metrics.FailureCount\n\t\t\t\t\t// 历史 Key 不计入 totalResults（不影响实时失败率计算）\n\t\t\t\t\t// 历史 Key 不计入 maxConsecutiveFailures（不影响熔断判断）\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// 构建按 Key 聚合后的响应（保持 activeKeys 顺序）\n\tvar keyResponses []*KeyMetricsResponse\n\tfor _, apiKey := range activeKeys {\n\t\tif agg, ok := keyAggMap[apiKey]; ok {\n\t\t\tkeySuccessRate := float64(100)\n\t\t\tif agg.requestCount > 0 {\n\t\t\t\tkeySuccessRate = float64(agg.successCount) / float64(agg.requestCount) * 100\n\t\t\t}\n\t\t\tkeyResponses = append(keyResponses, &KeyMetricsResponse{\n\t\t\t\tKeyMask:             agg.keyMask,\n\t\t\t\tRequestCount:        agg.requestCount,\n\t\t\t\tSuccessCount:        agg.successCount,\n\t\t\t\tFailureCount:        agg.failureCount,\n\t\t\t\tSuccessRate:         keySuccessRate,\n\t\t\t\tConsecutiveFailures: agg.consecutiveFailures,\n\t\t\t\tCircuitBroken:       agg.circuitBroken,\n\t\t\t})\n\t\t}\n\t}\n\n\t// 计算聚合失败率\n\tresp.ConsecutiveFailures = maxConsecutiveFailures\n\n\tif len(totalResults) > 0 {\n\t\tfailures := 0\n\t\tfor _, success := range totalResults {\n\t\t\tif !success {\n\t\t\t\tfailures++\n\t\t\t}\n\t\t}\n\t\tfailureRate := float64(failures) / float64(len(totalResults))\n\t\tresp.SuccessRate = (1 - failureRate) * 100\n\t\tresp.ErrorRate = failureRate * 100\n\t} else {\n\t\tresp.SuccessRate = 100\n\t\tresp.ErrorRate = 0\n\t}\n\n\tif latestSuccess != nil {\n\t\tt := latestSuccess.Format(time.RFC3339)\n\t\tresp.LastSuccessAt = &t\n\t}\n\tif latestFailure != nil {\n\t\tt := latestFailure.Format(time.RFC3339)\n\t\tresp.LastFailureAt = &t\n\t}\n\tif latestCircuitBroken != nil {\n\t\tt := latestCircuitBroken.Format(time.RFC3339)\n\t\tresp.CircuitBrokenAt = &t\n\t}\n\n\tresp.KeyMetrics = keyResponses\n\n\t// 计算聚合的时间窗口统计（多 URL 版本）\n\tresp.TimeWindows = m.calculateAggregatedTimeWindowsMultiURL(baseURLs, activeKeys)\n\n\treturn resp\n}\n\n// ToResponse 转换为 API 响应格式（需要提供 baseURL 和 activeKeys）\nfunc (m *MetricsManager) ToResponse(channelIndex int, baseURL string, activeKeys []string, latency int64) *MetricsResponse {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tresp := &MetricsResponse{\n\t\tChannelIndex: channelIndex,\n\t\tLatency:      latency,\n\t}\n\n\tif len(activeKeys) == 0 {\n\t\tresp.SuccessRate = 100\n\t\tresp.ErrorRate = 0\n\t\treturn resp\n\t}\n\n\tvar keyResponses []*KeyMetricsResponse\n\tvar latestSuccess, latestFailure, latestCircuitBroken *time.Time\n\tvar totalResults []bool\n\tvar maxConsecutiveFailures int64\n\n\tfor _, apiKey := range activeKeys {\n\t\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\t\tif metrics, exists := m.keyMetrics[metricsKey]; exists {\n\t\t\tresp.RequestCount += metrics.RequestCount\n\t\t\tresp.SuccessCount += metrics.SuccessCount\n\t\t\tresp.FailureCount += metrics.FailureCount\n\t\t\tresp.ActiveRequests += metrics.ActiveRequests\n\t\t\tif metrics.ConsecutiveFailures > maxConsecutiveFailures {\n\t\t\t\tmaxConsecutiveFailures = metrics.ConsecutiveFailures\n\t\t\t}\n\t\t\ttotalResults = append(totalResults, metrics.recentResults...)\n\n\t\t\t// 取最新的时间戳\n\t\t\tif metrics.LastSuccessAt != nil && (latestSuccess == nil || metrics.LastSuccessAt.After(*latestSuccess)) {\n\t\t\t\tlatestSuccess = metrics.LastSuccessAt\n\t\t\t}\n\t\t\tif metrics.LastFailureAt != nil && (latestFailure == nil || metrics.LastFailureAt.After(*latestFailure)) {\n\t\t\t\tlatestFailure = metrics.LastFailureAt\n\t\t\t}\n\t\t\tif metrics.CircuitBrokenAt != nil && (latestCircuitBroken == nil || metrics.CircuitBrokenAt.After(*latestCircuitBroken)) {\n\t\t\t\tlatestCircuitBroken = metrics.CircuitBrokenAt\n\t\t\t}\n\n\t\t\t// 单个 Key 的指标\n\t\t\tkeySuccessRate := float64(100)\n\t\t\tif metrics.RequestCount > 0 {\n\t\t\t\tkeySuccessRate = float64(metrics.SuccessCount) / float64(metrics.RequestCount) * 100\n\t\t\t}\n\t\t\tkeyResponses = append(keyResponses, &KeyMetricsResponse{\n\t\t\t\tKeyMask:             metrics.KeyMask,\n\t\t\t\tRequestCount:        metrics.RequestCount,\n\t\t\t\tSuccessCount:        metrics.SuccessCount,\n\t\t\t\tFailureCount:        metrics.FailureCount,\n\t\t\t\tSuccessRate:         keySuccessRate,\n\t\t\t\tConsecutiveFailures: metrics.ConsecutiveFailures,\n\t\t\t\tCircuitBroken:       metrics.CircuitBrokenAt != nil,\n\t\t\t})\n\t\t}\n\t}\n\n\t// 计算聚合失败率\n\tresp.ConsecutiveFailures = maxConsecutiveFailures\n\n\tif len(totalResults) > 0 {\n\t\tfailures := 0\n\t\tfor _, success := range totalResults {\n\t\t\tif !success {\n\t\t\t\tfailures++\n\t\t\t}\n\t\t}\n\t\tfailureRate := float64(failures) / float64(len(totalResults))\n\t\tresp.SuccessRate = (1 - failureRate) * 100\n\t\tresp.ErrorRate = failureRate * 100\n\t} else {\n\t\tresp.SuccessRate = 100\n\t\tresp.ErrorRate = 0\n\t}\n\n\tif latestSuccess != nil {\n\t\tt := latestSuccess.Format(time.RFC3339)\n\t\tresp.LastSuccessAt = &t\n\t}\n\tif latestFailure != nil {\n\t\tt := latestFailure.Format(time.RFC3339)\n\t\tresp.LastFailureAt = &t\n\t}\n\tif latestCircuitBroken != nil {\n\t\tt := latestCircuitBroken.Format(time.RFC3339)\n\t\tresp.CircuitBrokenAt = &t\n\t}\n\n\tresp.KeyMetrics = keyResponses\n\n\t// 计算聚合的时间窗口统计\n\tresp.TimeWindows = m.calculateAggregatedTimeWindowsInternal(baseURL, activeKeys)\n\n\treturn resp\n}\n\n// calculateAggregatedTimeWindowsInternal 计算聚合的时间窗口统计（内部方法，调用前需持有锁）\nfunc (m *MetricsManager) calculateAggregatedTimeWindowsInternal(baseURL string, activeKeys []string) map[string]TimeWindowStats {\n\twindows := map[string]time.Duration{\n\t\t\"15m\": 15 * time.Minute,\n\t\t\"1h\":  1 * time.Hour,\n\t\t\"6h\":  6 * time.Hour,\n\t\t\"24h\": 24 * time.Hour,\n\t}\n\n\tresult := make(map[string]TimeWindowStats)\n\tnow := time.Now()\n\n\tfor label, duration := range windows {\n\t\tcutoff := now.Add(-duration)\n\t\tvar requestCount, successCount, failureCount int64\n\t\tvar inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens int64\n\n\t\tfor _, apiKey := range activeKeys {\n\t\t\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\t\t\tif metrics, exists := m.keyMetrics[metricsKey]; exists {\n\t\t\t\tfor _, record := range metrics.requestHistory {\n\t\t\t\t\tif record.Timestamp.After(cutoff) {\n\t\t\t\t\t\trequestCount++\n\t\t\t\t\t\tif record.Success {\n\t\t\t\t\t\t\tsuccessCount++\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tfailureCount++\n\t\t\t\t\t\t}\n\t\t\t\t\t\tinputTokens += record.InputTokens\n\t\t\t\t\t\toutputTokens += record.OutputTokens\n\t\t\t\t\t\tcacheCreationTokens += record.CacheCreationInputTokens\n\t\t\t\t\t\tcacheReadTokens += record.CacheReadInputTokens\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tsuccessRate := float64(100)\n\t\tif requestCount > 0 {\n\t\t\tsuccessRate = float64(successCount) / float64(requestCount) * 100\n\t\t}\n\n\t\tcacheHitRate := float64(0)\n\t\tdenom := cacheReadTokens + inputTokens\n\t\tif denom > 0 {\n\t\t\tcacheHitRate = float64(cacheReadTokens) / float64(denom) * 100\n\t\t}\n\n\t\tresult[label] = TimeWindowStats{\n\t\t\tRequestCount:        requestCount,\n\t\t\tSuccessCount:        successCount,\n\t\t\tFailureCount:        failureCount,\n\t\t\tSuccessRate:         successRate,\n\t\t\tInputTokens:         inputTokens,\n\t\t\tOutputTokens:        outputTokens,\n\t\t\tCacheCreationTokens: cacheCreationTokens,\n\t\t\tCacheReadTokens:     cacheReadTokens,\n\t\t\tCacheHitRate:        cacheHitRate,\n\t\t}\n\t}\n\n\treturn result\n}\n\n// calculateAggregatedTimeWindowsMultiURL 计算聚合的时间窗口统计（多 URL 版本，内部方法，调用前需持有锁）\nfunc (m *MetricsManager) calculateAggregatedTimeWindowsMultiURL(baseURLs []string, activeKeys []string) map[string]TimeWindowStats {\n\twindows := map[string]time.Duration{\n\t\t\"15m\": 15 * time.Minute,\n\t\t\"1h\":  1 * time.Hour,\n\t\t\"6h\":  6 * time.Hour,\n\t\t\"24h\": 24 * time.Hour,\n\t}\n\n\tresult := make(map[string]TimeWindowStats)\n\tnow := time.Now()\n\n\tfor label, duration := range windows {\n\t\tcutoff := now.Add(-duration)\n\t\tvar requestCount, successCount, failureCount int64\n\t\tvar inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens int64\n\n\t\t// 遍历所有 BaseURL 和 Key 的组合\n\t\tfor _, baseURL := range baseURLs {\n\t\t\tfor _, apiKey := range activeKeys {\n\t\t\t\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\t\t\t\tif metrics, exists := m.keyMetrics[metricsKey]; exists {\n\t\t\t\t\tfor _, record := range metrics.requestHistory {\n\t\t\t\t\t\tif record.Timestamp.After(cutoff) {\n\t\t\t\t\t\t\trequestCount++\n\t\t\t\t\t\t\tif record.Success {\n\t\t\t\t\t\t\t\tsuccessCount++\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tfailureCount++\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tinputTokens += record.InputTokens\n\t\t\t\t\t\t\toutputTokens += record.OutputTokens\n\t\t\t\t\t\t\tcacheCreationTokens += record.CacheCreationInputTokens\n\t\t\t\t\t\t\tcacheReadTokens += record.CacheReadInputTokens\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tsuccessRate := float64(100)\n\t\tif requestCount > 0 {\n\t\t\tsuccessRate = float64(successCount) / float64(requestCount) * 100\n\t\t}\n\n\t\tcacheHitRate := float64(0)\n\t\tdenom := cacheReadTokens + inputTokens\n\t\tif denom > 0 {\n\t\t\tcacheHitRate = float64(cacheReadTokens) / float64(denom) * 100\n\t\t}\n\n\t\tresult[label] = TimeWindowStats{\n\t\t\tRequestCount:        requestCount,\n\t\t\tSuccessCount:        successCount,\n\t\t\tFailureCount:        failureCount,\n\t\t\tSuccessRate:         successRate,\n\t\t\tInputTokens:         inputTokens,\n\t\t\tOutputTokens:        outputTokens,\n\t\t\tCacheCreationTokens: cacheCreationTokens,\n\t\t\tCacheReadTokens:     cacheReadTokens,\n\t\t\tCacheHitRate:        cacheHitRate,\n\t\t}\n\t}\n\n\treturn result\n}\n\n// ============ 废弃的旧方法（保留签名以便编译，但标记为废弃）============\n\n// Deprecated: 使用 IsChannelHealthyWithKeys 代替\n// IsChannelHealthy 判断渠道是否健康（旧方法，不再使用 channelIndex）\n// 此方法保留是为了兼容，但始终返回 true，调用方应迁移到新方法\nfunc (m *MetricsManager) IsChannelHealthy(channelIndex int) bool {\n\tlog.Printf(\"[Metrics-Deprecated] 警告: 调用了废弃的 IsChannelHealthy(channelIndex=%d)，请迁移到 IsChannelHealthyWithKeys\", channelIndex)\n\treturn true // 默认健康，避免影响现有逻辑\n}\n\n// Deprecated: 使用 CalculateChannelFailureRate 代替\nfunc (m *MetricsManager) CalculateFailureRate(channelIndex int) float64 {\n\treturn 0\n}\n\n// Deprecated: 使用 CalculateChannelFailureRate 代替\nfunc (m *MetricsManager) CalculateSuccessRate(channelIndex int) float64 {\n\treturn 1\n}\n\n// Deprecated: 使用 ResetKey 代替\nfunc (m *MetricsManager) Reset(channelIndex int) {\n\tlog.Printf(\"[Metrics-Deprecated] 警告: 调用了废弃的 Reset(channelIndex=%d)，请迁移到 ResetKey\", channelIndex)\n}\n\n// Deprecated: 使用 GetChannelAggregatedMetrics 代替\nfunc (m *MetricsManager) GetMetrics(channelIndex int) *ChannelMetrics {\n\treturn nil\n}\n\n// Deprecated: 使用 GetAllKeyMetrics 代替\nfunc (m *MetricsManager) GetAllMetrics() []*ChannelMetrics {\n\treturn nil\n}\n\n// Deprecated: 使用 GetTimeWindowStatsForKey 代替\nfunc (m *MetricsManager) GetTimeWindowStats(channelIndex int, duration time.Duration) TimeWindowStats {\n\treturn TimeWindowStats{SuccessRate: 100}\n}\n\n// Deprecated: 使用 GetAllTimeWindowStatsForKey 代替\nfunc (m *MetricsManager) GetAllTimeWindowStats(channelIndex int) map[string]TimeWindowStats {\n\treturn map[string]TimeWindowStats{\n\t\t\"15m\": {SuccessRate: 100},\n\t\t\"1h\":  {SuccessRate: 100},\n\t\t\"6h\":  {SuccessRate: 100},\n\t\t\"24h\": {SuccessRate: 100},\n\t}\n}\n\n// Deprecated: 使用新的 ShouldSuspendKey 代替\nfunc (m *MetricsManager) ShouldSuspend(channelIndex int) bool {\n\treturn false\n}\n\n// ShouldSuspendKey 判断单个 Key 是否应该熔断\nfunc (m *MetricsManager) ShouldSuspendKey(baseURL, apiKey string) bool {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\tmetrics, exists := m.keyMetrics[metricsKey]\n\tif !exists {\n\t\treturn false\n\t}\n\n\t// 最小请求数保护：至少 max(3, windowSize/2) 次请求才判断\n\tminRequests := max(3, m.windowSize/2)\n\tif len(metrics.recentResults) < minRequests {\n\t\treturn false\n\t}\n\n\treturn m.calculateKeyFailureRateInternal(metrics) >= m.failureThreshold\n}\n\n// ============ 历史数据查询方法（用于图表可视化）============\n\n// HistoryDataPoint 历史数据点（用于时间序列图表）\ntype HistoryDataPoint struct {\n\tTimestamp    time.Time `json:\"timestamp\"`\n\tRequestCount int64     `json:\"requestCount\"`\n\tSuccessCount int64     `json:\"successCount\"`\n\tFailureCount int64     `json:\"failureCount\"`\n\tSuccessRate  float64   `json:\"successRate\"`\n}\n\n// KeyHistoryDataPoint Key 级别历史数据点（包含 Token 和 Cache 数据）\ntype KeyHistoryDataPoint struct {\n\tTimestamp                time.Time `json:\"timestamp\"`\n\tRequestCount             int64     `json:\"requestCount\"`\n\tSuccessCount             int64     `json:\"successCount\"`\n\tFailureCount             int64     `json:\"failureCount\"`\n\tSuccessRate              float64   `json:\"successRate\"`\n\tInputTokens              int64     `json:\"inputTokens\"`\n\tOutputTokens             int64     `json:\"outputTokens\"`\n\tCacheCreationInputTokens int64     `json:\"cacheCreationTokens\"`\n\tCacheReadInputTokens     int64     `json:\"cacheReadTokens\"`\n}\n\n// GetHistoricalStats 获取历史统计数据（按时间间隔聚合）\n// duration: 查询时间范围 (如 1h, 6h, 24h)\n// interval: 聚合间隔 (如 5m, 15m, 1h)\nfunc (m *MetricsManager) GetHistoricalStats(baseURL string, activeKeys []string, duration, interval time.Duration) []HistoryDataPoint {\n\t// 参数验证\n\tif interval <= 0 || duration <= 0 {\n\t\treturn []HistoryDataPoint{}\n\t}\n\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tnow := time.Now()\n\t// 时间对齐到 interval 边界\n\tstartTime := now.Add(-duration).Truncate(interval)\n\t// endTime 延伸一个 interval，确保当前时间段的请求也被包含\n\tendTime := now.Truncate(interval).Add(interval)\n\n\t// 计算需要多少个数据点（+1 用于包含延伸的当前时间段）\n\tnumPoints := int(duration / interval)\n\tif numPoints <= 0 {\n\t\tnumPoints = 1\n\t}\n\tnumPoints++ // 额外的一个桶用于当前时间段\n\n\t// 使用 map 按时间分桶，优化性能：O(records) 而不是 O(records * numPoints)\n\tbuckets := make(map[int64]*bucketData)\n\tfor i := 0; i < numPoints; i++ {\n\t\tbuckets[int64(i)] = &bucketData{}\n\t}\n\n\t// 收集所有相关 Key 的请求历史并放入对应桶\n\tfor _, apiKey := range activeKeys {\n\t\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\t\tif metrics, exists := m.keyMetrics[metricsKey]; exists {\n\t\t\tfor _, record := range metrics.requestHistory {\n\t\t\t\t// 使用 [startTime, endTime) 的区间，避免 endTime 处 offset 越界\n\t\t\t\tif !record.Timestamp.Before(startTime) && record.Timestamp.Before(endTime) {\n\t\t\t\t\t// 计算记录应该属于哪个桶\n\t\t\t\t\toffset := int64(record.Timestamp.Sub(startTime) / interval)\n\t\t\t\t\tif offset >= 0 && offset < int64(numPoints) {\n\t\t\t\t\t\tb := buckets[offset]\n\t\t\t\t\t\tb.requestCount++\n\t\t\t\t\t\tif record.Success {\n\t\t\t\t\t\t\tb.successCount++\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tb.failureCount++\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// 构建结果\n\tresult := make([]HistoryDataPoint, numPoints)\n\tfor i := 0; i < numPoints; i++ {\n\t\tb := buckets[int64(i)]\n\t\t// 空桶成功率默认为 0，避免误导（100% 暗示完美成功）\n\t\tsuccessRate := float64(0)\n\t\tif b.requestCount > 0 {\n\t\t\tsuccessRate = float64(b.successCount) / float64(b.requestCount) * 100\n\t\t}\n\t\tresult[i] = HistoryDataPoint{\n\t\t\tTimestamp:    startTime.Add(time.Duration(i) * interval),\n\t\t\tRequestCount: b.requestCount,\n\t\t\tSuccessCount: b.successCount,\n\t\t\tFailureCount: b.failureCount,\n\t\t\tSuccessRate:  successRate,\n\t\t}\n\t}\n\n\treturn result\n}\n\n// GetHistoricalStatsMultiURL 获取多 URL 聚合的历史统计数据\nfunc (m *MetricsManager) GetHistoricalStatsMultiURL(baseURLs []string, activeKeys []string, duration, interval time.Duration) []HistoryDataPoint {\n\t// 参数验证\n\tif interval <= 0 || duration <= 0 || len(baseURLs) == 0 {\n\t\treturn []HistoryDataPoint{}\n\t}\n\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tnow := time.Now()\n\t// 时间对齐到 interval 边界\n\tstartTime := now.Add(-duration).Truncate(interval)\n\t// endTime 延伸一个 interval，确保当前时间段的请求也被包含\n\tendTime := now.Truncate(interval).Add(interval)\n\n\t// 计算需要多少个数据点（+1 用于包含延伸的当前时间段）\n\tnumPoints := int(duration / interval)\n\tif numPoints <= 0 {\n\t\tnumPoints = 1\n\t}\n\tnumPoints++ // 额外的一个桶用于当前时间段\n\n\t// 使用 map 按时间分桶，优化性能：O(records) 而不是 O(records * numPoints)\n\tbuckets := make(map[int64]*bucketData)\n\tfor i := 0; i < numPoints; i++ {\n\t\tbuckets[int64(i)] = &bucketData{}\n\t}\n\n\t// 收集所有 BaseURL 和 Key 组合的请求历史并放入对应桶\n\tfor _, baseURL := range baseURLs {\n\t\tfor _, apiKey := range activeKeys {\n\t\t\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\t\t\tif metrics, exists := m.keyMetrics[metricsKey]; exists {\n\t\t\t\tfor _, record := range metrics.requestHistory {\n\t\t\t\t\t// 使用 [startTime, endTime) 的区间，避免 endTime 处 offset 越界\n\t\t\t\t\tif !record.Timestamp.Before(startTime) && record.Timestamp.Before(endTime) {\n\t\t\t\t\t\t// 计算记录应该属于哪个桶\n\t\t\t\t\t\toffset := int64(record.Timestamp.Sub(startTime) / interval)\n\t\t\t\t\t\tif offset >= 0 && offset < int64(numPoints) {\n\t\t\t\t\t\t\tb := buckets[offset]\n\t\t\t\t\t\t\tb.requestCount++\n\t\t\t\t\t\t\tif record.Success {\n\t\t\t\t\t\t\t\tb.successCount++\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tb.failureCount++\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// 构建结果\n\tresult := make([]HistoryDataPoint, numPoints)\n\tfor i := 0; i < numPoints; i++ {\n\t\tb := buckets[int64(i)]\n\t\t// 空桶成功率默认为 0，避免误导（100% 暗示完美成功）\n\t\tsuccessRate := float64(0)\n\t\tif b.requestCount > 0 {\n\t\t\tsuccessRate = float64(b.successCount) / float64(b.requestCount) * 100\n\t\t}\n\t\tresult[i] = HistoryDataPoint{\n\t\t\tTimestamp:    startTime.Add(time.Duration(i) * interval),\n\t\t\tRequestCount: b.requestCount,\n\t\t\tSuccessCount: b.successCount,\n\t\t\tFailureCount: b.failureCount,\n\t\t\tSuccessRate:  successRate,\n\t\t}\n\t}\n\n\treturn result\n}\n\n// bucketData 用于时间分桶的辅助结构\ntype bucketData struct {\n\trequestCount int64\n\tsuccessCount int64\n\tfailureCount int64\n}\n\nfunc (m *MetricsManager) GetAllKeysHistoricalStats(duration, interval time.Duration) []HistoryDataPoint {\n\t// 参数验证\n\tif interval <= 0 || duration <= 0 {\n\t\treturn []HistoryDataPoint{}\n\t}\n\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tnow := time.Now()\n\t// 时间对齐到 interval 边界\n\tstartTime := now.Add(-duration).Truncate(interval)\n\t// endTime 延伸一个 interval，确保当前时间段的请求也被包含\n\tendTime := now.Truncate(interval).Add(interval)\n\n\tnumPoints := int(duration / interval)\n\tif numPoints <= 0 {\n\t\tnumPoints = 1\n\t}\n\tnumPoints++ // 额外的一个桶用于当前时间段\n\n\t// 使用 map 按时间分桶，优化性能\n\tbuckets := make(map[int64]*bucketData)\n\tfor i := 0; i < numPoints; i++ {\n\t\tbuckets[int64(i)] = &bucketData{}\n\t}\n\n\t// 收集所有 Key 的请求历史并放入对应桶\n\tfor _, metrics := range m.keyMetrics {\n\t\tfor _, record := range metrics.requestHistory {\n\t\t\t// 使用 [startTime, endTime) 的区间，避免 endTime 处 offset 越界\n\t\t\tif !record.Timestamp.Before(startTime) && record.Timestamp.Before(endTime) {\n\t\t\t\toffset := int64(record.Timestamp.Sub(startTime) / interval)\n\t\t\t\tif offset >= 0 && offset < int64(numPoints) {\n\t\t\t\t\tb := buckets[offset]\n\t\t\t\t\tb.requestCount++\n\t\t\t\t\tif record.Success {\n\t\t\t\t\t\tb.successCount++\n\t\t\t\t\t} else {\n\t\t\t\t\t\tb.failureCount++\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// 构建结果\n\tresult := make([]HistoryDataPoint, numPoints)\n\tfor i := 0; i < numPoints; i++ {\n\t\tb := buckets[int64(i)]\n\t\t// 空桶成功率默认为 0，避免误导（100% 暗示完美成功）\n\t\tsuccessRate := float64(0)\n\t\tif b.requestCount > 0 {\n\t\t\tsuccessRate = float64(b.successCount) / float64(b.requestCount) * 100\n\t\t}\n\t\tresult[i] = HistoryDataPoint{\n\t\t\tTimestamp:    startTime.Add(time.Duration(i) * interval),\n\t\t\tRequestCount: b.requestCount,\n\t\t\tSuccessCount: b.successCount,\n\t\t\tFailureCount: b.failureCount,\n\t\t\tSuccessRate:  successRate,\n\t\t}\n\t}\n\n\treturn result\n}\n\n// GetKeyHistoricalStats 获取单个 Key 的历史统计数据（包含 Token 和 Cache 数据）\nfunc (m *MetricsManager) GetKeyHistoricalStats(baseURL, apiKey string, duration, interval time.Duration) []KeyHistoryDataPoint {\n\t// 参数验证\n\tif interval <= 0 || duration <= 0 {\n\t\treturn []KeyHistoryDataPoint{}\n\t}\n\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tnow := time.Now()\n\t// 时间对齐到 interval 边界\n\tstartTime := now.Add(-duration).Truncate(interval)\n\t// endTime 延伸一个 interval，确保当前时间段的请求也被包含\n\tendTime := now.Truncate(interval).Add(interval)\n\n\tnumPoints := int(duration / interval)\n\tif numPoints <= 0 {\n\t\tnumPoints = 1\n\t}\n\tnumPoints++ // 额外的一个桶用于当前时间段\n\n\t// 使用 map 按时间分桶\n\tbuckets := make(map[int64]*keyBucketData)\n\tfor i := 0; i < numPoints; i++ {\n\t\tbuckets[int64(i)] = &keyBucketData{}\n\t}\n\n\t// 获取 Key 的指标\n\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\tmetrics, exists := m.keyMetrics[metricsKey]\n\tif !exists {\n\t\t// Key 不存在，返回空数据点\n\t\tresult := make([]KeyHistoryDataPoint, numPoints)\n\t\tfor i := 0; i < numPoints; i++ {\n\t\t\tresult[i] = KeyHistoryDataPoint{\n\t\t\t\tTimestamp: startTime.Add(time.Duration(i+1) * interval),\n\t\t\t}\n\t\t}\n\t\treturn result\n\t}\n\n\t// 收集该 Key 的请求历史并放入对应桶\n\tfor _, record := range metrics.requestHistory {\n\t\t// 使用 Before(endTime) 排除恰好落在 endTime 的记录，避免 offset 越界\n\t\tif record.Timestamp.After(startTime) && record.Timestamp.Before(endTime) {\n\t\t\toffset := int64(record.Timestamp.Sub(startTime) / interval)\n\t\t\tif offset >= 0 && offset < int64(numPoints) {\n\t\t\t\tb := buckets[offset]\n\t\t\t\tb.requestCount++\n\t\t\t\tif record.Success {\n\t\t\t\t\tb.successCount++\n\t\t\t\t} else {\n\t\t\t\t\tb.failureCount++\n\t\t\t\t}\n\t\t\t\t// 累加 Token 数据\n\t\t\t\tb.inputTokens += record.InputTokens\n\t\t\t\tb.outputTokens += record.OutputTokens\n\t\t\t\tb.cacheCreationTokens += record.CacheCreationInputTokens\n\t\t\t\tb.cacheReadTokens += record.CacheReadInputTokens\n\t\t\t}\n\t\t}\n\t}\n\n\t// 构建结果\n\tresult := make([]KeyHistoryDataPoint, numPoints)\n\tfor i := 0; i < numPoints; i++ {\n\t\tb := buckets[int64(i)]\n\t\t// 空桶成功率默认为 0，避免误导（100% 暗示完美成功）\n\t\tsuccessRate := float64(0)\n\t\tif b.requestCount > 0 {\n\t\t\tsuccessRate = float64(b.successCount) / float64(b.requestCount) * 100\n\t\t}\n\t\tresult[i] = KeyHistoryDataPoint{\n\t\t\tTimestamp:                startTime.Add(time.Duration(i+1) * interval),\n\t\t\tRequestCount:             b.requestCount,\n\t\t\tSuccessCount:             b.successCount,\n\t\t\tFailureCount:             b.failureCount,\n\t\t\tSuccessRate:              successRate,\n\t\t\tInputTokens:              b.inputTokens,\n\t\t\tOutputTokens:             b.outputTokens,\n\t\t\tCacheCreationInputTokens: b.cacheCreationTokens,\n\t\t\tCacheReadInputTokens:     b.cacheReadTokens,\n\t\t}\n\t}\n\n\treturn result\n}\n\n// GetKeyHistoricalStatsMultiURL 获取单个 Key 的多 URL 聚合历史统计\nfunc (m *MetricsManager) GetKeyHistoricalStatsMultiURL(baseURLs []string, apiKey string, duration, interval time.Duration) []KeyHistoryDataPoint {\n\t// 参数验证\n\tif interval <= 0 || duration <= 0 || len(baseURLs) == 0 {\n\t\treturn []KeyHistoryDataPoint{}\n\t}\n\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tnow := time.Now()\n\t// 时间对齐到 interval 边界\n\tstartTime := now.Add(-duration).Truncate(interval)\n\t// endTime 延伸一个 interval，确保当前时间段的请求也被包含\n\tendTime := now.Truncate(interval).Add(interval)\n\n\tnumPoints := int(duration / interval)\n\tif numPoints <= 0 {\n\t\tnumPoints = 1\n\t}\n\tnumPoints++ // 额外的一个桶用于当前时间段\n\n\t// 使用 map 按时间分桶\n\tbuckets := make(map[int64]*keyBucketData)\n\tfor i := 0; i < numPoints; i++ {\n\t\tbuckets[int64(i)] = &keyBucketData{}\n\t}\n\n\t// 遍历所有 BaseURL 聚合同一 Key 的历史数据\n\thasData := false\n\tfor _, baseURL := range baseURLs {\n\t\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\t\tmetrics, exists := m.keyMetrics[metricsKey]\n\t\tif !exists {\n\t\t\tcontinue\n\t\t}\n\t\thasData = true\n\n\t\t// 收集该 URL+Key 组合的请求历史并放入对应桶\n\t\tfor _, record := range metrics.requestHistory {\n\t\t\t// 使用 Before(endTime) 排除恰好落在 endTime 的记录，避免 offset 越界\n\t\t\tif record.Timestamp.After(startTime) && record.Timestamp.Before(endTime) {\n\t\t\t\toffset := int64(record.Timestamp.Sub(startTime) / interval)\n\t\t\t\tif offset >= 0 && offset < int64(numPoints) {\n\t\t\t\t\tb := buckets[offset]\n\t\t\t\t\tb.requestCount++\n\t\t\t\t\tif record.Success {\n\t\t\t\t\t\tb.successCount++\n\t\t\t\t\t} else {\n\t\t\t\t\t\tb.failureCount++\n\t\t\t\t\t}\n\t\t\t\t\t// 累加 Token 数据\n\t\t\t\t\tb.inputTokens += record.InputTokens\n\t\t\t\t\tb.outputTokens += record.OutputTokens\n\t\t\t\t\tb.cacheCreationTokens += record.CacheCreationInputTokens\n\t\t\t\t\tb.cacheReadTokens += record.CacheReadInputTokens\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// 如果没有任何数据，返回空数据点\n\tif !hasData {\n\t\tresult := make([]KeyHistoryDataPoint, numPoints)\n\t\tfor i := 0; i < numPoints; i++ {\n\t\t\tresult[i] = KeyHistoryDataPoint{\n\t\t\t\tTimestamp: startTime.Add(time.Duration(i+1) * interval),\n\t\t\t}\n\t\t}\n\t\treturn result\n\t}\n\n\t// 构建结果\n\tresult := make([]KeyHistoryDataPoint, numPoints)\n\tfor i := 0; i < numPoints; i++ {\n\t\tb := buckets[int64(i)]\n\t\t// 空桶成功率默认为 0，避免误导（100% 暗示完美成功）\n\t\tsuccessRate := float64(0)\n\t\tif b.requestCount > 0 {\n\t\t\tsuccessRate = float64(b.successCount) / float64(b.requestCount) * 100\n\t\t}\n\t\tresult[i] = KeyHistoryDataPoint{\n\t\t\tTimestamp:                startTime.Add(time.Duration(i+1) * interval),\n\t\t\tRequestCount:             b.requestCount,\n\t\t\tSuccessCount:             b.successCount,\n\t\t\tFailureCount:             b.failureCount,\n\t\t\tSuccessRate:              successRate,\n\t\t\tInputTokens:              b.inputTokens,\n\t\t\tOutputTokens:             b.outputTokens,\n\t\t\tCacheCreationInputTokens: b.cacheCreationTokens,\n\t\t\tCacheReadInputTokens:     b.cacheReadTokens,\n\t\t}\n\t}\n\n\treturn result\n}\n\n// keyBucketData Key 级别时间分桶的辅助结构（包含 Token 数据）\ntype keyBucketData struct {\n\trequestCount        int64\n\tsuccessCount        int64\n\tfailureCount        int64\n\tinputTokens         int64\n\toutputTokens        int64\n\tcacheCreationTokens int64\n\tcacheReadTokens     int64\n}\n\n// ============ 全局统计数据结构和方法（用于全局流量统计图表）============\n\n// GlobalHistoryDataPoint 全局历史数据点（含 Token 数据）\ntype GlobalHistoryDataPoint struct {\n\tTimestamp           time.Time `json:\"timestamp\"`\n\tRequestCount        int64     `json:\"requestCount\"`\n\tSuccessCount        int64     `json:\"successCount\"`\n\tFailureCount        int64     `json:\"failureCount\"`\n\tSuccessRate         float64   `json:\"successRate\"`\n\tInputTokens         int64     `json:\"inputTokens\"`\n\tOutputTokens        int64     `json:\"outputTokens\"`\n\tCacheCreationTokens int64     `json:\"cacheCreationTokens\"`\n\tCacheReadTokens     int64     `json:\"cacheReadTokens\"`\n}\n\n// GlobalStatsSummary 全局统计汇总\ntype GlobalStatsSummary struct {\n\tTotalRequests            int64   `json:\"totalRequests\"`\n\tTotalSuccess             int64   `json:\"totalSuccess\"`\n\tTotalFailure             int64   `json:\"totalFailure\"`\n\tTotalInputTokens         int64   `json:\"totalInputTokens\"`\n\tTotalOutputTokens        int64   `json:\"totalOutputTokens\"`\n\tTotalCacheCreationTokens int64   `json:\"totalCacheCreationTokens\"`\n\tTotalCacheReadTokens     int64   `json:\"totalCacheReadTokens\"`\n\tAvgSuccessRate           float64 `json:\"avgSuccessRate\"`\n\tDuration                 string  `json:\"duration\"`\n}\n\n// GlobalStatsHistoryResponse 全局统计响应\ntype GlobalStatsHistoryResponse struct {\n\tDataPoints []GlobalHistoryDataPoint `json:\"dataPoints\"`\n\tSummary    GlobalStatsSummary       `json:\"summary\"`\n}\n\n// GetGlobalHistoricalStatsWithTokens 获取全局历史统计（包含 Token 数据）\n// 聚合所有 Key 的数据，按时间间隔分桶\nfunc (m *MetricsManager) GetGlobalHistoricalStatsWithTokens(duration, interval time.Duration) GlobalStatsHistoryResponse {\n\t// 参数验证\n\tif interval <= 0 || duration <= 0 {\n\t\treturn GlobalStatsHistoryResponse{\n\t\t\tDataPoints: []GlobalHistoryDataPoint{},\n\t\t\tSummary:    GlobalStatsSummary{Duration: duration.String()},\n\t\t}\n\t}\n\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tnow := time.Now()\n\t// 时间对齐到 interval 边界\n\tstartTime := now.Add(-duration).Truncate(interval)\n\t// endTime 延伸一个 interval，确保当前时间段的请求也被包含\n\tendTime := now.Truncate(interval).Add(interval)\n\n\tnumPoints := int(duration / interval)\n\tif numPoints <= 0 {\n\t\tnumPoints = 1\n\t}\n\tnumPoints++ // 额外的一个桶用于当前时间段\n\n\t// 使用 map 按时间分桶\n\tbuckets := make(map[int64]*globalBucketData)\n\tfor i := 0; i < numPoints; i++ {\n\t\tbuckets[int64(i)] = &globalBucketData{}\n\t}\n\n\t// 汇总统计\n\tvar totalRequests, totalSuccess, totalFailure int64\n\tvar totalInputTokens, totalOutputTokens, totalCacheCreation, totalCacheRead int64\n\n\t// 遍历所有 Key 的请求历史\n\tfor _, metrics := range m.keyMetrics {\n\t\tfor _, record := range metrics.requestHistory {\n\t\t\t// 使用 Before(endTime) 排除恰好落在 endTime 的记录，避免 offset 越界\n\t\t\tif record.Timestamp.After(startTime) && record.Timestamp.Before(endTime) {\n\t\t\t\toffset := int64(record.Timestamp.Sub(startTime) / interval)\n\t\t\t\tif offset >= 0 && offset < int64(numPoints) {\n\t\t\t\t\tb := buckets[offset]\n\t\t\t\t\tb.requestCount++\n\t\t\t\t\tif record.Success {\n\t\t\t\t\t\tb.successCount++\n\t\t\t\t\t} else {\n\t\t\t\t\t\tb.failureCount++\n\t\t\t\t\t}\n\t\t\t\t\tb.inputTokens += record.InputTokens\n\t\t\t\t\tb.outputTokens += record.OutputTokens\n\t\t\t\t\tb.cacheCreationTokens += record.CacheCreationInputTokens\n\t\t\t\t\tb.cacheReadTokens += record.CacheReadInputTokens\n\n\t\t\t\t\t// 累加汇总\n\t\t\t\t\ttotalRequests++\n\t\t\t\t\tif record.Success {\n\t\t\t\t\t\ttotalSuccess++\n\t\t\t\t\t} else {\n\t\t\t\t\t\ttotalFailure++\n\t\t\t\t\t}\n\t\t\t\t\ttotalInputTokens += record.InputTokens\n\t\t\t\t\ttotalOutputTokens += record.OutputTokens\n\t\t\t\t\ttotalCacheCreation += record.CacheCreationInputTokens\n\t\t\t\t\ttotalCacheRead += record.CacheReadInputTokens\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// 构建数据点结果\n\tdataPoints := make([]GlobalHistoryDataPoint, numPoints)\n\tfor i := 0; i < numPoints; i++ {\n\t\tb := buckets[int64(i)]\n\t\tsuccessRate := float64(0)\n\t\tif b.requestCount > 0 {\n\t\t\tsuccessRate = float64(b.successCount) / float64(b.requestCount) * 100\n\t\t}\n\t\tdataPoints[i] = GlobalHistoryDataPoint{\n\t\t\tTimestamp:           startTime.Add(time.Duration(i+1) * interval),\n\t\t\tRequestCount:        b.requestCount,\n\t\t\tSuccessCount:        b.successCount,\n\t\t\tFailureCount:        b.failureCount,\n\t\t\tSuccessRate:         successRate,\n\t\t\tInputTokens:         b.inputTokens,\n\t\t\tOutputTokens:        b.outputTokens,\n\t\t\tCacheCreationTokens: b.cacheCreationTokens,\n\t\t\tCacheReadTokens:     b.cacheReadTokens,\n\t\t}\n\t}\n\n\t// 计算平均成功率\n\tavgSuccessRate := float64(0)\n\tif totalRequests > 0 {\n\t\tavgSuccessRate = float64(totalSuccess) / float64(totalRequests) * 100\n\t}\n\n\tsummary := GlobalStatsSummary{\n\t\tTotalRequests:            totalRequests,\n\t\tTotalSuccess:             totalSuccess,\n\t\tTotalFailure:             totalFailure,\n\t\tTotalInputTokens:         totalInputTokens,\n\t\tTotalOutputTokens:        totalOutputTokens,\n\t\tTotalCacheCreationTokens: totalCacheCreation,\n\t\tTotalCacheReadTokens:     totalCacheRead,\n\t\tAvgSuccessRate:           avgSuccessRate,\n\t\tDuration:                 duration.String(),\n\t}\n\n\treturn GlobalStatsHistoryResponse{\n\t\tDataPoints: dataPoints,\n\t\tSummary:    summary,\n\t}\n}\n\n// globalBucketData 全局统计时间分桶的辅助结构\ntype globalBucketData struct {\n\trequestCount        int64\n\tsuccessCount        int64\n\tfailureCount        int64\n\tinputTokens         int64\n\toutputTokens        int64\n\tcacheCreationTokens int64\n\tcacheReadTokens     int64\n}\n\n// CalculateTodayDuration 计算\"今日\"时间范围（从今天 0 点到现在）\nfunc CalculateTodayDuration() time.Duration {\n\tnow := time.Now()\n\tstartOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())\n\treturn now.Sub(startOfDay)\n}\n\n// ============ 渠道实时活跃度数据（用于渐变背景显示）============\n\n// ActivitySegment 活跃度分段数据（每 6 秒一段）\ntype ActivitySegment struct {\n\tRequestCount int64 `json:\"requestCount\"`\n\tSuccessCount int64 `json:\"successCount\"`\n\tFailureCount int64 `json:\"failureCount\"`\n\tInputTokens  int64 `json:\"inputTokens\"`\n\tOutputTokens int64 `json:\"outputTokens\"`\n}\n\n// ChannelRecentActivity 渠道最近活跃度数据\ntype ChannelRecentActivity struct {\n\tChannelIndex int               `json:\"channelIndex\"`\n\tSegments     []ActivitySegment `json:\"segments\"` // 150 段，每段 6 秒，从旧到新（共 15 分钟）\n\tRPM          float64           `json:\"rpm\"`      // 15分钟平均 RPM\n\tTPM          float64           `json:\"tpm\"`      // 15分钟平均 TPM\n}\n\n// GetRecentActivityMultiURL 获取渠道最近活跃度数据（支持多 URL 和多 Key 聚合）\n// 参数：\n//   - channelIndex: 渠道索引\n//   - baseURLs: 渠道的所有故障转移 URL（支持多个）\n//   - activeKeys: 渠道的所有活跃 API Key（支持多个）\n//\n// 返回：\n//   - 150 段活跃度数据（每段 6 秒，共 15 分钟）\n//   - 自动聚合所有 URL × Key 组合的请求数据\n//   - RPM/TPM 为 15 分钟平均值\nfunc (m *MetricsManager) GetRecentActivityMultiURL(channelIndex int, baseURLs []string, activeKeys []string) *ChannelRecentActivity {\n\t// 150 段，每段 6 秒 = 900 秒 = 15 分钟\n\tconst numSegments = 150\n\tconst segmentDuration = 6 * time.Second\n\n\tif len(baseURLs) == 0 || len(activeKeys) == 0 {\n\t\treturn &ChannelRecentActivity{\n\t\t\tChannelIndex: channelIndex,\n\t\t\tSegments:     make([]ActivitySegment, numSegments),\n\t\t\tRPM:          0,\n\t\t\tTPM:          0,\n\t\t}\n\t}\n\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tnow := time.Now()\n\n\t// 时间边界对齐：将 endTime 向上对齐到下一个 segmentDuration 边界\n\t// 这样每次请求的分段边界都是固定的，不会因为 now 的微小变化而导致数据跳动\n\t// 例如：segmentDuration=6s，now=12:34:57，则 endTime=12:35:00（包含当前正在进行的段）\n\tendTimeUnix := now.Unix()\n\tsegmentSeconds := int64(segmentDuration.Seconds())\n\talignedEndUnix := ((endTimeUnix / segmentSeconds) + 1) * segmentSeconds\n\tendTime := time.Unix(alignedEndUnix, 0)\n\tstartTime := endTime.Add(-time.Duration(numSegments) * segmentDuration)\n\n\t// 初始化分段数据\n\tsegments := make([]ActivitySegment, numSegments)\n\n\t// 汇总统计\n\tvar totalRequests, totalInputTokens, totalOutputTokens int64\n\n\t// 遍历所有 BaseURL 和 Key 的组合\n\tfor _, baseURL := range baseURLs {\n\t\tfor _, apiKey := range activeKeys {\n\t\t\tmetricsKey := generateMetricsKey(baseURL, apiKey)\n\t\t\tmetrics, exists := m.keyMetrics[metricsKey]\n\t\t\tif !exists {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// 遍历该 Key 的请求历史，放入对应分段\n\t\t\tfor _, record := range metrics.requestHistory {\n\t\t\t\t// 检查是否在 [startTime, endTime) 范围内\n\t\t\t\tif record.Timestamp.Before(startTime) || !record.Timestamp.Before(endTime) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// 计算属于哪个分段\n\t\t\t\toffset := int(record.Timestamp.Sub(startTime) / segmentDuration)\n\t\t\t\tif offset < 0 || offset >= numSegments {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tseg := &segments[offset]\n\t\t\t\tseg.RequestCount++\n\t\t\t\tif record.Success {\n\t\t\t\t\tseg.SuccessCount++\n\t\t\t\t} else {\n\t\t\t\t\tseg.FailureCount++\n\t\t\t\t}\n\t\t\t\tseg.InputTokens += record.InputTokens\n\t\t\t\tseg.OutputTokens += record.OutputTokens\n\n\t\t\t\t// 累加汇总\n\t\t\t\ttotalRequests++\n\t\t\t\ttotalInputTokens += record.InputTokens\n\t\t\t\ttotalOutputTokens += record.OutputTokens\n\t\t\t}\n\t\t}\n\t}\n\n\t// 计算 RPM 和 TPM（基于实际窗口时长）\n\t// TPM 只计算输出 tokens（包含思考），不包含输入 tokens 和缓存 tokens\n\twindowMinutes := float64(numSegments) * segmentDuration.Minutes()\n\trpm := float64(totalRequests) / windowMinutes\n\ttpm := float64(totalOutputTokens) / windowMinutes\n\n\treturn &ChannelRecentActivity{\n\t\tChannelIndex: channelIndex,\n\t\tSegments:     segments,\n\t\tRPM:          rpm,\n\t\tTPM:          tpm,\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/metrics/channel_metrics_activity_test.go",
    "content": "package metrics\n\nimport (\n\t\"math\"\n\t\"testing\"\n\t\"time\"\n)\n\n// floatEquals 使用容差比较浮点数\nfunc floatEquals(a, b, epsilon float64) bool {\n\treturn math.Abs(a-b) < epsilon\n}\n\nfunc TestGetRecentActivityMultiURL_EmptyInputs(t *testing.T) {\n\tm := NewMetricsManager()\n\tdefer m.Stop()\n\n\t// 空 baseURLs\n\tresult := m.GetRecentActivityMultiURL(0, []string{}, []string{\"key1\"})\n\tif result.ChannelIndex != 0 {\n\t\tt.Errorf(\"expected channelIndex 0, got %d\", result.ChannelIndex)\n\t}\n\tif len(result.Segments) != 150 {\n\t\tt.Errorf(\"expected 150 segments, got %d\", len(result.Segments))\n\t}\n\tif result.RPM != 0 || result.TPM != 0 {\n\t\tt.Errorf(\"expected RPM=0, TPM=0 for empty input\")\n\t}\n\n\t// 空 activeKeys\n\tresult = m.GetRecentActivityMultiURL(0, []string{\"http://example.com\"}, []string{})\n\tif len(result.Segments) != 150 {\n\t\tt.Errorf(\"expected 150 segments, got %d\", len(result.Segments))\n\t}\n}\n\nfunc TestGetRecentActivityMultiURL_SegmentBoundaries(t *testing.T) {\n\tm := NewMetricsManager()\n\tdefer m.Stop()\n\n\tbaseURL := \"http://test.com\"\n\tapiKey := \"test-key\"\n\n\t// 模拟在不同时间点的请求\n\tnow := time.Now()\n\tm.mu.Lock()\n\tmetrics := m.getOrCreateKey(baseURL, apiKey)\n\n\t// 添加当前 6 秒段的请求（应该在最后一个 segment）\n\tmetrics.requestHistory = append(metrics.requestHistory, RequestRecord{\n\t\tTimestamp:    now,\n\t\tSuccess:      true,\n\t\tInputTokens:  100,\n\t\tOutputTokens: 50,\n\t})\n\n\t// 添加 5 分钟前的请求（5*60/6 = 50 段前）\n\tmetrics.requestHistory = append(metrics.requestHistory, RequestRecord{\n\t\tTimestamp:    now.Add(-5 * time.Minute),\n\t\tSuccess:      true,\n\t\tInputTokens:  200,\n\t\tOutputTokens: 100,\n\t})\n\n\t// 添加 14 分钟前的请求（14*60/6 = 140 段前）\n\tmetrics.requestHistory = append(metrics.requestHistory, RequestRecord{\n\t\tTimestamp:    now.Add(-14 * time.Minute),\n\t\tSuccess:      false,\n\t\tInputTokens:  50,\n\t\tOutputTokens: 25,\n\t})\n\n\t// 添加 16 分钟前的请求（应该被排除，超出 15 分钟窗口）\n\tmetrics.requestHistory = append(metrics.requestHistory, RequestRecord{\n\t\tTimestamp:    now.Add(-16 * time.Minute),\n\t\tSuccess:      true,\n\t\tInputTokens:  1000,\n\t\tOutputTokens: 500,\n\t})\n\tm.mu.Unlock()\n\n\tresult := m.GetRecentActivityMultiURL(1, []string{baseURL}, []string{apiKey})\n\n\t// 验证 channelIndex\n\tif result.ChannelIndex != 1 {\n\t\tt.Errorf(\"expected channelIndex 1, got %d\", result.ChannelIndex)\n\t}\n\n\t// 验证 segment 数量（150 段，每段 6 秒）\n\tif len(result.Segments) != 150 {\n\t\tt.Errorf(\"expected 150 segments, got %d\", len(result.Segments))\n\t}\n\n\t// 验证总请求数（应该是 3，排除 16 分钟前的）\n\tvar totalRequests int64\n\tfor _, seg := range result.Segments {\n\t\ttotalRequests += seg.RequestCount\n\t}\n\tif totalRequests != 3 {\n\t\tt.Errorf(\"expected 3 total requests, got %d\", totalRequests)\n\t}\n\n\t// 验证 RPM 计算（15 分钟平均）\n\texpectedRPM := 3.0 / 15.0\n\tif !floatEquals(result.RPM, expectedRPM, 0.0001) {\n\t\tt.Errorf(\"expected RPM %.4f, got %.4f\", expectedRPM, result.RPM)\n\t}\n\n\t// 验证 TPM 只计算 OutputTokens（50 + 100 + 25 = 175）\n\texpectedTPM := 175.0 / 15.0\n\tif !floatEquals(result.TPM, expectedTPM, 0.0001) {\n\t\tt.Errorf(\"expected TPM %.4f, got %.4f\", expectedTPM, result.TPM)\n\t}\n}\n\nfunc TestGetRecentActivityMultiURL_FailureCount(t *testing.T) {\n\tm := NewMetricsManager()\n\tdefer m.Stop()\n\n\tbaseURL := \"http://test.com\"\n\tapiKey := \"test-key\"\n\n\tnow := time.Now()\n\tm.mu.Lock()\n\tmetrics := m.getOrCreateKey(baseURL, apiKey)\n\n\t// 添加 2 个成功和 1 个失败\n\tmetrics.requestHistory = append(metrics.requestHistory,\n\t\tRequestRecord{Timestamp: now, Success: true},\n\t\tRequestRecord{Timestamp: now, Success: true},\n\t\tRequestRecord{Timestamp: now, Success: false},\n\t)\n\tm.mu.Unlock()\n\n\tresult := m.GetRecentActivityMultiURL(0, []string{baseURL}, []string{apiKey})\n\n\t// 找到有数据的 segment\n\tvar foundSeg *ActivitySegment\n\tfor i := range result.Segments {\n\t\tif result.Segments[i].RequestCount > 0 {\n\t\t\tfoundSeg = &result.Segments[i]\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif foundSeg == nil {\n\t\tt.Fatal(\"expected to find a segment with data\")\n\t}\n\n\tif foundSeg.RequestCount != 3 {\n\t\tt.Errorf(\"expected 3 requests, got %d\", foundSeg.RequestCount)\n\t}\n\tif foundSeg.SuccessCount != 2 {\n\t\tt.Errorf(\"expected 2 successes, got %d\", foundSeg.SuccessCount)\n\t}\n\tif foundSeg.FailureCount != 1 {\n\t\tt.Errorf(\"expected 1 failure, got %d\", foundSeg.FailureCount)\n\t}\n}\n\nfunc TestGetRecentActivityMultiURL_MultipleURLs(t *testing.T) {\n\tm := NewMetricsManager()\n\tdefer m.Stop()\n\n\tbaseURL1 := \"http://test1.com\"\n\tbaseURL2 := \"http://test2.com\"\n\tapiKey := \"test-key\"\n\n\tnow := time.Now()\n\tm.mu.Lock()\n\tmetrics1 := m.getOrCreateKey(baseURL1, apiKey)\n\tmetrics1.requestHistory = append(metrics1.requestHistory, RequestRecord{\n\t\tTimestamp:    now,\n\t\tSuccess:      true,\n\t\tOutputTokens: 100,\n\t})\n\n\tmetrics2 := m.getOrCreateKey(baseURL2, apiKey)\n\tmetrics2.requestHistory = append(metrics2.requestHistory, RequestRecord{\n\t\tTimestamp:    now,\n\t\tSuccess:      true,\n\t\tOutputTokens: 200,\n\t})\n\tm.mu.Unlock()\n\n\tresult := m.GetRecentActivityMultiURL(0, []string{baseURL1, baseURL2}, []string{apiKey})\n\n\t// 验证聚合了两个 URL 的数据\n\tvar totalRequests int64\n\tfor _, seg := range result.Segments {\n\t\ttotalRequests += seg.RequestCount\n\t}\n\tif totalRequests != 2 {\n\t\tt.Errorf(\"expected 2 total requests from 2 URLs, got %d\", totalRequests)\n\t}\n\n\t// TPM 应该是 (100 + 200) / 15\n\texpectedTPM := 300.0 / 15.0\n\tif !floatEquals(result.TPM, expectedTPM, 0.0001) {\n\t\tt.Errorf(\"expected TPM %.4f, got %.4f\", expectedTPM, result.TPM)\n\t}\n}\n\nfunc TestGetRecentActivityMultiURL_MultipleKeys(t *testing.T) {\n\tm := NewMetricsManager()\n\tdefer m.Stop()\n\n\tbaseURL := \"http://test.com\"\n\tapiKey1 := \"test-key-1\"\n\tapiKey2 := \"test-key-2\"\n\n\tnow := time.Now()\n\tm.mu.Lock()\n\tmetrics1 := m.getOrCreateKey(baseURL, apiKey1)\n\tmetrics1.requestHistory = append(metrics1.requestHistory, RequestRecord{\n\t\tTimestamp:    now,\n\t\tSuccess:      true,\n\t\tOutputTokens: 150,\n\t})\n\n\tmetrics2 := m.getOrCreateKey(baseURL, apiKey2)\n\tmetrics2.requestHistory = append(metrics2.requestHistory, RequestRecord{\n\t\tTimestamp:    now,\n\t\tSuccess:      true,\n\t\tOutputTokens: 250,\n\t})\n\tm.mu.Unlock()\n\n\tresult := m.GetRecentActivityMultiURL(0, []string{baseURL}, []string{apiKey1, apiKey2})\n\n\t// 验证聚合了两个 Key 的数据\n\tvar totalRequests int64\n\tfor _, seg := range result.Segments {\n\t\ttotalRequests += seg.RequestCount\n\t}\n\tif totalRequests != 2 {\n\t\tt.Errorf(\"expected 2 total requests from 2 Keys, got %d\", totalRequests)\n\t}\n\n\t// TPM 应该是 (150 + 250) / 15\n\texpectedTPM := 400.0 / 15.0\n\tif !floatEquals(result.TPM, expectedTPM, 0.0001) {\n\t\tt.Errorf(\"expected TPM %.4f, got %.4f\", expectedTPM, result.TPM)\n\t}\n}\n\nfunc TestGetRecentActivityMultiURL_MultipleURLsAndKeys(t *testing.T) {\n\tm := NewMetricsManager()\n\tdefer m.Stop()\n\n\tbaseURL1 := \"http://test1.com\"\n\tbaseURL2 := \"http://test2.com\"\n\tapiKey1 := \"test-key-1\"\n\tapiKey2 := \"test-key-2\"\n\n\tnow := time.Now()\n\tm.mu.Lock()\n\t// URL1 + Key1\n\tmetrics11 := m.getOrCreateKey(baseURL1, apiKey1)\n\tmetrics11.requestHistory = append(metrics11.requestHistory, RequestRecord{\n\t\tTimestamp:    now,\n\t\tSuccess:      true,\n\t\tOutputTokens: 100,\n\t})\n\n\t// URL1 + Key2\n\tmetrics12 := m.getOrCreateKey(baseURL1, apiKey2)\n\tmetrics12.requestHistory = append(metrics12.requestHistory, RequestRecord{\n\t\tTimestamp:    now,\n\t\tSuccess:      true,\n\t\tOutputTokens: 200,\n\t})\n\n\t// URL2 + Key1\n\tmetrics21 := m.getOrCreateKey(baseURL2, apiKey1)\n\tmetrics21.requestHistory = append(metrics21.requestHistory, RequestRecord{\n\t\tTimestamp:    now,\n\t\tSuccess:      false,\n\t\tOutputTokens: 150,\n\t})\n\n\t// URL2 + Key2\n\tmetrics22 := m.getOrCreateKey(baseURL2, apiKey2)\n\tmetrics22.requestHistory = append(metrics22.requestHistory, RequestRecord{\n\t\tTimestamp:    now,\n\t\tSuccess:      true,\n\t\tOutputTokens: 250,\n\t})\n\tm.mu.Unlock()\n\n\tresult := m.GetRecentActivityMultiURL(0, []string{baseURL1, baseURL2}, []string{apiKey1, apiKey2})\n\n\t// 验证聚合了所有 URL × Key 组合的数据（2×2=4 个请求）\n\tvar totalRequests int64\n\tvar totalFailures int64\n\tfor _, seg := range result.Segments {\n\t\ttotalRequests += seg.RequestCount\n\t\ttotalFailures += seg.FailureCount\n\t}\n\tif totalRequests != 4 {\n\t\tt.Errorf(\"expected 4 total requests from 2 URLs × 2 Keys, got %d\", totalRequests)\n\t}\n\tif totalFailures != 1 {\n\t\tt.Errorf(\"expected 1 failure, got %d\", totalFailures)\n\t}\n\n\t// TPM 应该是 (100 + 200 + 150 + 250) / 15\n\texpectedTPM := 700.0 / 15.0\n\tif !floatEquals(result.TPM, expectedTPM, 0.0001) {\n\t\tt.Errorf(\"expected TPM %.4f, got %.4f\", expectedTPM, result.TPM)\n\t}\n\n\t// RPM 应该是 4 / 15\n\texpectedRPM := 4.0 / 15.0\n\tif !floatEquals(result.RPM, expectedRPM, 0.0001) {\n\t\tt.Errorf(\"expected RPM %.4f, got %.4f\", expectedRPM, result.RPM)\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/metrics/channel_metrics_cache_stats_test.go",
    "content": "package metrics\n\nimport (\n\t\"math\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n)\n\nfunc TestToResponse_TimeWindowsIncludesCacheStats(t *testing.T) {\n\tm := NewMetricsManagerWithConfig(10, 0.5)\n\n\tbaseURL := \"https://example.com\"\n\tkey1 := \"k1\"\n\tkey2 := \"k2\"\n\n\tm.RecordSuccessWithUsage(baseURL, key1, &types.Usage{\n\t\tInputTokens:              100,\n\t\tOutputTokens:             10,\n\t\tCacheCreationInputTokens: 20,\n\t\tCacheReadInputTokens:     50,\n\t})\n\tm.RecordSuccessWithUsage(baseURL, key2, &types.Usage{\n\t\tInputTokens:  200,\n\t\tOutputTokens: 20,\n\t})\n\n\tresp := m.ToResponse(0, baseURL, []string{key1, key2}, 0)\n\tstats, ok := resp.TimeWindows[\"15m\"]\n\tif !ok {\n\t\tt.Fatalf(\"expected timeWindows[15m] to exist\")\n\t}\n\n\tif stats.InputTokens != 300 {\n\t\tt.Fatalf(\"expected inputTokens=300, got %d\", stats.InputTokens)\n\t}\n\tif stats.OutputTokens != 30 {\n\t\tt.Fatalf(\"expected outputTokens=30, got %d\", stats.OutputTokens)\n\t}\n\tif stats.CacheCreationTokens != 20 {\n\t\tt.Fatalf(\"expected cacheCreationTokens=20, got %d\", stats.CacheCreationTokens)\n\t}\n\tif stats.CacheReadTokens != 50 {\n\t\tt.Fatalf(\"expected cacheReadTokens=50, got %d\", stats.CacheReadTokens)\n\t}\n\n\twantHitRate := float64(50) / float64(50+300) * 100\n\tif math.Abs(stats.CacheHitRate-wantHitRate) > 0.01 {\n\t\tt.Fatalf(\"expected cacheHitRate=%.4f, got %.4f\", wantHitRate, stats.CacheHitRate)\n\t}\n}\n\nfunc TestRecordSuccessWithUsage_CacheCreationFallbackFromTTLBreakdown(t *testing.T) {\n\tm := NewMetricsManagerWithConfig(10, 0.5)\n\n\tbaseURL := \"https://example.com\"\n\tkey := \"k1\"\n\n\t// 上游有时只返回 TTL 细分字段（5m/1h），不返回 cache_creation_input_tokens。\n\tm.RecordSuccessWithUsage(baseURL, key, &types.Usage{\n\t\tInputTokens:                100,\n\t\tOutputTokens:               10,\n\t\tCacheCreationInputTokens:   0,\n\t\tCacheCreation5mInputTokens: 20,\n\t\tCacheCreation1hInputTokens: 30,\n\t\tCacheReadInputTokens:       50,\n\t})\n\n\tresp := m.ToResponse(0, baseURL, []string{key}, 0)\n\tstats, ok := resp.TimeWindows[\"15m\"]\n\tif !ok {\n\t\tt.Fatalf(\"expected timeWindows[15m] to exist\")\n\t}\n\n\tif stats.CacheCreationTokens != 50 {\n\t\tt.Fatalf(\"expected cacheCreationTokens=50, got %d\", stats.CacheCreationTokens)\n\t}\n\tif stats.CacheReadTokens != 50 {\n\t\tt.Fatalf(\"expected cacheReadTokens=50, got %d\", stats.CacheReadTokens)\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/metrics/persistence.go",
    "content": "package metrics\n\nimport (\n\t\"time\"\n)\n\n// PersistenceStore 持久化存储接口\ntype PersistenceStore interface {\n\t// AddRecord 添加记录到写入缓冲区（非阻塞）\n\tAddRecord(record PersistentRecord)\n\n\t// LoadRecords 加载指定时间范围内的记录\n\tLoadRecords(since time.Time, apiType string) ([]PersistentRecord, error)\n\n\t// CleanupOldRecords 清理过期数据\n\tCleanupOldRecords(before time.Time) (int64, error)\n\n\t// DeleteRecordsByMetricsKeys 按 metrics_key 和 api_type 批量删除记录（用于删除渠道时清理数据）\n\t// apiType: 接口类型（messages/responses/gemini），避免误删其他接口的数据\n\tDeleteRecordsByMetricsKeys(metricsKeys []string, apiType string) (int64, error)\n\n\t// Close 关闭存储（会先刷新缓冲区）\n\tClose() error\n}\n\n// PersistentRecord 持久化记录结构\ntype PersistentRecord struct {\n\tMetricsKey          string    // hash(baseURL + apiKey)\n\tBaseURL             string    // 上游 BaseURL\n\tKeyMask             string    // 脱敏的 API Key\n\tTimestamp           time.Time // 请求时间\n\tSuccess             bool      // 是否成功\n\tInputTokens         int64     // 输入 Token 数\n\tOutputTokens        int64     // 输出 Token 数\n\tCacheCreationTokens int64     // 缓存创建 Token\n\tCacheReadTokens     int64     // 缓存读取 Token\n\tAPIType             string    // \"messages\"、\"responses\" 或 \"gemini\"\n}\n"
  },
  {
    "path": "backend-go/internal/metrics/sqlite_store.go",
    "content": "package metrics\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t_ \"modernc.org/sqlite\"\n)\n\n// SQLiteStore SQLite 持久化存储\ntype SQLiteStore struct {\n\tdb     *sql.DB\n\tdbPath string\n\n\t// 写入缓冲区\n\twriteBuffer []PersistentRecord\n\tbufferMu    sync.Mutex\n\n\t// 配置\n\tbatchSize     int           // 批量写入阈值（记录数）\n\tflushInterval time.Duration // 定时刷新间隔\n\tretentionDays int           // 数据保留天数\n\n\t// 控制\n\tstopCh       chan struct{}\n\twg           sync.WaitGroup\n\tclosed       bool           // 是否已关闭\n\tflushMu      sync.Mutex     // 串行化 flush 与 delete 操作，避免并发竞态\n\tasyncFlushWg sync.WaitGroup // 追踪 AddRecord 触发的异步 flush goroutine\n}\n\n// SQLiteStoreConfig SQLite 存储配置\ntype SQLiteStoreConfig struct {\n\tDBPath        string // 数据库文件路径\n\tRetentionDays int    // 数据保留天数（3-30）\n}\n\n// 硬编码的内部配置\nconst (\n\tdefaultBatchSize     = 100              // 批量写入阈值\n\tdefaultFlushInterval = 30 * time.Second // 定时刷新间隔\n)\n\n// NewSQLiteStore 创建 SQLite 存储\nfunc NewSQLiteStore(cfg *SQLiteStoreConfig) (*SQLiteStore, error) {\n\tif cfg == nil {\n\t\tcfg = &SQLiteStoreConfig{\n\t\t\tDBPath:        \".config/metrics.db\",\n\t\t\tRetentionDays: 7,\n\t\t}\n\t}\n\n\t// 验证保留天数范围\n\tif cfg.RetentionDays < 3 {\n\t\tcfg.RetentionDays = 3\n\t} else if cfg.RetentionDays > 30 {\n\t\tcfg.RetentionDays = 30\n\t}\n\n\t// 确保目录存在\n\tdir := filepath.Dir(cfg.DBPath)\n\tif err := os.MkdirAll(dir, 0755); err != nil {\n\t\treturn nil, fmt.Errorf(\"创建数据库目录失败: %w\", err)\n\t}\n\n\t// 打开数据库连接（WAL 模式 + NORMAL 同步）\n\t// modernc.org/sqlite 使用 _pragma= 语法设置 PRAGMA\n\tdsn := cfg.DBPath + \"?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=busy_timeout(5000)\"\n\tdb, err := sql.Open(\"sqlite\", dsn)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"打开数据库失败: %w\", err)\n\t}\n\n\t// 设置连接池参数\n\tdb.SetMaxOpenConns(1) // SQLite 单写入连接\n\tdb.SetMaxIdleConns(1)\n\tdb.SetConnMaxLifetime(0) // 不限制连接生命周期\n\n\t// 初始化表结构\n\tif err := initSchema(db); err != nil {\n\t\tdb.Close()\n\t\treturn nil, fmt.Errorf(\"初始化数据库 schema 失败: %w\", err)\n\t}\n\n\tstore := &SQLiteStore{\n\t\tdb:            db,\n\t\tdbPath:        cfg.DBPath,\n\t\twriteBuffer:   make([]PersistentRecord, 0, defaultBatchSize),\n\t\tbatchSize:     defaultBatchSize,\n\t\tflushInterval: defaultFlushInterval,\n\t\tretentionDays: cfg.RetentionDays,\n\t\tstopCh:        make(chan struct{}),\n\t}\n\n\t// 启动后台任务\n\tstore.wg.Add(2)\n\tgo store.flushLoop()\n\tgo store.cleanupLoop()\n\n\tlog.Printf(\"[SQLite-Init] 指标存储已初始化: %s (保留 %d 天)\", cfg.DBPath, cfg.RetentionDays)\n\treturn store, nil\n}\n\n// initSchema 初始化数据库表结构\nfunc initSchema(db *sql.DB) error {\n\tschema := `\n\t\t-- 请求记录表\n\t\tCREATE TABLE IF NOT EXISTS request_records (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tmetrics_key TEXT NOT NULL,\n\t\t\tbase_url TEXT NOT NULL,\n\t\t\tkey_mask TEXT NOT NULL,\n\t\t\ttimestamp INTEGER NOT NULL,\n\t\t\tsuccess INTEGER NOT NULL,\n\t\t\tinput_tokens INTEGER DEFAULT 0,\n\t\t\toutput_tokens INTEGER DEFAULT 0,\n\t\t\tcache_creation_tokens INTEGER DEFAULT 0,\n\t\t\tcache_read_tokens INTEGER DEFAULT 0,\n\t\t\tapi_type TEXT NOT NULL DEFAULT 'messages'\n\t\t);\n\n\t\t-- 索引：按 api_type 和时间查询\n\t\tCREATE INDEX IF NOT EXISTS idx_records_api_type_timestamp\n\t\t\tON request_records(api_type, timestamp);\n\n\t\t-- 索引：按 metrics_key 查询\n\t\tCREATE INDEX IF NOT EXISTS idx_records_metrics_key\n\t\t\tON request_records(metrics_key);\n\t`\n\n\t_, err := db.Exec(schema)\n\treturn err\n}\n\n// AddRecord 添加记录到写入缓冲区（非阻塞）\nfunc (s *SQLiteStore) AddRecord(record PersistentRecord) {\n\ts.bufferMu.Lock()\n\tif s.closed {\n\t\ts.bufferMu.Unlock()\n\t\treturn // 已关闭，忽略新记录\n\t}\n\ts.writeBuffer = append(s.writeBuffer, record)\n\tshouldFlush := len(s.writeBuffer) >= s.batchSize\n\ts.bufferMu.Unlock()\n\n\tif shouldFlush {\n\t\ts.asyncFlushWg.Add(1)\n\t\tgo func() {\n\t\t\tdefer s.asyncFlushWg.Done()\n\t\t\t// 获取 flush 锁，与 DeleteRecordsByMetricsKeys 串行化\n\t\t\ts.flushMu.Lock()\n\t\t\ts.flush()\n\t\t\ts.flushMu.Unlock()\n\t\t}()\n\t}\n}\n\n// flush 刷新缓冲区到数据库\nfunc (s *SQLiteStore) flush() {\n\ts.bufferMu.Lock()\n\tif len(s.writeBuffer) == 0 {\n\t\ts.bufferMu.Unlock()\n\t\treturn\n\t}\n\n\t// 取出缓冲区数据\n\trecords := s.writeBuffer\n\ts.writeBuffer = make([]PersistentRecord, 0, s.batchSize)\n\ts.bufferMu.Unlock()\n\n\t// 批量写入\n\tif err := s.batchInsertRecords(records); err != nil {\n\t\tlog.Printf(\"[SQLite-Flush] 警告: 批量写入指标记录失败: %v\", err)\n\t\t// 失败时将记录放回缓冲区（限制重试，避免无限增长）\n\t\ts.bufferMu.Lock()\n\t\tif len(s.writeBuffer) < s.batchSize*10 { // 最多保留 10 倍缓冲\n\t\t\ts.writeBuffer = append(records, s.writeBuffer...)\n\t\t} else {\n\t\t\tlog.Printf(\"[SQLite-Flush] 警告: 写入缓冲区已满，丢弃 %d 条记录\", len(records))\n\t\t}\n\t\ts.bufferMu.Unlock()\n\t}\n}\n\n// batchInsertRecords 批量插入记录\nfunc (s *SQLiteStore) batchInsertRecords(records []PersistentRecord) error {\n\tif len(records) == 0 {\n\t\treturn nil\n\t}\n\n\ttx, err := s.db.Begin()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer tx.Rollback()\n\n\tstmt, err := tx.Prepare(`\n\t\tINSERT INTO request_records\n\t\t(metrics_key, base_url, key_mask, timestamp, success,\n\t\t input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, api_type)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t`)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer stmt.Close()\n\n\tfor _, r := range records {\n\t\tsuccess := 0\n\t\tif r.Success {\n\t\t\tsuccess = 1\n\t\t}\n\t\t_, err := stmt.Exec(\n\t\t\tr.MetricsKey, r.BaseURL, r.KeyMask, r.Timestamp.Unix(), success,\n\t\t\tr.InputTokens, r.OutputTokens, r.CacheCreationTokens, r.CacheReadTokens, r.APIType,\n\t\t)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn tx.Commit()\n}\n\n// LoadRecords 加载指定时间范围内的记录\nfunc (s *SQLiteStore) LoadRecords(since time.Time, apiType string) ([]PersistentRecord, error) {\n\trows, err := s.db.Query(`\n\t\tSELECT metrics_key, base_url, key_mask, timestamp, success,\n\t\t       input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens\n\t\tFROM request_records\n\t\tWHERE timestamp >= ? AND api_type = ?\n\t\tORDER BY timestamp ASC\n\t`, since.Unix(), apiType)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\n\tvar records []PersistentRecord\n\tfor rows.Next() {\n\t\tvar r PersistentRecord\n\t\tvar ts int64\n\t\tvar success int\n\n\t\terr := rows.Scan(\n\t\t\t&r.MetricsKey, &r.BaseURL, &r.KeyMask, &ts, &success,\n\t\t\t&r.InputTokens, &r.OutputTokens, &r.CacheCreationTokens, &r.CacheReadTokens,\n\t\t)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tr.Timestamp = time.Unix(ts, 0)\n\t\tr.Success = success == 1\n\t\tr.APIType = apiType\n\t\trecords = append(records, r)\n\t}\n\n\treturn records, rows.Err()\n}\n\n// CleanupOldRecords 清理过期数据\nfunc (s *SQLiteStore) CleanupOldRecords(before time.Time) (int64, error) {\n\tresult, err := s.db.Exec(\n\t\t\"DELETE FROM request_records WHERE timestamp < ?\",\n\t\tbefore.Unix(),\n\t)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\treturn result.RowsAffected()\n}\n\n// DeleteRecordsByMetricsKeys 按 metrics_key 和 api_type 批量删除记录\n// apiType: 接口类型（messages/responses/gemini），避免误删其他接口的数据\nfunc (s *SQLiteStore) DeleteRecordsByMetricsKeys(metricsKeys []string, apiType string) (int64, error) {\n\tif len(metricsKeys) == 0 {\n\t\treturn 0, nil\n\t}\n\n\t// 获取 flush 锁，确保删除期间不会有后台 flush 写入新记录\n\ts.flushMu.Lock()\n\tdefer s.flushMu.Unlock()\n\n\t// 先刷新缓冲区，确保待删除的记录已写入数据库\n\ts.flush()\n\n\t// 分批删除，避免触发 SQLite 变量上限（默认 999）\n\tconst batchSize = 500\n\tvar totalDeleted int64\n\n\tfor i := 0; i < len(metricsKeys); i += batchSize {\n\t\tend := i + batchSize\n\t\tif end > len(metricsKeys) {\n\t\t\tend = len(metricsKeys)\n\t\t}\n\t\tbatch := metricsKeys[i:end]\n\n\t\t// 构建 IN 子句的占位符\n\t\tplaceholders := make([]string, len(batch))\n\t\targs := make([]interface{}, 0, len(batch)+1)\n\t\targs = append(args, apiType) // 第一个参数是 api_type\n\t\tfor j, key := range batch {\n\t\t\tplaceholders[j] = \"?\"\n\t\t\targs = append(args, key)\n\t\t}\n\n\t\tquery := fmt.Sprintf(\n\t\t\t\"DELETE FROM request_records WHERE api_type = ? AND metrics_key IN (%s)\",\n\t\t\tstrings.Join(placeholders, \",\"),\n\t\t)\n\n\t\tresult, err := s.db.Exec(query, args...)\n\t\tif err != nil {\n\t\t\treturn totalDeleted, fmt.Errorf(\"batch %d-%d failed: %w\", i, end, err)\n\t\t}\n\t\taffected, _ := result.RowsAffected()\n\t\ttotalDeleted += affected\n\t}\n\n\treturn totalDeleted, nil\n}\n\n// flushLoop 定时刷新循环\nfunc (s *SQLiteStore) flushLoop() {\n\tdefer s.wg.Done()\n\tticker := time.NewTicker(s.flushInterval)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ticker.C:\n\t\t\t// 获取 flush 锁，与 DeleteRecordsByMetricsKeys 串行化\n\t\t\ts.flushMu.Lock()\n\t\t\ts.flush()\n\t\t\ts.flushMu.Unlock()\n\t\tcase <-s.stopCh:\n\t\t\t// 关闭前最后一次刷新\n\t\t\ts.flushMu.Lock()\n\t\t\ts.flush()\n\t\t\ts.flushMu.Unlock()\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// cleanupLoop 定期清理循环\nfunc (s *SQLiteStore) cleanupLoop() {\n\tdefer s.wg.Done()\n\n\t// 启动时先清理一次\n\ts.doCleanup()\n\n\tticker := time.NewTicker(1 * time.Hour)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ticker.C:\n\t\t\ts.doCleanup()\n\t\tcase <-s.stopCh:\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// doCleanup 执行清理\nfunc (s *SQLiteStore) doCleanup() {\n\tcutoff := time.Now().AddDate(0, 0, -s.retentionDays)\n\tdeleted, err := s.CleanupOldRecords(cutoff)\n\tif err != nil {\n\t\tlog.Printf(\"[SQLite-Cleanup] 警告: 清理过期指标记录失败: %v\", err)\n\t} else if deleted > 0 {\n\t\tlog.Printf(\"[SQLite-Cleanup] 已清理 %d 条过期指标记录（超过 %d 天）\", deleted, s.retentionDays)\n\t}\n}\n\n// Close 关闭存储\nfunc (s *SQLiteStore) Close() error {\n\t// 标记为已关闭，阻止新记录\n\ts.bufferMu.Lock()\n\ts.closed = true\n\ts.bufferMu.Unlock()\n\n\t// 停止后台循环（flushLoop 会在退出前执行最后一次 flush）\n\tclose(s.stopCh)\n\ts.wg.Wait()\n\n\t// 等待所有 AddRecord 触发的异步 flush goroutine 完成\n\ts.asyncFlushWg.Wait()\n\n\treturn s.db.Close()\n}\n\n// GetRecordCount 获取记录总数（用于调试）\nfunc (s *SQLiteStore) GetRecordCount() (int64, error) {\n\tvar count int64\n\terr := s.db.QueryRow(\"SELECT COUNT(*) FROM request_records\").Scan(&count)\n\treturn count, err\n}\n"
  },
  {
    "path": "backend-go/internal/middleware/auth.go",
    "content": "package middleware\n\nimport (\n\t\"log\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// WebAuthMiddleware Web 访问控制中间件\nfunc WebAuthMiddleware(envCfg *config.EnvConfig, cfgManager *config.ConfigManager) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tpath := c.Request.URL.Path\n\n\t\t// 公开端点直接放行（健康检查固定为 /health）\n\t\tif path == \"/health\" {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// 静态资源文件直接放行\n\t\tif isStaticResource(path) {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// API 代理端点后续处理\n\t\tif strings.HasPrefix(path, \"/v1/\") || strings.HasPrefix(path, \"/v1beta/\") {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// 如果禁用了 Web UI，返回 404\n\t\tif !envCfg.EnableWebUI {\n\t\t\tc.JSON(404, gin.H{\n\t\t\t\t\"error\":   \"Web界面已禁用\",\n\t\t\t\t\"message\": \"此服务器运行在纯API模式下，请通过API端点访问服务\",\n\t\t\t})\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\t// SPA 页面路由直接交给前端处理，但需要排除 /api* 路径\n\t\tif path == \"/\" || path == \"/index.html\" || (!strings.Contains(path, \".\") && !strings.HasPrefix(path, \"/api\") && !strings.HasPrefix(path, \"/admin\")) {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// 检查访问密钥（管理 API + 管理端点）\n\t\tif strings.HasPrefix(path, \"/api\") || strings.HasPrefix(path, \"/admin\") {\n\t\t\tprovidedKey := getAPIKey(c)\n\t\t\texpectedKey := envCfg.ProxyAccessKey\n\n\t\t\t// 记录认证尝试\n\t\t\tclientIP := c.ClientIP()\n\t\t\ttimestamp := time.Now().Format(time.RFC3339)\n\n\t\t\tif providedKey == \"\" || providedKey != expectedKey {\n\t\t\t\t// 认证失败 - 记录详细日志\n\t\t\t\treason := \"密钥无效\"\n\t\t\t\tif providedKey == \"\" {\n\t\t\t\t\treason = \"密钥缺失\"\n\t\t\t\t}\n\t\t\t\tlog.Printf(\"[Auth-Failed] IP: %s | Path: %s | Time: %s | Reason: %s\",\n\t\t\t\t\tclientIP, path, timestamp, reason)\n\n\t\t\t\tc.JSON(401, gin.H{\n\t\t\t\t\t\"error\":   \"Unauthorized\",\n\t\t\t\t\t\"message\": \"Invalid or missing access key\",\n\t\t\t\t})\n\t\t\t\tc.Abort()\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 认证成功 - 记录日志(可选，根据日志级别)\n\t\t\t// 如果启用了 QuietPollingLogs，则静默轮询端点日志\n\t\t\tif envCfg.ShouldLog(\"info\") && !(envCfg.QuietPollingLogs && isPollingEndpoint(path)) {\n\t\t\t\tlog.Printf(\"[Auth-Success] IP: %s | Path: %s | Time: %s\", clientIP, path, timestamp)\n\t\t\t}\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n\n// isPollingEndpoint 判断是否为轮询端点（前缀匹配，兼容 query string 和尾部斜杠）\n// 复用 defaultSkipPrefixes 保持与 FilteredLogger 一致\nfunc isPollingEndpoint(path string) bool {\n\t// 移除 query string\n\tif idx := strings.Index(path, \"?\"); idx != -1 {\n\t\tpath = path[:idx]\n\t}\n\t// 移除尾部斜杠\n\tpath = strings.TrimSuffix(path, \"/\")\n\n\t// 复用 logger.go 中的 defaultSkipPrefixes\n\tfor _, prefix := range defaultSkipPrefixes {\n\t\tif strings.HasPrefix(path, prefix) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// isStaticResource 判断是否为静态资源\nfunc isStaticResource(path string) bool {\n\tstaticExtensions := []string{\n\t\t\"/assets/\", \".css\", \".js\", \".ico\", \".png\", \".jpg\",\n\t\t\".gif\", \".svg\", \".woff\", \".woff2\", \".ttf\", \".eot\",\n\t}\n\n\tfor _, ext := range staticExtensions {\n\t\tif strings.HasPrefix(path, ext) || strings.HasSuffix(path, ext) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// getAPIKey 获取 API 密钥\nfunc getAPIKey(c *gin.Context) string {\n\t// 从 header 获取\n\tif key := c.GetHeader(\"x-api-key\"); key != \"\" {\n\t\treturn key\n\t}\n\n\tif auth := c.GetHeader(\"Authorization\"); auth != \"\" {\n\t\t// 移除 Bearer 前缀\n\t\treturn strings.TrimPrefix(auth, \"Bearer \")\n\t}\n\n\t// 支持 Gemini SDK 的 x-goog-api-key 头部\n\tif key := c.GetHeader(\"x-goog-api-key\"); key != \"\" {\n\t\treturn key\n\t}\n\n\treturn \"\"\n}\n\n// ProxyAuthMiddleware 代理访问控制中间件\nfunc ProxyAuthMiddleware(envCfg *config.EnvConfig) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tprovidedKey := getAPIKey(c)\n\t\texpectedKey := envCfg.ProxyAccessKey\n\n\t\tif providedKey == \"\" || providedKey != expectedKey {\n\t\t\tif envCfg.ShouldLog(\"warn\") {\n\t\t\t\tlog.Printf(\"[Auth-Failed] 代理访问密钥验证失败 - IP: %s\", c.ClientIP())\n\t\t\t}\n\n\t\t\tc.JSON(401, gin.H{\n\t\t\t\t\"error\": \"Invalid proxy access key\",\n\t\t\t})\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/middleware/auth_test.go",
    "content": "package middleware\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// setupRouterWithAuth builds a minimal router with the auth middleware wired.\nfunc setupRouterWithAuth(envCfg *config.EnvConfig) *gin.Engine {\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.Use(WebAuthMiddleware(envCfg, nil))\n\n\t// Protected management API\n\tr.GET(\"/api/channels\", func(c *gin.Context) {\n\t\tc.JSON(http.StatusOK, gin.H{\"ok\": true})\n\t})\n\n\t// Protected admin endpoint\n\tr.POST(\"/admin/config/save\", func(c *gin.Context) {\n\t\tc.JSON(http.StatusOK, gin.H{\"ok\": true})\n\t})\n\tr.GET(\"/admin/dev/info\", func(c *gin.Context) {\n\t\tc.JSON(http.StatusOK, gin.H{\"ok\": true})\n\t})\n\n\t// SPA routes should pass through without access key\n\tr.GET(\"/\", func(c *gin.Context) {\n\t\tc.String(http.StatusOK, \"home\")\n\t})\n\tr.GET(\"/dashboard\", func(c *gin.Context) {\n\t\tc.String(http.StatusOK, \"dashboard\")\n\t})\n\n\treturn r\n}\n\nfunc TestWebAuthMiddleware_APIRequiresKey(t *testing.T) {\n\tenvCfg := &config.EnvConfig{\n\t\tProxyAccessKey: \"secret-key\",\n\t\tEnableWebUI:    true,\n\t}\n\trouter := setupRouterWithAuth(envCfg)\n\n\tt.Run(\"missing key returns 401\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/channels\", nil)\n\t\tw := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(w, req)\n\n\t\tif w.Code != http.StatusUnauthorized {\n\t\t\tt.Fatalf(\"status = %d, want %d\", w.Code, http.StatusUnauthorized)\n\t\t}\n\t})\n\n\tt.Run(\"wrong key returns 401\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/channels\", nil)\n\t\treq.Header.Set(\"x-api-key\", \"wrong\")\n\t\tw := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(w, req)\n\n\t\tif w.Code != http.StatusUnauthorized {\n\t\t\tt.Fatalf(\"status = %d, want %d\", w.Code, http.StatusUnauthorized)\n\t\t}\n\t})\n\n\tt.Run(\"correct key allows access\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/channels\", nil)\n\t\treq.Header.Set(\"x-api-key\", envCfg.ProxyAccessKey)\n\t\tw := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(w, req)\n\n\t\tif w.Code != http.StatusOK {\n\t\t\tt.Fatalf(\"status = %d, want %d\", w.Code, http.StatusOK)\n\t\t}\n\t})\n}\n\nfunc TestWebAuthMiddleware_SPAPassesThrough(t *testing.T) {\n\tenvCfg := &config.EnvConfig{\n\t\tProxyAccessKey: \"secret-key\",\n\t\tEnableWebUI:    true,\n\t}\n\trouter := setupRouterWithAuth(envCfg)\n\n\treq := httptest.NewRequest(http.MethodGet, \"/dashboard\", nil)\n\tw := httptest.NewRecorder()\n\n\trouter.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d\", w.Code, http.StatusOK)\n\t}\n}\n\nfunc TestWebAuthMiddleware_AdminRequiresKey(t *testing.T) {\n\tenvCfg := &config.EnvConfig{\n\t\tProxyAccessKey: \"secret-key\",\n\t\tEnableWebUI:    true,\n\t}\n\trouter := setupRouterWithAuth(envCfg)\n\n\tt.Run(\"missing key returns 401\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodPost, \"/admin/config/save\", nil)\n\t\tw := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(w, req)\n\n\t\tif w.Code != http.StatusUnauthorized {\n\t\t\tt.Fatalf(\"status = %d, want %d\", w.Code, http.StatusUnauthorized)\n\t\t}\n\t})\n\n\tt.Run(\"correct key allows access\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodPost, \"/admin/config/save\", nil)\n\t\treq.Header.Set(\"x-api-key\", envCfg.ProxyAccessKey)\n\t\tw := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(w, req)\n\n\t\tif w.Code != http.StatusOK {\n\t\t\tt.Fatalf(\"status = %d, want %d\", w.Code, http.StatusOK)\n\t\t}\n\t})\n}\n\nfunc TestWebAuthMiddleware_DevInfoRequiresKeyInDevelopment(t *testing.T) {\n\tenvCfg := &config.EnvConfig{\n\t\tEnv:            \"development\",\n\t\tProxyAccessKey: \"secret-key\",\n\t\tEnableWebUI:    true,\n\t}\n\trouter := setupRouterWithAuth(envCfg)\n\n\tt.Run(\"missing key returns 401\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/admin/dev/info\", nil)\n\t\tw := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(w, req)\n\n\t\tif w.Code != http.StatusUnauthorized {\n\t\t\tt.Fatalf(\"status = %d, want %d\", w.Code, http.StatusUnauthorized)\n\t\t}\n\t})\n\n\tt.Run(\"correct key allows access\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/admin/dev/info\", nil)\n\t\treq.Header.Set(\"x-api-key\", envCfg.ProxyAccessKey)\n\t\tw := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(w, req)\n\n\t\tif w.Code != http.StatusOK {\n\t\t\tt.Fatalf(\"status = %d, want %d\", w.Code, http.StatusOK)\n\t\t}\n\t})\n}\n\nfunc TestWebAuthMiddleware_AllowsV1BetaRoutesWhenWebUIDisabled(t *testing.T) {\n\tenvCfg := &config.EnvConfig{\n\t\tProxyAccessKey: \"secret-key\",\n\t\tEnableWebUI:    false,\n\t}\n\n\tgin.SetMode(gin.TestMode)\n\tr := gin.New()\n\tr.Use(WebAuthMiddleware(envCfg, nil))\n\n\tr.POST(\"/v1beta/models/*modelAction\", func(c *gin.Context) {\n\t\tc.JSON(http.StatusOK, gin.H{\"ok\": true})\n\t})\n\n\treq := httptest.NewRequest(http.MethodPost, \"/v1beta/models/gemini-2.0-flash:generateContent\", nil)\n\tw := httptest.NewRecorder()\n\n\tr.ServeHTTP(w, req)\n\n\tif w.Code != http.StatusOK {\n\t\tt.Fatalf(\"status = %d, want %d\", w.Code, http.StatusOK)\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/middleware/cors.go",
    "content": "package middleware\n\nimport (\n\t\"strings\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// CORSMiddleware CORS 中间件\nfunc CORSMiddleware(envCfg *config.EnvConfig) gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// 如果未启用 CORS，直接跳过\n\t\tif !envCfg.EnableCORS {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\torigin := c.GetHeader(\"Origin\")\n\n\t\t// 开发环境允许所有 localhost 源\n\t\tif envCfg.IsDevelopment() {\n\t\t\tif origin != \"\" && strings.Contains(origin, \"localhost\") {\n\t\t\t\tc.Header(\"Access-Control-Allow-Origin\", origin)\n\t\t\t}\n\t\t} else {\n\t\t\t// 生产环境使用配置的源\n\t\t\tc.Header(\"Access-Control-Allow-Origin\", envCfg.CORSOrigin)\n\t\t}\n\n\t\tc.Header(\"Access-Control-Allow-Methods\", \"GET, POST, PUT, PATCH, DELETE, OPTIONS\")\n\t\tc.Header(\"Access-Control-Allow-Headers\", \"Content-Type, Authorization, x-api-key, x-goog-api-key\")\n\t\t// 仅在非 * 时设置 credentials，避免浏览器拒绝 credentials + * 组合\n\t\tif envCfg.CORSOrigin != \"*\" {\n\t\t\tc.Header(\"Access-Control-Allow-Credentials\", \"true\")\n\t\t}\n\n\t\t// 处理预检请求\n\t\tif c.Request.Method == \"OPTIONS\" {\n\t\t\tc.AbortWithStatus(204)\n\t\t\treturn\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/middleware/logger.go",
    "content": "package middleware\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// 默认跳过日志的路径前缀（仅 GET 请求）\nvar defaultSkipPrefixes = []string{\n\t\"/api/messages/channels\",\n\t\"/api/responses/channels\",\n\t\"/api/gemini/channels\",\n\t\"/api/messages/global/stats\",\n\t\"/api/responses/global/stats\",\n\t\"/api/gemini/global/stats\",\n}\n\n// FilteredLogger 创建一个可过滤路径的 Logger 中间件\n// 仅对 GET 请求且匹配 skipPrefixes 前缀的路径跳过日志输出\n// POST/PUT/DELETE 等管理操作始终记录日志以保留审计跟踪\nfunc FilteredLogger(envCfg *config.EnvConfig, skipPrefixes ...string) gin.HandlerFunc {\n\t// 如果 QuietPollingLogs 为 false，使用标准 Logger\n\tif !envCfg.QuietPollingLogs {\n\t\treturn gin.Logger()\n\t}\n\n\tif len(skipPrefixes) == 0 {\n\t\tskipPrefixes = defaultSkipPrefixes\n\t}\n\n\treturn gin.LoggerWithConfig(gin.LoggerConfig{\n\t\tSkip: func(c *gin.Context) bool {\n\t\t\t// 只跳过 GET 请求，保留其他方法的审计日志\n\t\t\tif c.Request.Method != http.MethodGet {\n\t\t\t\treturn false\n\t\t\t}\n\n\t\t\tpath := c.Request.URL.Path\n\t\t\tfor _, prefix := range skipPrefixes {\n\t\t\t\tif strings.HasPrefix(path, prefix) {\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn false\n\t\t},\n\t})\n}\n"
  },
  {
    "path": "backend-go/internal/providers/claude.go",
    "content": "package providers\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n\t\"github.com/BenedictKing/claude-proxy/internal/utils\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// ClaudeProvider Claude 提供商（直接透传）\ntype ClaudeProvider struct{}\n\n// redirectModelInBody 仅修改请求体中的 model 字段，保持其他内容不变\n// 使用 map[string]interface{} 避免结构体字段丢失问题\nfunc redirectModelInBody(bodyBytes []byte, upstream *config.UpstreamConfig) []byte {\n\tdecoder := json.NewDecoder(bytes.NewReader(bodyBytes))\n\tdecoder.UseNumber() // 保留数字精度\n\n\tvar data map[string]interface{}\n\tif err := decoder.Decode(&data); err != nil {\n\t\treturn bodyBytes // 解析失败，返回原始数据\n\t}\n\n\tmodel, ok := data[\"model\"].(string)\n\tif !ok {\n\t\treturn bodyBytes // 没有 model 字段或类型不对\n\t}\n\n\tnewModel := config.RedirectModel(model, upstream)\n\tif newModel == model {\n\t\treturn bodyBytes // 模型未变，无需重编码\n\t}\n\n\tdata[\"model\"] = newModel\n\n\t// 使用 Encoder 并禁用 HTML 转义，保持原始格式\n\tnewBytes, err := utils.MarshalJSONNoEscape(data)\n\tif err != nil {\n\t\treturn bodyBytes // 编码失败，返回原始数据\n\t}\n\treturn newBytes\n}\n\n// ConvertToProviderRequest 转换为 Claude 请求（实现真正的透传）\nfunc (p *ClaudeProvider) ConvertToProviderRequest(c *gin.Context, upstream *config.UpstreamConfig, apiKey string) (*http.Request, []byte, error) {\n\t// 读取原始请求体\n\tbodyBytes, err := io.ReadAll(c.Request.Body)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tc.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes)) // 恢复body\n\n\t// 模型重定向：仅修改 model 字段，保持其他内容不变\n\tif upstream.ModelMapping != nil && len(upstream.ModelMapping) > 0 {\n\t\tbodyBytes = redirectModelInBody(bodyBytes, upstream)\n\t}\n\n\t// 构建目标URL\n\t// 智能拼接逻辑：\n\t// 1. 如果 baseURL 以 # 结尾，跳过自动添加 /v1\n\t// 2. 如果 baseURL 已包含版本号后缀（如 /v1, /v2, /v3），直接拼接端点路径\n\t// 3. 如果 baseURL 不包含版本号后缀，自动添加 /v1 再拼接端点路径\n\tendpoint := strings.TrimPrefix(c.Request.URL.Path, \"/v1\")\n\tbaseURL := upstream.GetEffectiveBaseURL()\n\tskipVersionPrefix := strings.HasSuffix(baseURL, \"#\")\n\tif skipVersionPrefix {\n\t\tbaseURL = strings.TrimSuffix(baseURL, \"#\")\n\t}\n\tbaseURL = strings.TrimSuffix(baseURL, \"/\")\n\n\t// 使用正则表达式检测 baseURL 是否以版本号结尾（/v1, /v2, /v1beta, /v2alpha等）\n\tversionPattern := regexp.MustCompile(`/v\\d+[a-z]*$`)\n\n\tvar targetURL string\n\tif versionPattern.MatchString(baseURL) || skipVersionPrefix {\n\t\t// baseURL 已包含版本号或以#结尾，直接拼接\n\t\ttargetURL = baseURL + endpoint\n\t} else {\n\t\t// baseURL 不包含版本号，添加 /v1\n\t\ttargetURL = baseURL + \"/v1\" + endpoint\n\t}\n\n\tif c.Request.URL.RawQuery != \"\" {\n\t\ttargetURL += \"?\" + c.Request.URL.RawQuery\n\t}\n\n\t// 创建请求\n\tvar req *http.Request\n\tif len(bodyBytes) > 0 {\n\t\treq, err = http.NewRequestWithContext(c.Request.Context(), c.Request.Method, targetURL, bytes.NewReader(bodyBytes))\n\t} else {\n\t\t// 如果 bodyBytes 为空（例如 GET 请求或原始请求体为空），则直接使用 nil Body\n\t\treq, err = http.NewRequestWithContext(c.Request.Context(), c.Request.Method, targetURL, nil)\n\t}\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\t// 使用统一的头部处理逻辑\n\treq.Header = utils.PrepareUpstreamHeaders(c, req.URL.Host)\n\tutils.SetAuthenticationHeader(req.Header, apiKey)\n\tutils.EnsureCompatibleUserAgent(req.Header, \"claude\")\n\n\treturn req, bodyBytes, nil\n}\n\n// ConvertToClaudeResponse 转换为 Claude 响应（直接透传）\nfunc (p *ClaudeProvider) ConvertToClaudeResponse(providerResp *types.ProviderResponse) (*types.ClaudeResponse, error) {\n\tvar claudeResp types.ClaudeResponse\n\tif err := json.Unmarshal(providerResp.Body, &claudeResp); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &claudeResp, nil\n}\n\n// HandleStreamResponse 处理流式响应（直接透传）\nfunc (p *ClaudeProvider) HandleStreamResponse(body io.ReadCloser) (<-chan string, <-chan error, error) {\n\teventChan := make(chan string, 100)\n\terrChan := make(chan error, 1)\n\n\tgo func() {\n\t\tdefer close(eventChan)\n\t\tdefer close(errChan)\n\t\tdefer body.Close()\n\n\t\tscanner := bufio.NewScanner(body)\n\t\t// 设置更大的 buffer (1MB) 以处理大 JSON chunk，避免默认 64KB 限制\n\t\tconst maxScannerBufferSize = 1024 * 1024 // 1MB\n\t\tscanner.Buffer(make([]byte, 0, 64*1024), maxScannerBufferSize)\n\n\t\ttoolUseStopEmitted := false\n\n\t\t// 注意：为了让下游的 token 注入/修补逻辑保持正确，这里必须按「完整 SSE 事件」转发。\n\t\t// 上游以空行分隔事件：event/data/id/retry/... + \"\\n\"，空行 => 事件结束。\n\t\tvar eventBuf strings.Builder\n\n\t\tflushEvent := func() {\n\t\t\tif eventBuf.Len() == 0 {\n\t\t\t\treturn\n\t\t\t}\n\t\t\teventChan <- eventBuf.String()\n\t\t\teventBuf.Reset()\n\t\t}\n\n\t\tfor scanner.Scan() {\n\t\t\tline := scanner.Text()\n\n\t\t\t// 检测是否发送了 tool_use 相关的 stop_reason（通常在 data 行中）\n\t\t\tif strings.Contains(line, `\"stop_reason\":\"tool_use\"`) ||\n\t\t\t\tstrings.Contains(line, `\"stop_reason\": \"tool_use\"`) {\n\t\t\t\ttoolUseStopEmitted = true\n\t\t\t}\n\n\t\t\t// 透传所有 SSE 字段（包括注释、id、retry 等）\n\t\t\teventBuf.WriteString(line)\n\t\t\teventBuf.WriteString(\"\\n\")\n\n\t\t\t// 空行表示一个 SSE event 结束\n\t\t\tif line == \"\" {\n\t\t\t\tflushEvent()\n\t\t\t}\n\t\t}\n\n\t\t// 若上游未以空行结尾，仍尝试把最后的残留事件发出去\n\t\tflushEvent()\n\n\t\tif err := scanner.Err(); err != nil {\n\t\t\t// 在 tool_use 场景下，客户端主动断开是正常行为\n\t\t\t// 如果已经发送了 tool_use stop 事件，并且错误是连接断开相关的，则忽略该错误\n\t\t\terrMsg := err.Error()\n\t\t\tif toolUseStopEmitted && (strings.Contains(errMsg, \"broken pipe\") ||\n\t\t\t\tstrings.Contains(errMsg, \"connection reset\") ||\n\t\t\t\tstrings.Contains(errMsg, \"EOF\")) {\n\t\t\t\t// 这是预期的客户端行为，不报告错误\n\t\t\t\treturn\n\t\t\t}\n\t\t\terrChan <- err\n\t\t}\n\t}()\n\n\treturn eventChan, errChan, nil\n}\n"
  },
  {
    "path": "backend-go/internal/providers/gemini.go",
    "content": "package providers\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n\t\"github.com/BenedictKing/claude-proxy/internal/utils\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// GeminiProvider Gemini 提供商\ntype GeminiProvider struct{}\n\n// ConvertToProviderRequest 转换为 Gemini 请求\nfunc (p *GeminiProvider) ConvertToProviderRequest(c *gin.Context, upstream *config.UpstreamConfig, apiKey string) (*http.Request, []byte, error) {\n\t// 读取和解析原始请求体\n\toriginalBodyBytes, err := io.ReadAll(c.Request.Body)\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"读取请求体失败: %w\", err)\n\t}\n\t// 恢复请求体，以便gin context可以被其他地方再次读取（尽管这里我们已经完全处理了）\n\tc.Request.Body = io.NopCloser(bytes.NewReader(originalBodyBytes))\n\n\tvar claudeReq types.ClaudeRequest\n\tif err := json.Unmarshal(originalBodyBytes, &claudeReq); err != nil {\n\t\treturn nil, originalBodyBytes, fmt.Errorf(\"解析Claude请求体失败: %w\", err)\n\t}\n\n\t// --- 复用旧的转换逻辑 ---\n\tgeminiReq := p.convertToGeminiRequest(&claudeReq, upstream)\n\t// --- 转换逻辑结束 ---\n\n\treqBodyBytes, err := json.Marshal(geminiReq)\n\tif err != nil {\n\t\treturn nil, originalBodyBytes, fmt.Errorf(\"序列化Gemini请求体失败: %w\", err)\n\t}\n\n\tmodel := config.RedirectModel(claudeReq.Model, upstream)\n\taction := \"generateContent\"\n\tif claudeReq.Stream {\n\t\taction = \"streamGenerateContent?alt=sse\"\n\t}\n\n\turl := fmt.Sprintf(\"%s/models/%s:%s\", strings.TrimSuffix(upstream.GetEffectiveBaseURL(), \"/\"), model, action)\n\n\treq, err := http.NewRequestWithContext(c.Request.Context(), \"POST\", url, bytes.NewReader(reqBodyBytes))\n\tif err != nil {\n\t\treturn nil, originalBodyBytes, fmt.Errorf(\"创建Gemini请求失败: %w\", err)\n\t}\n\n\t// 使用统一的头部处理逻辑（透明代理）\n\t// 保留客户端的大部分 headers，只移除/替换必要的认证和代理相关 headers\n\treq.Header = utils.PrepareUpstreamHeaders(c, req.URL.Host)\n\tutils.SetGeminiAuthenticationHeader(req.Header, apiKey)\n\n\treturn req, originalBodyBytes, nil\n}\n\n// convertToGeminiRequest 转换为 Gemini 请求体\nfunc (p *GeminiProvider) convertToGeminiRequest(claudeReq *types.ClaudeRequest, upstream *config.UpstreamConfig) map[string]interface{} {\n\treq := map[string]interface{}{\n\t\t\"contents\": p.convertMessages(claudeReq.Messages),\n\t}\n\n\t// 添加系统指令\n\tif claudeReq.System != nil {\n\t\tsystemText := extractSystemText(claudeReq.System)\n\t\tif systemText != \"\" {\n\t\t\treq[\"systemInstruction\"] = map[string]interface{}{\n\t\t\t\t\"parts\": []map[string]string{\n\t\t\t\t\t{\"text\": systemText},\n\t\t\t\t},\n\t\t\t}\n\t\t}\n\t}\n\n\t// 生成配置\n\tgenConfig := map[string]interface{}{}\n\n\tif claudeReq.MaxTokens > 0 {\n\t\tgenConfig[\"maxOutputTokens\"] = claudeReq.MaxTokens\n\t}\n\n\tif claudeReq.Temperature > 0 {\n\t\tgenConfig[\"temperature\"] = claudeReq.Temperature\n\t}\n\n\tif len(genConfig) > 0 {\n\t\treq[\"generationConfig\"] = genConfig\n\t}\n\n\t// 工具\n\tif len(claudeReq.Tools) > 0 {\n\t\treq[\"tools\"] = []map[string]interface{}{\n\t\t\t{\n\t\t\t\t\"functionDeclarations\": p.convertTools(claudeReq.Tools),\n\t\t\t},\n\t\t}\n\t}\n\n\treturn req\n}\n\n// convertMessages 转换消息\nfunc (p *GeminiProvider) convertMessages(claudeMessages []types.ClaudeMessage) []map[string]interface{} {\n\tmessages := []map[string]interface{}{}\n\n\tfor _, msg := range claudeMessages {\n\t\tgeminiMsg := p.convertMessage(msg)\n\t\tif geminiMsg != nil {\n\t\t\tmessages = append(messages, geminiMsg)\n\t\t}\n\t}\n\n\treturn messages\n}\n\n// convertMessage 转换单个消息\nfunc (p *GeminiProvider) convertMessage(msg types.ClaudeMessage) map[string]interface{} {\n\trole := msg.Role\n\tif role == \"assistant\" {\n\t\trole = \"model\"\n\t}\n\n\tparts := []interface{}{}\n\n\t// 处理字符串内容\n\tif str, ok := msg.Content.(string); ok {\n\t\tparts = append(parts, map[string]string{\n\t\t\t\"text\": str,\n\t\t})\n\t\treturn map[string]interface{}{\n\t\t\t\"role\":  role,\n\t\t\t\"parts\": parts,\n\t\t}\n\t}\n\n\t// 处理内容数组\n\tcontents, ok := msg.Content.([]interface{})\n\tif !ok {\n\t\treturn nil\n\t}\n\n\tfor _, c := range contents {\n\t\tcontent, ok := c.(map[string]interface{})\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcontentType, _ := content[\"type\"].(string)\n\n\t\tswitch contentType {\n\t\tcase \"text\":\n\t\t\tif text, ok := content[\"text\"].(string); ok {\n\t\t\t\tparts = append(parts, map[string]string{\n\t\t\t\t\t\"text\": text,\n\t\t\t\t})\n\t\t\t}\n\n\t\tcase \"tool_use\":\n\t\t\tname, _ := content[\"name\"].(string)\n\t\t\tinput := content[\"input\"]\n\n\t\t\tparts = append(parts, map[string]interface{}{\n\t\t\t\t\"functionCall\": map[string]interface{}{\n\t\t\t\t\t\"name\": name,\n\t\t\t\t\t\"args\": input,\n\t\t\t\t},\n\t\t\t})\n\n\t\tcase \"tool_result\":\n\t\t\ttoolUseID, _ := content[\"tool_use_id\"].(string)\n\t\t\tresultContent := content[\"content\"]\n\n\t\t\tvar response interface{}\n\t\t\tif str, ok := resultContent.(string); ok {\n\t\t\t\tresponse = map[string]string{\"result\": str}\n\t\t\t} else {\n\t\t\t\tresponse = resultContent\n\t\t\t}\n\n\t\t\tparts = append(parts, map[string]interface{}{\n\t\t\t\t\"functionResponse\": map[string]interface{}{\n\t\t\t\t\t\"name\":     toolUseID,\n\t\t\t\t\t\"response\": response,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\t}\n\n\tif len(parts) == 0 {\n\t\treturn nil\n\t}\n\n\treturn map[string]interface{}{\n\t\t\"role\":  role,\n\t\t\"parts\": parts,\n\t}\n}\n\n// convertTools 转换工具\nfunc (p *GeminiProvider) convertTools(claudeTools []types.ClaudeTool) []map[string]interface{} {\n\ttools := []map[string]interface{}{}\n\n\tfor _, tool := range claudeTools {\n\t\ttools = append(tools, map[string]interface{}{\n\t\t\t\"name\":        tool.Name,\n\t\t\t\"description\": tool.Description,\n\t\t\t\"parameters\":  normalizeGeminiParameters(cleanJsonSchema(tool.InputSchema)),\n\t\t})\n\t}\n\n\treturn tools\n}\n\n// normalizeGeminiParameters 确保参数 schema 符合 Gemini 要求\n// Gemini 要求 functionDeclaration.parameters 必须是 type: \"object\" 且有 properties 字段\nfunc normalizeGeminiParameters(schema interface{}) map[string]interface{} {\n\t// 默认空 schema\n\tdefaultSchema := map[string]interface{}{\n\t\t\"type\":       \"object\",\n\t\t\"properties\": map[string]interface{}{},\n\t}\n\n\tif schema == nil {\n\t\treturn defaultSchema\n\t}\n\n\tschemaMap, ok := schema.(map[string]interface{})\n\tif !ok {\n\t\treturn defaultSchema\n\t}\n\n\t// 确保有 type 字段且为 \"object\"\n\tif _, hasType := schemaMap[\"type\"]; !hasType {\n\t\tschemaMap[\"type\"] = \"object\"\n\t}\n\n\t// 确保有 properties 字段\n\tif _, hasProps := schemaMap[\"properties\"]; !hasProps {\n\t\tschemaMap[\"properties\"] = map[string]interface{}{}\n\t}\n\n\treturn schemaMap\n}\n\n// ConvertToClaudeResponse 转换为 Claude 响应\nfunc (p *GeminiProvider) ConvertToClaudeResponse(providerResp *types.ProviderResponse) (*types.ClaudeResponse, error) {\n\tvar geminiResp map[string]interface{}\n\tif err := json.Unmarshal(providerResp.Body, &geminiResp); err != nil {\n\t\treturn nil, err\n\t}\n\n\tclaudeResp := &types.ClaudeResponse{\n\t\tID:      generateID(),\n\t\tType:    \"message\",\n\t\tRole:    \"assistant\",\n\t\tContent: []types.ClaudeContent{},\n\t}\n\n\tcandidates, ok := geminiResp[\"candidates\"].([]interface{})\n\tif !ok || len(candidates) == 0 {\n\t\treturn claudeResp, nil\n\t}\n\n\tcandidate, ok := candidates[0].(map[string]interface{})\n\tif !ok {\n\t\treturn claudeResp, nil\n\t}\n\n\tcontent, ok := candidate[\"content\"].(map[string]interface{})\n\tif !ok {\n\t\treturn claudeResp, nil\n\t}\n\n\tparts, ok := content[\"parts\"].([]interface{})\n\tif !ok {\n\t\treturn claudeResp, nil\n\t}\n\n\t// 处理各个部分\n\tfor _, p := range parts {\n\t\tpart, ok := p.(map[string]interface{})\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 文本内容\n\t\tif text, ok := part[\"text\"].(string); ok {\n\t\t\tclaudeResp.Content = append(claudeResp.Content, types.ClaudeContent{\n\t\t\t\tType: \"text\",\n\t\t\t\tText: text,\n\t\t\t})\n\t\t}\n\n\t\t// 函数调用\n\t\tif fc, ok := part[\"functionCall\"].(map[string]interface{}); ok {\n\t\t\tname, _ := fc[\"name\"].(string)\n\t\t\targs := fc[\"args\"]\n\n\t\t\tclaudeResp.Content = append(claudeResp.Content, types.ClaudeContent{\n\t\t\t\tType:  \"tool_use\",\n\t\t\t\tID:    fmt.Sprintf(\"toolu_%d\", len(claudeResp.Content)),\n\t\t\t\tName:  name,\n\t\t\t\tInput: args,\n\t\t\t})\n\t\t}\n\t}\n\n\t// 设置停止原因\n\tfinishReason, _ := candidate[\"finishReason\"].(string)\n\tif strings.Contains(strings.ToLower(finishReason), \"stop\") {\n\t\t// 检查是否有工具调用\n\t\thasToolCall := false\n\t\tfor _, c := range claudeResp.Content {\n\t\t\tif c.Type == \"tool_use\" {\n\t\t\t\thasToolCall = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif hasToolCall {\n\t\t\tclaudeResp.StopReason = \"tool_use\"\n\t\t} else {\n\t\t\tclaudeResp.StopReason = \"end_turn\"\n\t\t}\n\t} else if strings.Contains(strings.ToLower(finishReason), \"length\") {\n\t\tclaudeResp.StopReason = \"max_tokens\"\n\t}\n\n\t// 使用统计\n\tif usageMetadata, ok := geminiResp[\"usageMetadata\"].(map[string]interface{}); ok {\n\t\tusage := &types.Usage{}\n\t\tif promptTokens, ok := usageMetadata[\"promptTokenCount\"].(float64); ok {\n\t\t\tusage.InputTokens = int(promptTokens)\n\t\t}\n\t\tif candidatesTokens, ok := usageMetadata[\"candidatesTokenCount\"].(float64); ok {\n\t\t\tusage.OutputTokens = int(candidatesTokens)\n\t\t}\n\t\tclaudeResp.Usage = usage\n\t}\n\n\treturn claudeResp, nil\n}\n\n// HandleStreamResponse 处理流式响应\nfunc (p *GeminiProvider) HandleStreamResponse(body io.ReadCloser) (<-chan string, <-chan error, error) {\n\teventChan := make(chan string, 100)\n\terrChan := make(chan error, 1)\n\n\tgo func() {\n\t\tdefer close(eventChan)\n\t\t// defer close(errChan) // 移除此行，避免竞态条件\n\t\tdefer body.Close()\n\n\t\tscanner := bufio.NewScanner(body)\n\t\t// 设置更大的 buffer (1MB) 以处理大 JSON chunk，避免默认 64KB 限制\n\t\tconst maxScannerBufferSize = 1024 * 1024 // 1MB\n\t\tscanner.Buffer(make([]byte, 0, 64*1024), maxScannerBufferSize)\n\n\t\ttoolUseBlockIndex := 0\n\n\t\t// 文本块状态跟踪\n\t\ttextBlockStarted := false\n\t\ttextBlockIndex := 0\n\n\t\tfor scanner.Scan() {\n\t\t\tline := scanner.Text()\n\t\t\tline = strings.TrimSpace(line)\n\n\t\t\tif line == \"\" || line == \"data: [DONE]\" {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tjsonStr := strings.TrimPrefix(line, \"data: \")\n\n\t\t\tvar chunk map[string]interface{}\n\t\t\tif err := json.Unmarshal([]byte(jsonStr), &chunk); err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tcandidates, ok := chunk[\"candidates\"].([]interface{})\n\t\t\tif !ok || len(candidates) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tcandidate, ok := candidates[0].(map[string]interface{})\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tcontent, ok := candidate[\"content\"].(map[string]interface{})\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tparts, ok := content[\"parts\"].([]interface{})\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tfor _, p := range parts {\n\t\t\t\tpart, ok := p.(map[string]interface{})\n\t\t\t\tif !ok {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// 处理文本\n\t\t\t\tif text, ok := part[\"text\"].(string); ok {\n\t\t\t\t\t// 如果是第一个文本块,发送 content_block_start\n\t\t\t\t\tif !textBlockStarted {\n\t\t\t\t\t\tstartEvent := map[string]interface{}{\n\t\t\t\t\t\t\t\"type\":  \"content_block_start\",\n\t\t\t\t\t\t\t\"index\": textBlockIndex,\n\t\t\t\t\t\t\t\"content_block\": map[string]string{\n\t\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\t\"text\": \"\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}\n\t\t\t\t\t\tstartJSON, _ := json.Marshal(startEvent)\n\t\t\t\t\t\teventChan <- fmt.Sprintf(\"event: content_block_start\\ndata: %s\\n\\n\", startJSON)\n\t\t\t\t\t\ttextBlockStarted = true\n\t\t\t\t\t}\n\n\t\t\t\t\t// 发送 content_block_delta\n\t\t\t\t\tdeltaEvent := map[string]interface{}{\n\t\t\t\t\t\t\"type\":  \"content_block_delta\",\n\t\t\t\t\t\t\"index\": textBlockIndex,\n\t\t\t\t\t\t\"delta\": map[string]string{\n\t\t\t\t\t\t\t\"type\": \"text_delta\",\n\t\t\t\t\t\t\t\"text\": text,\n\t\t\t\t\t\t},\n\t\t\t\t\t}\n\t\t\t\t\tdeltaJSON, _ := json.Marshal(deltaEvent)\n\t\t\t\t\teventChan <- fmt.Sprintf(\"event: content_block_delta\\ndata: %s\\n\\n\", deltaJSON)\n\t\t\t\t}\n\n\t\t\t\t// 处理函数调用\n\t\t\t\tif fc, ok := part[\"functionCall\"].(map[string]interface{}); ok {\n\t\t\t\t\t// 如果有文本块正在进行,先关闭它\n\t\t\t\t\tif textBlockStarted {\n\t\t\t\t\t\tstopEvent := map[string]interface{}{\n\t\t\t\t\t\t\t\"type\":  \"content_block_stop\",\n\t\t\t\t\t\t\t\"index\": textBlockIndex,\n\t\t\t\t\t\t}\n\t\t\t\t\t\tstopJSON, _ := json.Marshal(stopEvent)\n\t\t\t\t\t\teventChan <- fmt.Sprintf(\"event: content_block_stop\\ndata: %s\\n\\n\", stopJSON)\n\t\t\t\t\t\ttextBlockStarted = false\n\t\t\t\t\t\ttextBlockIndex++\n\t\t\t\t\t}\n\n\t\t\t\t\tname, _ := fc[\"name\"].(string)\n\t\t\t\t\targs := fc[\"args\"]\n\t\t\t\t\tid := fmt.Sprintf(\"toolu_%d\", toolUseBlockIndex)\n\n\t\t\t\t\tevents := processToolUsePart(id, name, args, toolUseBlockIndex)\n\t\t\t\t\tfor _, event := range events {\n\t\t\t\t\t\teventChan <- event\n\t\t\t\t\t}\n\t\t\t\t\ttoolUseBlockIndex++\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// 处理结束原因\n\t\t\tif finishReason, ok := candidate[\"finishReason\"].(string); ok {\n\t\t\t\t// 如果有未关闭的文本块,先关闭它\n\t\t\t\tif textBlockStarted {\n\t\t\t\t\tstopEvent := map[string]interface{}{\n\t\t\t\t\t\t\"type\":  \"content_block_stop\",\n\t\t\t\t\t\t\"index\": textBlockIndex,\n\t\t\t\t\t}\n\t\t\t\t\tstopJSON, _ := json.Marshal(stopEvent)\n\t\t\t\t\teventChan <- fmt.Sprintf(\"event: content_block_stop\\ndata: %s\\n\\n\", stopJSON)\n\t\t\t\t\ttextBlockStarted = false\n\t\t\t\t}\n\n\t\t\t\tif strings.Contains(strings.ToLower(finishReason), \"stop\") {\n\t\t\t\t\tevent := map[string]interface{}{\n\t\t\t\t\t\t\"type\": \"message_delta\",\n\t\t\t\t\t\t\"delta\": map[string]string{\n\t\t\t\t\t\t\t\"stop_reason\": \"end_turn\",\n\t\t\t\t\t\t},\n\t\t\t\t\t}\n\t\t\t\t\teventJSON, _ := json.Marshal(event)\n\t\t\t\t\teventChan <- fmt.Sprintf(\"event: message_delta\\ndata: %s\\n\\n\", eventJSON)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 确保流结束时关闭任何未关闭的文本块\n\t\tif textBlockStarted {\n\t\t\tstopEvent := map[string]interface{}{\n\t\t\t\t\"type\":  \"content_block_stop\",\n\t\t\t\t\"index\": textBlockIndex,\n\t\t\t}\n\t\t\tstopJSON, _ := json.Marshal(stopEvent)\n\t\t\teventChan <- fmt.Sprintf(\"event: content_block_stop\\ndata: %s\\n\\n\", stopJSON)\n\t\t}\n\n\t\tif err := scanner.Err(); err != nil {\n\t\t\terrChan <- err\n\t\t}\n\t}()\n\n\treturn eventChan, errChan, nil\n}\n"
  },
  {
    "path": "backend-go/internal/providers/openai.go",
    "content": "package providers\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n\t\"github.com/BenedictKing/claude-proxy/internal/utils\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// OpenAIProvider OpenAI 提供商\ntype OpenAIProvider struct{}\n\n// ConvertToProviderRequest 转换为 OpenAI 请求\nfunc (p *OpenAIProvider) ConvertToProviderRequest(c *gin.Context, upstream *config.UpstreamConfig, apiKey string) (*http.Request, []byte, error) {\n\t// 读取和解析原始请求体\n\toriginalBodyBytes, err := io.ReadAll(c.Request.Body)\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"读取请求体失败: %w\", err)\n\t}\n\t// 恢复请求体，以便gin context可以被其他地方再次读取（尽管这里我们已经完全处理了）\n\tc.Request.Body = io.NopCloser(bytes.NewReader(originalBodyBytes))\n\n\tvar claudeReq types.ClaudeRequest\n\tif err := json.Unmarshal(originalBodyBytes, &claudeReq); err != nil {\n\t\treturn nil, originalBodyBytes, fmt.Errorf(\"解析Claude请求体失败: %w\", err)\n\t}\n\n\t// --- 复用旧的转换逻辑 ---\n\topenaiReq := &types.OpenAIRequest{\n\t\tModel:       config.RedirectModel(claudeReq.Model, upstream),\n\t\tMessages:    p.convertMessages(&claudeReq),\n\t\tStream:      claudeReq.Stream,\n\t\tTemperature: claudeReq.Temperature,\n\t}\n\n\tif claudeReq.MaxTokens > 0 {\n\t\topenaiReq.MaxCompletionTokens = claudeReq.MaxTokens\n\t} else {\n\t\topenaiReq.MaxCompletionTokens = 65535\n\t}\n\n\t// 转换工具\n\tif len(claudeReq.Tools) > 0 {\n\t\topenaiReq.Tools = p.convertTools(claudeReq.Tools)\n\t\topenaiReq.ToolChoice = \"auto\"\n\t}\n\t// --- 转换逻辑结束 ---\n\n\treqBodyBytes, err := json.Marshal(openaiReq)\n\tif err != nil {\n\t\treturn nil, originalBodyBytes, fmt.Errorf(\"序列化OpenAI请求体失败: %w\", err)\n\t}\n\n\t// 构建URL - baseURL可能已包含版本号(如/v1, /v2, /v1beta, /v2alpha等),需要智能拼接\n\t// 如果 baseURL 以 # 结尾，则跳过自动添加 /v1\n\tbaseURL := upstream.GetEffectiveBaseURL()\n\tskipVersionPrefix := strings.HasSuffix(baseURL, \"#\")\n\tif skipVersionPrefix {\n\t\tbaseURL = strings.TrimSuffix(baseURL, \"#\")\n\t}\n\tbaseURL = strings.TrimSuffix(baseURL, \"/\")\n\n\t// 检查baseURL是否以版本号结尾(如/v1, /v2, /v1beta, /v2alpha等)\n\t// 使用正则表达式匹配 /v\\d+[a-z]* 的模式(v后跟数字,可选字母后缀)\n\tversionPattern := regexp.MustCompile(`/v\\d+[a-z]*$`)\n\thasVersionSuffix := versionPattern.MatchString(baseURL)\n\n\t// 如果baseURL已经包含版本号或以#结尾,直接拼接/chat/completions\n\t// 否则拼接/v1/chat/completions\n\tendpoint := \"/chat/completions\"\n\tif !hasVersionSuffix && !skipVersionPrefix {\n\t\tendpoint = \"/v1\" + endpoint\n\t}\n\turl := baseURL + endpoint\n\n\treq, err := http.NewRequestWithContext(c.Request.Context(), \"POST\", url, bytes.NewReader(reqBodyBytes))\n\tif err != nil {\n\t\treturn nil, originalBodyBytes, fmt.Errorf(\"创建OpenAI请求失败: %w\", err)\n\t}\n\n\t// 使用统一的头部处理逻辑（透明代理）\n\t// 保留客户端的大部分 headers，只移除/替换必要的认证和代理相关 headers\n\treq.Header = utils.PrepareUpstreamHeaders(c, req.URL.Host)\n\tutils.SetAuthenticationHeader(req.Header, apiKey)\n\n\treturn req, originalBodyBytes, nil\n}\n\n// convertMessages 转换消息\nfunc (p *OpenAIProvider) convertMessages(claudeReq *types.ClaudeRequest) []types.OpenAIMessage {\n\tmessages := []types.OpenAIMessage{}\n\n\t// 添加系统消息\n\tif claudeReq.System != nil {\n\t\tsystemText := extractSystemText(claudeReq.System)\n\t\tif systemText != \"\" {\n\t\t\tmessages = append(messages, types.OpenAIMessage{\n\t\t\t\tRole:    \"system\",\n\t\t\t\tContent: systemText,\n\t\t\t})\n\t\t}\n\t}\n\n\t// 转换普通消息\n\tfor _, msg := range claudeReq.Messages {\n\t\topenaiMsg := p.convertMessage(msg)\n\t\tmessages = append(messages, openaiMsg...)\n\t}\n\n\treturn messages\n}\n\n// convertMessage 转换单个消息\nfunc (p *OpenAIProvider) convertMessage(msg types.ClaudeMessage) []types.OpenAIMessage {\n\tmessages := []types.OpenAIMessage{}\n\n\t// 如果是字符串内容\n\tif str, ok := msg.Content.(string); ok {\n\t\tif msg.Role != \"tool\" {\n\t\t\tmessages = append(messages, types.OpenAIMessage{\n\t\t\t\tRole:    normalizeRole(msg.Role),\n\t\t\t\tContent: str,\n\t\t\t})\n\t\t}\n\t\treturn messages\n\t}\n\n\t// 如果是内容数组\n\tcontents, ok := msg.Content.([]interface{})\n\tif !ok {\n\t\treturn messages\n\t}\n\n\ttextContents := []string{}\n\ttoolCalls := []types.OpenAIToolCall{}\n\ttoolResults := []types.OpenAIMessage{}\n\n\tfor _, c := range contents {\n\t\tcontent, ok := c.(map[string]interface{})\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tcontentType, _ := content[\"type\"].(string)\n\n\t\tswitch contentType {\n\t\tcase \"text\":\n\t\t\tif text, ok := content[\"text\"].(string); ok {\n\t\t\t\ttextContents = append(textContents, text)\n\t\t\t}\n\n\t\tcase \"tool_use\":\n\t\t\tid, _ := content[\"id\"].(string)\n\t\t\tname, _ := content[\"name\"].(string)\n\t\t\tinput := content[\"input\"]\n\n\t\t\tinputJSON, _ := json.Marshal(input)\n\t\t\ttoolCalls = append(toolCalls, types.OpenAIToolCall{\n\t\t\t\tID:   id,\n\t\t\t\tType: \"function\",\n\t\t\t\tFunction: types.OpenAIToolCallFunction{\n\t\t\t\t\tName:      name,\n\t\t\t\t\tArguments: string(inputJSON),\n\t\t\t\t},\n\t\t\t})\n\n\t\tcase \"tool_result\":\n\t\t\ttoolUseID, _ := content[\"tool_use_id\"].(string)\n\t\t\tresultContent := content[\"content\"]\n\n\t\t\tvar contentStr string\n\t\t\tif str, ok := resultContent.(string); ok {\n\t\t\t\tcontentStr = str\n\t\t\t} else {\n\t\t\t\tcontentJSON, _ := json.Marshal(resultContent)\n\t\t\t\tcontentStr = string(contentJSON)\n\t\t\t}\n\n\t\t\ttoolResults = append(toolResults, types.OpenAIMessage{\n\t\t\t\tRole:       \"tool\",\n\t\t\t\tToolCallID: toolUseID,\n\t\t\t\tContent:    contentStr,\n\t\t\t})\n\t\t}\n\t}\n\n\t// 添加工具结果\n\tmessages = append(messages, toolResults...)\n\n\t// 添加文本和工具调用\n\tif len(textContents) > 0 || len(toolCalls) > 0 {\n\t\trole := normalizeRole(msg.Role)\n\t\tif role != \"tool\" {\n\t\t\topenaiMsg := types.OpenAIMessage{\n\t\t\t\tRole: role,\n\t\t\t}\n\n\t\t\tif len(textContents) > 0 {\n\t\t\t\topenaiMsg.Content = strings.Join(textContents, \"\\n\")\n\t\t\t} else {\n\t\t\t\topenaiMsg.Content = nil\n\t\t\t}\n\n\t\t\tif len(toolCalls) > 0 {\n\t\t\t\topenaiMsg.ToolCalls = toolCalls\n\t\t\t}\n\n\t\t\tmessages = append(messages, openaiMsg)\n\t\t}\n\t}\n\n\treturn messages\n}\n\n// convertTools 转换工具\nfunc (p *OpenAIProvider) convertTools(claudeTools []types.ClaudeTool) []types.OpenAITool {\n\ttools := []types.OpenAITool{}\n\n\tfor _, tool := range claudeTools {\n\t\ttools = append(tools, types.OpenAITool{\n\t\t\tType: \"function\",\n\t\t\tFunction: types.OpenAIToolFunction{\n\t\t\t\tName:        tool.Name,\n\t\t\t\tDescription: tool.Description,\n\t\t\t\tParameters:  cleanJsonSchema(tool.InputSchema),\n\t\t\t},\n\t\t})\n\t}\n\n\treturn tools\n}\n\n// cleanJsonSchema 清理 JSON Schema，移除某些上游不支持的字段\nfunc cleanJsonSchema(schema interface{}) interface{} {\n\tif schema == nil {\n\t\treturn schema\n\t}\n\n\t// 如果是 map，递归清理\n\tif schemaMap, ok := schema.(map[string]interface{}); ok {\n\t\tcleaned := make(map[string]interface{})\n\n\t\tfor key, value := range schemaMap {\n\t\t\t// 移除不需要的字段\n\t\t\tif key == \"$schema\" || key == \"title\" || key == \"examples\" || key == \"additionalProperties\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// 移除 format 字段（当类型为 string 时）\n\t\t\tif key == \"format\" {\n\t\t\t\tif schemaType, hasType := schemaMap[\"type\"]; hasType && schemaType == \"string\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t\t// 递归处理嵌套对象\n\t\t\tif key == \"properties\" || key == \"items\" {\n\t\t\t\tcleaned[key] = cleanJsonSchema(value)\n\t\t\t} else if valueMap, isMap := value.(map[string]interface{}); isMap {\n\t\t\t\tcleaned[key] = cleanJsonSchema(valueMap)\n\t\t\t} else if valueSlice, isSlice := value.([]interface{}); isSlice {\n\t\t\t\tcleanedSlice := make([]interface{}, len(valueSlice))\n\t\t\t\tfor i, item := range valueSlice {\n\t\t\t\t\tcleanedSlice[i] = cleanJsonSchema(item)\n\t\t\t\t}\n\t\t\t\tcleaned[key] = cleanedSlice\n\t\t\t} else {\n\t\t\t\tcleaned[key] = value\n\t\t\t}\n\t\t}\n\n\t\treturn cleaned\n\t}\n\n\t// 如果是数组，递归清理每个元素\n\tif schemaSlice, ok := schema.([]interface{}); ok {\n\t\tcleaned := make([]interface{}, len(schemaSlice))\n\t\tfor i, item := range schemaSlice {\n\t\t\tcleaned[i] = cleanJsonSchema(item)\n\t\t}\n\t\treturn cleaned\n\t}\n\n\t// 其他类型直接返回\n\treturn schema\n}\n\n// ConvertToClaudeResponse 转换为 Claude 响应\nfunc (p *OpenAIProvider) ConvertToClaudeResponse(providerResp *types.ProviderResponse) (*types.ClaudeResponse, error) {\n\tvar openaiResp types.OpenAIResponse\n\tif err := json.Unmarshal(providerResp.Body, &openaiResp); err != nil {\n\t\treturn nil, err\n\t}\n\n\tclaudeResp := &types.ClaudeResponse{\n\t\tID:      generateID(),\n\t\tType:    \"message\",\n\t\tRole:    \"assistant\",\n\t\tContent: []types.ClaudeContent{},\n\t}\n\n\tif len(openaiResp.Choices) > 0 {\n\t\tchoice := openaiResp.Choices[0]\n\t\tmsg := choice.Message\n\n\t\t// 添加文本内容\n\t\tif str, ok := msg.Content.(string); ok && str != \"\" {\n\t\t\tclaudeResp.Content = append(claudeResp.Content, types.ClaudeContent{\n\t\t\t\tType: \"text\",\n\t\t\t\tText: str,\n\t\t\t})\n\t\t}\n\n\t\t// 添加工具调用\n\t\tfor _, toolCall := range msg.ToolCalls {\n\t\t\tvar input interface{}\n\t\t\tjson.Unmarshal([]byte(toolCall.Function.Arguments), &input)\n\n\t\t\tclaudeResp.Content = append(claudeResp.Content, types.ClaudeContent{\n\t\t\t\tType:  \"tool_use\",\n\t\t\t\tID:    toolCall.ID,\n\t\t\t\tName:  toolCall.Function.Name,\n\t\t\t\tInput: input,\n\t\t\t})\n\t\t}\n\n\t\t// 设置停止原因\n\t\tif len(msg.ToolCalls) > 0 {\n\t\t\tclaudeResp.StopReason = \"tool_use\"\n\t\t} else if choice.FinishReason == \"length\" {\n\t\t\tclaudeResp.StopReason = \"max_tokens\"\n\t\t} else {\n\t\t\tclaudeResp.StopReason = \"end_turn\"\n\t\t}\n\t}\n\n\t// 添加使用统计\n\tif openaiResp.Usage != nil {\n\t\tclaudeResp.Usage = &types.Usage{\n\t\t\tInputTokens:  openaiResp.Usage.PromptTokens,\n\t\t\tOutputTokens: openaiResp.Usage.CompletionTokens,\n\t\t}\n\t}\n\n\treturn claudeResp, nil\n}\n\n// HandleStreamResponse 处理流式响应\nfunc (p *OpenAIProvider) HandleStreamResponse(body io.ReadCloser) (<-chan string, <-chan error, error) {\n\teventChan := make(chan string, 100)\n\terrChan := make(chan error, 1)\n\n\tgo func() {\n\t\tdefer close(eventChan)\n\t\t// defer close(errChan) // 移除此行，避免竞态条件\n\t\tdefer body.Close()\n\n\t\tscanner := bufio.NewScanner(body)\n\t\t// 设置更大的 buffer (1MB) 以处理大 JSON chunk，避免默认 64KB 限制\n\t\tconst maxScannerBufferSize = 1024 * 1024 // 1MB\n\t\tscanner.Buffer(make([]byte, 0, 64*1024), maxScannerBufferSize)\n\n\t\ttoolUseBlockIndex := 0\n\t\ttoolCallAccumulator := make(map[int]*ToolCallAccumulator)\n\t\ttoolUseStopEmitted := false\n\n\t\t// 文本块状态跟踪\n\t\ttextBlockStarted := false\n\t\ttextBlockIndex := 0\n\n\t\tfor scanner.Scan() {\n\t\t\tline := scanner.Text()\n\t\t\tline = strings.TrimSpace(line)\n\n\t\t\tif line == \"\" || line == \"data: [DONE]\" {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tjsonStr := strings.TrimPrefix(line, \"data: \")\n\n\t\t\tvar chunk map[string]interface{}\n\t\t\tif err := json.Unmarshal([]byte(jsonStr), &chunk); err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// 检查是否有错误\n\t\t\tif errObj, ok := chunk[\"error\"]; ok {\n\t\t\t\terrChan <- fmt.Errorf(\"upstream error: %v\", errObj)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tchoices, ok := chunk[\"choices\"].([]interface{})\n\t\t\tif !ok || len(choices) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tchoice, ok := choices[0].(map[string]interface{})\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tdelta, ok := choice[\"delta\"].(map[string]interface{})\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// 处理文本内容\n\t\t\tif content, ok := delta[\"content\"].(string); ok && content != \"\" {\n\t\t\t\t// 如果是第一个文本块,发送 content_block_start\n\t\t\t\tif !textBlockStarted {\n\t\t\t\t\tstartEvent := map[string]interface{}{\n\t\t\t\t\t\t\"type\":  \"content_block_start\",\n\t\t\t\t\t\t\"index\": textBlockIndex,\n\t\t\t\t\t\t\"content_block\": map[string]string{\n\t\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\t\"text\": \"\",\n\t\t\t\t\t\t},\n\t\t\t\t\t}\n\t\t\t\t\tstartJSON, _ := json.Marshal(startEvent)\n\t\t\t\t\teventChan <- fmt.Sprintf(\"event: content_block_start\\ndata: %s\\n\\n\", startJSON)\n\t\t\t\t\ttextBlockStarted = true\n\t\t\t\t}\n\n\t\t\t\t// 发送 content_block_delta\n\t\t\t\tdeltaEvent := map[string]interface{}{\n\t\t\t\t\t\"type\":  \"content_block_delta\",\n\t\t\t\t\t\"index\": textBlockIndex,\n\t\t\t\t\t\"delta\": map[string]string{\n\t\t\t\t\t\t\"type\": \"text_delta\",\n\t\t\t\t\t\t\"text\": content,\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t\tdeltaJSON, _ := json.Marshal(deltaEvent)\n\t\t\t\teventChan <- fmt.Sprintf(\"event: content_block_delta\\ndata: %s\\n\\n\", deltaJSON)\n\t\t\t}\n\n\t\t\t// 处理工具调用\n\t\t\tif toolCalls, ok := delta[\"tool_calls\"].([]interface{}); ok {\n\t\t\t\t// 如果有文本块正在进行,先关闭它\n\t\t\t\tif textBlockStarted {\n\t\t\t\t\tstopEvent := map[string]interface{}{\n\t\t\t\t\t\t\"type\":  \"content_block_stop\",\n\t\t\t\t\t\t\"index\": textBlockIndex,\n\t\t\t\t\t}\n\t\t\t\t\tstopJSON, _ := json.Marshal(stopEvent)\n\t\t\t\t\teventChan <- fmt.Sprintf(\"event: content_block_stop\\ndata: %s\\n\\n\", stopJSON)\n\t\t\t\t\ttextBlockStarted = false\n\t\t\t\t\ttextBlockIndex++\n\t\t\t\t}\n\n\t\t\t\tfor _, tc := range toolCalls {\n\t\t\t\t\ttoolCall, ok := tc.(map[string]interface{})\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\tindex := 0\n\t\t\t\t\tif idx, ok := toolCall[\"index\"].(float64); ok {\n\t\t\t\t\t\tindex = int(idx)\n\t\t\t\t\t}\n\n\t\t\t\t\t// 获取或创建累加器\n\t\t\t\t\tif _, exists := toolCallAccumulator[index]; !exists {\n\t\t\t\t\t\ttoolCallAccumulator[index] = &ToolCallAccumulator{}\n\t\t\t\t\t}\n\t\t\t\t\tacc := toolCallAccumulator[index]\n\n\t\t\t\t\t// 累积数据\n\t\t\t\t\tif id, ok := toolCall[\"id\"].(string); ok {\n\t\t\t\t\t\tacc.ID = id\n\t\t\t\t\t}\n\n\t\t\t\t\tif function, ok := toolCall[\"function\"].(map[string]interface{}); ok {\n\t\t\t\t\t\tif name, ok := function[\"name\"].(string); ok {\n\t\t\t\t\t\t\tacc.Name = name\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif args, ok := function[\"arguments\"].(string); ok {\n\t\t\t\t\t\t\tacc.Arguments += args\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t// 检查是否完整\n\t\t\t\t\tif acc.ID != \"\" && acc.Name != \"\" && acc.Arguments != \"\" {\n\t\t\t\t\t\tvar args interface{}\n\t\t\t\t\t\tif err := json.Unmarshal([]byte(acc.Arguments), &args); err == nil {\n\t\t\t\t\t\t\tevents := processToolUsePart(acc.ID, acc.Name, args, toolUseBlockIndex)\n\t\t\t\t\t\t\tfor _, event := range events {\n\t\t\t\t\t\t\t\teventChan <- event\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\ttoolUseBlockIndex++\n\t\t\t\t\t\t\tdelete(toolCallAccumulator, index)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// 处理结束原因\n\t\t\tif finishReason, ok := choice[\"finish_reason\"].(string); ok {\n\t\t\t\t// 如果有未关闭的文本块,先关闭它\n\t\t\t\tif textBlockStarted {\n\t\t\t\t\tstopEvent := map[string]interface{}{\n\t\t\t\t\t\t\"type\":  \"content_block_stop\",\n\t\t\t\t\t\t\"index\": textBlockIndex,\n\t\t\t\t\t}\n\t\t\t\t\tstopJSON, _ := json.Marshal(stopEvent)\n\t\t\t\t\teventChan <- fmt.Sprintf(\"event: content_block_stop\\ndata: %s\\n\\n\", stopJSON)\n\t\t\t\t\ttextBlockStarted = false\n\t\t\t\t}\n\n\t\t\t\tif !toolUseStopEmitted && (finishReason == \"tool_calls\" || finishReason == \"function_call\") {\n\t\t\t\t\tevent := map[string]interface{}{\n\t\t\t\t\t\t\"type\": \"message_delta\",\n\t\t\t\t\t\t\"delta\": map[string]string{\n\t\t\t\t\t\t\t\"stop_reason\": \"tool_use\",\n\t\t\t\t\t\t},\n\t\t\t\t\t}\n\t\t\t\t\teventJSON, _ := json.Marshal(event)\n\t\t\t\t\teventChan <- fmt.Sprintf(\"event: message_delta\\ndata: %s\\n\\n\", eventJSON)\n\t\t\t\t\ttoolUseStopEmitted = true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 确保流结束时关闭任何未关闭的文本块\n\t\tif textBlockStarted {\n\t\t\tstopEvent := map[string]interface{}{\n\t\t\t\t\"type\":  \"content_block_stop\",\n\t\t\t\t\"index\": textBlockIndex,\n\t\t\t}\n\t\t\tstopJSON, _ := json.Marshal(stopEvent)\n\t\t\teventChan <- fmt.Sprintf(\"event: content_block_stop\\ndata: %s\\n\\n\", stopJSON)\n\t\t}\n\n\t\tif err := scanner.Err(); err != nil {\n\t\t\t// 在 tool_use 场景下，客户端主动断开是正常行为\n\t\t\t// 如果已经发送了 tool_use stop 事件，并且错误是连接断开相关的，则忽略该错误\n\t\t\terrMsg := err.Error()\n\t\t\tif toolUseStopEmitted && (strings.Contains(errMsg, \"broken pipe\") ||\n\t\t\t\tstrings.Contains(errMsg, \"connection reset\") ||\n\t\t\t\tstrings.Contains(errMsg, \"EOF\")) {\n\t\t\t\t// 这是预期的客户端行为，不报告错误\n\t\t\t\treturn\n\t\t\t}\n\t\t\terrChan <- err\n\t\t}\n\t}()\n\n\treturn eventChan, errChan, nil\n}\n\n// ToolCallAccumulator 工具调用累加器\ntype ToolCallAccumulator struct {\n\tID        string\n\tName      string\n\tArguments string\n}\n\n// processToolUsePart 处理工具使用部分\nfunc processToolUsePart(id, name string, input interface{}, index int) []string {\n\tevents := []string{}\n\n\t// content_block_start\n\tstartEvent := map[string]interface{}{\n\t\t\"type\":  \"content_block_start\",\n\t\t\"index\": index,\n\t\t\"content_block\": map[string]interface{}{\n\t\t\t\"type\": \"tool_use\",\n\t\t\t\"id\":   id,\n\t\t\t\"name\": name,\n\t\t},\n\t}\n\tstartJSON, _ := json.Marshal(startEvent)\n\tevents = append(events, fmt.Sprintf(\"event: content_block_start\\ndata: %s\\n\\n\", startJSON))\n\n\t// content_block_delta\n\tinputJSON, _ := json.Marshal(input)\n\tdeltaEvent := map[string]interface{}{\n\t\t\"type\":  \"content_block_delta\",\n\t\t\"index\": index,\n\t\t\"delta\": map[string]string{\n\t\t\t\"type\":         \"input_json_delta\",\n\t\t\t\"partial_json\": string(inputJSON),\n\t\t},\n\t}\n\tdeltaJSON, _ := json.Marshal(deltaEvent)\n\tevents = append(events, fmt.Sprintf(\"event: content_block_delta\\ndata: %s\\n\\n\", deltaJSON))\n\n\t// content_block_stop\n\tstopEvent := map[string]interface{}{\n\t\t\"type\":  \"content_block_stop\",\n\t\t\"index\": index,\n\t}\n\tstopJSON, _ := json.Marshal(stopEvent)\n\tevents = append(events, fmt.Sprintf(\"event: content_block_stop\\ndata: %s\\n\\n\", stopJSON))\n\n\treturn events\n}\n\n// 辅助函数\n\nfunc extractSystemText(system interface{}) string {\n\tif str, ok := system.(string); ok {\n\t\treturn str\n\t}\n\n\t// 可能是数组\n\tarr, ok := system.([]interface{})\n\tif !ok {\n\t\treturn \"\"\n\t}\n\n\tparts := []string{}\n\tfor _, item := range arr {\n\t\tobj, ok := item.(map[string]interface{})\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tif obj[\"type\"] == \"text\" {\n\t\t\tif text, ok := obj[\"text\"].(string); ok {\n\t\t\t\tparts = append(parts, text)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn strings.Join(parts, \"\\n\")\n}\n\nfunc normalizeRole(role string) string {\n\trole = strings.ToLower(role)\n\tswitch role {\n\tcase \"user\", \"assistant\", \"system\", \"tool\":\n\t\treturn role\n\tdefault:\n\t\treturn \"user\"\n\t}\n}\n\nfunc generateID() string {\n\treturn fmt.Sprintf(\"msg_%d\", time.Now().UnixNano())\n}\n"
  },
  {
    "path": "backend-go/internal/providers/provider.go",
    "content": "package providers\n\nimport (\n\t\"io\"\n\t\"net/http\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// Provider 提供商接口\ntype Provider interface {\n\t// ConvertToProviderRequest 将 gin context 中的请求转换为目标上游的 http.Request，并返回用于日志的原始请求体\n\tConvertToProviderRequest(c *gin.Context, upstream *config.UpstreamConfig, apiKey string) (*http.Request, []byte, error)\n\n\t// ConvertToClaudeResponse 将提供商响应转换为 Claude 响应\n\tConvertToClaudeResponse(providerResp *types.ProviderResponse) (*types.ClaudeResponse, error)\n\n\t// HandleStreamResponse 处理流式响应\n\tHandleStreamResponse(body io.ReadCloser) (<-chan string, <-chan error, error)\n}\n\n// GetProvider 根据服务类型获取提供商\nfunc GetProvider(serviceType string) Provider {\n\tswitch serviceType {\n\tcase \"openai\":\n\t\treturn &OpenAIProvider{}\n\tcase \"gemini\":\n\t\treturn &GeminiProvider{}\n\tcase \"claude\":\n\t\treturn &ClaudeProvider{}\n\tdefault:\n\t\treturn nil\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/providers/request_context_test.go",
    "content": "package providers\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/gin-gonic/gin\"\n)\n\ntype testContextKey string\n\nfunc newGinContext(method, url string, body []byte, ctx context.Context) *gin.Context {\n\tw := httptest.NewRecorder()\n\tc, _ := gin.CreateTestContext(w)\n\n\treq := httptest.NewRequest(method, url, bytes.NewReader(body))\n\tif ctx != nil {\n\t\treq = req.WithContext(ctx)\n\t}\n\tc.Request = req\n\treturn c\n}\n\nfunc TestConvertToProviderRequest_PropagatesContext(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\tkey := testContextKey(\"test-key\")\n\tctx := context.WithValue(context.Background(), key, \"ok\")\n\n\tt.Run(\"claude\", func(t *testing.T) {\n\t\tc := newGinContext(http.MethodPost, \"/v1/messages\", []byte(`{\"model\":\"claude-3\",\"messages\":[]}`), ctx)\n\t\tupstream := &config.UpstreamConfig{BaseURL: \"https://api.example.com\", ServiceType: \"claude\"}\n\n\t\tp := &ClaudeProvider{}\n\t\treq, _, err := p.ConvertToProviderRequest(c, upstream, \"sk-ant-test\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"ConvertToProviderRequest() err = %v\", err)\n\t\t}\n\t\tif got := req.Context().Value(key); got != \"ok\" {\n\t\t\tt.Fatalf(\"req.Context().Value(key) = %v, want %v\", got, \"ok\")\n\t\t}\n\t})\n\n\tt.Run(\"openai\", func(t *testing.T) {\n\t\tc := newGinContext(http.MethodPost, \"/v1/messages\", []byte(`{\"model\":\"gpt-4o\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`), ctx)\n\t\tupstream := &config.UpstreamConfig{BaseURL: \"https://api.example.com\", ServiceType: \"openai\"}\n\n\t\tp := &OpenAIProvider{}\n\t\treq, _, err := p.ConvertToProviderRequest(c, upstream, \"sk-test\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"ConvertToProviderRequest() err = %v\", err)\n\t\t}\n\t\tif got := req.Context().Value(key); got != \"ok\" {\n\t\t\tt.Fatalf(\"req.Context().Value(key) = %v, want %v\", got, \"ok\")\n\t\t}\n\t})\n\n\tt.Run(\"gemini\", func(t *testing.T) {\n\t\tc := newGinContext(http.MethodPost, \"/v1/messages\", []byte(`{\"model\":\"gemini-2.0-flash\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}`), ctx)\n\t\tupstream := &config.UpstreamConfig{BaseURL: \"https://api.example.com\", ServiceType: \"gemini\"}\n\n\t\tp := &GeminiProvider{}\n\t\treq, _, err := p.ConvertToProviderRequest(c, upstream, \"AIza-test\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"ConvertToProviderRequest() err = %v\", err)\n\t\t}\n\t\tif got := req.Context().Value(key); got != \"ok\" {\n\t\t\tt.Fatalf(\"req.Context().Value(key) = %v, want %v\", got, \"ok\")\n\t\t}\n\t})\n\n\tt.Run(\"responses\", func(t *testing.T) {\n\t\tc := newGinContext(http.MethodPost, \"/v1/responses\", []byte(`{\"model\":\"gpt-4o\",\"input\":\"hi\"}`), ctx)\n\t\tupstream := &config.UpstreamConfig{BaseURL: \"https://api.example.com\", ServiceType: \"responses\"}\n\n\t\tp := &ResponsesProvider{}\n\t\treq, _, err := p.ConvertToProviderRequest(c, upstream, \"sk-test\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"ConvertToProviderRequest() err = %v\", err)\n\t\t}\n\t\tif got := req.Context().Value(key); got != \"ok\" {\n\t\t\tt.Fatalf(\"req.Context().Value(key) = %v, want %v\", got, \"ok\")\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "backend-go/internal/providers/responses.go",
    "content": "package providers\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/converters\"\n\t\"github.com/BenedictKing/claude-proxy/internal/session\"\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n\t\"github.com/BenedictKing/claude-proxy/internal/utils\"\n\t\"github.com/gin-gonic/gin\"\n)\n\n// ResponsesProvider Responses API 提供商\ntype ResponsesProvider struct {\n\tSessionManager *session.SessionManager\n}\n\n// ConvertToProviderRequest 将 Responses 请求转换为上游格式\nfunc (p *ResponsesProvider) ConvertToProviderRequest(\n\tc *gin.Context,\n\tupstream *config.UpstreamConfig,\n\tapiKey string,\n) (*http.Request, []byte, error) {\n\t// 1. 读取原始请求体\n\tbodyBytes, err := io.ReadAll(c.Request.Body)\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"读取请求体失败: %w\", err)\n\t}\n\tc.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))\n\n\tvar providerReq interface{}\n\n\t// 2. 使用转换器工厂创建转换器\n\tconverter := converters.NewConverter(upstream.ServiceType)\n\n\t// 3. 判断是否为透传模式\n\tif _, ok := converter.(*converters.ResponsesPassthroughConverter); ok {\n\t\t// [Mode-Passthrough] 透传模式：使用 map 保留所有字段\n\t\tvar reqMap map[string]interface{}\n\t\tif err := json.Unmarshal(bodyBytes, &reqMap); err != nil {\n\t\t\treturn nil, bodyBytes, fmt.Errorf(\"透传模式下解析请求失败: %w\", err)\n\t\t}\n\n\t\t// 只做模型重定向\n\t\tif model, ok := reqMap[\"model\"].(string); ok {\n\t\t\treqMap[\"model\"] = config.RedirectModel(model, upstream)\n\t\t}\n\n\t\tproviderReq = reqMap\n\t} else {\n\t\t// [Mode-Convert] 非透传模式：保持原有逻辑\n\t\tvar responsesReq types.ResponsesRequest\n\t\tif err := json.Unmarshal(bodyBytes, &responsesReq); err != nil {\n\t\t\treturn nil, bodyBytes, fmt.Errorf(\"解析 Responses 请求失败: %w\", err)\n\t\t}\n\n\t\t// 获取或创建会话\n\t\tsess, err := p.SessionManager.GetOrCreateSession(responsesReq.PreviousResponseID)\n\t\tif err != nil {\n\t\t\treturn nil, bodyBytes, fmt.Errorf(\"获取会话失败: %w\", err)\n\t\t}\n\n\t\t// 模型重定向\n\t\tresponsesReq.Model = config.RedirectModel(responsesReq.Model, upstream)\n\n\t\t// 转换请求\n\t\tconvertedReq, err := converter.ToProviderRequest(sess, &responsesReq)\n\t\tif err != nil {\n\t\t\treturn nil, bodyBytes, fmt.Errorf(\"转换请求失败: %w\", err)\n\t\t}\n\t\tproviderReq = convertedReq\n\t}\n\n\t// 4. 序列化请求体（禁用 HTML 转义）\n\treqBody, err := utils.MarshalJSONNoEscape(providerReq)\n\tif err != nil {\n\t\treturn nil, bodyBytes, fmt.Errorf(\"序列化请求失败: %w\", err)\n\t}\n\n\t// 7. 构建 HTTP 请求\n\ttargetURL := p.buildTargetURL(upstream)\n\treq, err := http.NewRequestWithContext(c.Request.Context(), \"POST\", targetURL, bytes.NewReader(reqBody))\n\tif err != nil {\n\t\treturn nil, bodyBytes, err\n\t}\n\n\t// 8. 设置请求头（透明代理）\n\t// 使用统一的头部处理逻辑，保留客户端的大部分 headers\n\treq.Header = utils.PrepareUpstreamHeaders(c, req.URL.Host)\n\n\t// 删除客户端的所有认证头，避免冲突\n\treq.Header.Del(\"authorization\")\n\treq.Header.Del(\"x-api-key\")\n\treq.Header.Del(\"x-goog-api-key\")\n\n\t// 根据 ServiceType 设置对应的认证头\n\tswitch upstream.ServiceType {\n\tcase \"gemini\":\n\t\t// 只有 Gemini 使用特殊的认证头\n\t\tutils.SetGeminiAuthenticationHeader(req.Header, apiKey)\n\tdefault:\n\t\t// claude, responses, openai 等都使用 Authorization: Bearer\n\t\tutils.SetAuthenticationHeader(req.Header, apiKey)\n\t}\n\n\t// 确保 Content-Type 正确\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\treturn req, bodyBytes, nil\n}\n\n// buildTargetURL 根据上游类型构建目标 URL\n// 智能拼接逻辑：\n// 1. 如果 baseURL 以 # 结尾，跳过自动添加 /v1\n// 2. 如果 baseURL 已包含版本号后缀（如 /v1, /v2, /v8, /v1beta），直接拼接端点路径\n// 3. 如果 baseURL 不包含版本号后缀，自动添加 /v1 再拼接端点路径\nfunc (p *ResponsesProvider) buildTargetURL(upstream *config.UpstreamConfig) string {\n\tbaseURL := upstream.BaseURL\n\tskipVersionPrefix := strings.HasSuffix(baseURL, \"#\")\n\tif skipVersionPrefix {\n\t\tbaseURL = strings.TrimSuffix(baseURL, \"#\")\n\t}\n\tbaseURL = strings.TrimSuffix(baseURL, \"/\")\n\n\t// 使用正则表达式检测 baseURL 是否以版本号结尾（/v1, /v2, /v1beta, /v2alpha等）\n\tversionPattern := regexp.MustCompile(`/v\\d+[a-z]*$`)\n\thasVersionSuffix := versionPattern.MatchString(baseURL)\n\n\t// 根据 ServiceType 确定端点路径\n\tvar endpoint string\n\tswitch upstream.ServiceType {\n\tcase \"responses\":\n\t\tendpoint = \"/responses\"\n\tcase \"claude\":\n\t\tendpoint = \"/messages\"\n\tdefault:\n\t\tendpoint = \"/chat/completions\"\n\t}\n\n\t// 如果 baseURL 已包含版本号或以#结尾，直接拼接端点\n\t// 否则添加 /v1 再拼接端点\n\tif hasVersionSuffix || skipVersionPrefix {\n\t\treturn baseURL + endpoint\n\t}\n\treturn baseURL + \"/v1\" + endpoint\n}\n\n// ConvertToClaudeResponse 将上游响应转换为 Responses 格式（实际上不再需要 Claude 格式）\nfunc (p *ResponsesProvider) ConvertToClaudeResponse(providerResp *types.ProviderResponse) (*types.ClaudeResponse, error) {\n\t// 这个方法在 ResponsesHandler 中不会被调用，这里提供兼容性实现\n\treturn nil, fmt.Errorf(\"ResponsesProvider 不支持 ConvertToClaudeResponse\")\n}\n\n// ConvertToResponsesResponse 将上游响应转换为 Responses 格式\nfunc (p *ResponsesProvider) ConvertToResponsesResponse(\n\tproviderResp *types.ProviderResponse,\n\tupstreamType string,\n\tsessionID string,\n) (*types.ResponsesResponse, error) {\n\t// 解析响应体为 map\n\trespMap, err := converters.JSONToMap(providerResp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"解析响应失败: %w\", err)\n\t}\n\n\t// 使用转换器工厂\n\tconverter := converters.NewConverter(upstreamType)\n\treturn converter.FromProviderResponse(respMap, sessionID)\n}\n\n// HandleStreamResponse 处理流式响应（暂不实现）\nfunc (p *ResponsesProvider) HandleStreamResponse(body io.ReadCloser) (<-chan string, <-chan error, error) {\n\treturn nil, nil, fmt.Errorf(\"Responses Provider 暂不支持流式响应\")\n}\n"
  },
  {
    "path": "backend-go/internal/providers/url_builder_test.go",
    "content": "package providers\n\nimport (\n\t\"regexp\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n)\n\n// buildOpenAIURL 模拟 openai.go 中的 URL 构建逻辑\nfunc buildOpenAIURL(baseURL string) string {\n\tskipVersionPrefix := strings.HasSuffix(baseURL, \"#\")\n\tif skipVersionPrefix {\n\t\tbaseURL = strings.TrimSuffix(baseURL, \"#\")\n\t}\n\tbaseURL = strings.TrimSuffix(baseURL, \"/\")\n\n\tversionPattern := regexp.MustCompile(`/v\\d+[a-z]*$`)\n\thasVersionSuffix := versionPattern.MatchString(baseURL)\n\n\tendpoint := \"/chat/completions\"\n\tif !hasVersionSuffix && !skipVersionPrefix {\n\t\tendpoint = \"/v1\" + endpoint\n\t}\n\treturn baseURL + endpoint\n}\n\n// buildClaudeURL 模拟 claude.go 中的 URL 构建逻辑\nfunc buildClaudeURL(baseURL, requestPath string) string {\n\tendpoint := strings.TrimPrefix(requestPath, \"/v1\")\n\tskipVersionPrefix := strings.HasSuffix(baseURL, \"#\")\n\tif skipVersionPrefix {\n\t\tbaseURL = strings.TrimSuffix(baseURL, \"#\")\n\t}\n\tbaseURL = strings.TrimSuffix(baseURL, \"/\")\n\n\tversionPattern := regexp.MustCompile(`/v\\d+[a-z]*$`)\n\tif versionPattern.MatchString(baseURL) || skipVersionPrefix {\n\t\treturn baseURL + endpoint\n\t}\n\treturn baseURL + \"/v1\" + endpoint\n}\n\nfunc TestOpenAIURL_SkipVersionWithHash(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tbaseURL string\n\t\twant    string\n\t}{\n\t\t{\"normal\", \"https://api.openai.com\", \"https://api.openai.com/v1/chat/completions\"},\n\t\t{\"with_v1\", \"https://api.openai.com/v1\", \"https://api.openai.com/v1/chat/completions\"},\n\t\t{\"hash_skip\", \"https://api.example.com#\", \"https://api.example.com/chat/completions\"},\n\t\t{\"hash_with_slash\", \"https://api.example.com/#\", \"https://api.example.com/chat/completions\"},\n\t\t{\"trailing_slash\", \"https://api.example.com/\", \"https://api.example.com/v1/chat/completions\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := buildOpenAIURL(tt.baseURL)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"buildOpenAIURL(%q) = %q, want %q\", tt.baseURL, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestClaudeURL_SkipVersionWithHash(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tbaseURL     string\n\t\trequestPath string\n\t\twant        string\n\t}{\n\t\t{\"normal\", \"https://api.anthropic.com\", \"/v1/messages\", \"https://api.anthropic.com/v1/messages\"},\n\t\t{\"with_v1\", \"https://api.anthropic.com/v1\", \"/v1/messages\", \"https://api.anthropic.com/v1/messages\"},\n\t\t{\"hash_skip\", \"https://api.example.com#\", \"/v1/messages\", \"https://api.example.com/messages\"},\n\t\t{\"hash_with_slash\", \"https://api.example.com/#\", \"/v1/messages\", \"https://api.example.com/messages\"},\n\t\t{\"trailing_slash\", \"https://api.example.com/\", \"/v1/messages\", \"https://api.example.com/v1/messages\"},\n\t\t{\"count_tokens\", \"https://api.example.com#\", \"/v1/messages/count_tokens\", \"https://api.example.com/messages/count_tokens\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := buildClaudeURL(tt.baseURL, tt.requestPath)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"buildClaudeURL(%q, %q) = %q, want %q\", tt.baseURL, tt.requestPath, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildTargetURL_SkipVersionWithHash(t *testing.T) {\n\tp := &ResponsesProvider{}\n\n\ttests := []struct {\n\t\tname        string\n\t\tbaseURL     string\n\t\tserviceType string\n\t\twant        string\n\t}{\n\t\t// 正常情况：自动添加 /v1\n\t\t{\"normal_responses\", \"https://api.example.com\", \"responses\", \"https://api.example.com/v1/responses\"},\n\t\t{\"normal_claude\", \"https://api.example.com\", \"claude\", \"https://api.example.com/v1/messages\"},\n\t\t{\"normal_openai\", \"https://api.example.com\", \"openai\", \"https://api.example.com/v1/chat/completions\"},\n\n\t\t// 已有版本号：不添加 /v1\n\t\t{\"with_version\", \"https://api.example.com/v1\", \"responses\", \"https://api.example.com/v1/responses\"},\n\t\t{\"with_v2\", \"https://api.example.com/v2\", \"openai\", \"https://api.example.com/v2/chat/completions\"},\n\n\t\t// # 结尾：跳过 /v1\n\t\t{\"hash_skip\", \"https://api.example.com#\", \"responses\", \"https://api.example.com/responses\"},\n\t\t{\"hash_skip_claude\", \"https://api.example.com#\", \"claude\", \"https://api.example.com/messages\"},\n\t\t{\"hash_skip_openai\", \"https://api.example.com#\", \"openai\", \"https://api.example.com/chat/completions\"},\n\n\t\t// # 结尾 + 末尾斜杠：正确处理\n\t\t{\"hash_with_slash\", \"https://api.example.com/#\", \"responses\", \"https://api.example.com/responses\"},\n\t\t{\"hash_with_slash_openai\", \"https://api.example.com/#\", \"openai\", \"https://api.example.com/chat/completions\"},\n\n\t\t// 末尾斜杠：正确移除\n\t\t{\"trailing_slash\", \"https://api.example.com/\", \"responses\", \"https://api.example.com/v1/responses\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tupstream := &config.UpstreamConfig{\n\t\t\t\tBaseURL:     tt.baseURL,\n\t\t\t\tServiceType: tt.serviceType,\n\t\t\t}\n\t\t\tgot := p.buildTargetURL(upstream)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"buildTargetURL() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/scheduler/channel_scheduler.go",
    "content": "package scheduler\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"sort\"\n\t\"sync\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/metrics\"\n\t\"github.com/BenedictKing/claude-proxy/internal/session\"\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n\t\"github.com/BenedictKing/claude-proxy/internal/warmup\"\n)\n\n// ChannelScheduler 多渠道调度器\ntype ChannelScheduler struct {\n\tmu                      sync.RWMutex\n\tconfigManager           *config.ConfigManager\n\tmessagesMetricsManager  *metrics.MetricsManager // Messages 渠道指标\n\tresponsesMetricsManager *metrics.MetricsManager // Responses 渠道指标\n\tgeminiMetricsManager    *metrics.MetricsManager // Gemini 渠道指标\n\ttraceAffinity           *session.TraceAffinityManager\n\turlManager              *warmup.URLManager // URL 管理器（非阻塞，动态排序）\n}\n\n// ChannelKind 标识调度器所处理的渠道类型\n// 注意：这里的 kind 与 upstream.ServiceType（openai/claude/gemini）不同，\n// kind 对应的是本代理对外暴露的三类入口：messages / responses / gemini。\ntype ChannelKind string\n\nconst (\n\tChannelKindMessages  ChannelKind = \"messages\"\n\tChannelKindResponses ChannelKind = \"responses\"\n\tChannelKindGemini    ChannelKind = \"gemini\"\n)\n\n// NewChannelScheduler 创建多渠道调度器\nfunc NewChannelScheduler(\n\tcfgManager *config.ConfigManager,\n\tmessagesMetrics *metrics.MetricsManager,\n\tresponsesMetrics *metrics.MetricsManager,\n\tgeminiMetrics *metrics.MetricsManager,\n\ttraceAffinity *session.TraceAffinityManager,\n\turlMgr *warmup.URLManager,\n) *ChannelScheduler {\n\treturn &ChannelScheduler{\n\t\tconfigManager:           cfgManager,\n\t\tmessagesMetricsManager:  messagesMetrics,\n\t\tresponsesMetricsManager: responsesMetrics,\n\t\tgeminiMetricsManager:    geminiMetrics,\n\t\ttraceAffinity:           traceAffinity,\n\t\turlManager:              urlMgr,\n\t}\n}\n\n// getMetricsManager 根据类型获取对应的指标管理器\nfunc (s *ChannelScheduler) getMetricsManager(kind ChannelKind) *metrics.MetricsManager {\n\tswitch kind {\n\tcase ChannelKindResponses:\n\t\treturn s.responsesMetricsManager\n\tcase ChannelKindGemini:\n\t\treturn s.geminiMetricsManager\n\tdefault:\n\t\treturn s.messagesMetricsManager\n\t}\n}\n\n// SelectionResult 渠道选择结果\ntype SelectionResult struct {\n\tUpstream     *config.UpstreamConfig\n\tChannelIndex int\n\tReason       string // 选择原因（用于日志）\n}\n\n// SelectChannel 选择最佳渠道\n// 优先级: 促销期渠道 > Trace亲和（促销渠道失败时回退） > 渠道优先级顺序\nfunc (s *ChannelScheduler) SelectChannel(\n\tctx context.Context,\n\tuserID string,\n\tfailedChannels map[int]bool,\n\tkind ChannelKind,\n) (*SelectionResult, error) {\n\ts.mu.RLock()\n\tdefer s.mu.RUnlock()\n\n\t// 获取活跃渠道列表\n\tactiveChannels := s.getActiveChannels(kind)\n\tif len(activeChannels) == 0 {\n\t\tswitch kind {\n\t\tcase ChannelKindGemini:\n\t\t\treturn nil, fmt.Errorf(\"没有可用的活跃 Gemini 渠道\")\n\t\tcase ChannelKindResponses:\n\t\t\treturn nil, fmt.Errorf(\"没有可用的活跃 Responses 渠道\")\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"没有可用的活跃 Messages 渠道\")\n\t\t}\n\t}\n\n\t// 获取对应类型的指标管理器\n\tmetricsManager := s.getMetricsManager(kind)\n\n\t// 0. 检查促销期渠道（最高优先级，绕过健康检查）\n\tpromotedChannel := s.findPromotedChannel(activeChannels, kind)\n\tif promotedChannel != nil && !failedChannels[promotedChannel.Index] {\n\t\t// 促销渠道存在且未失败，直接使用（不检查健康状态，让用户设置的促销渠道有机会尝试）\n\t\tupstream := s.getUpstreamByIndex(promotedChannel.Index, kind)\n\t\tif upstream != nil && len(upstream.APIKeys) > 0 {\n\t\t\tfailureRate := metricsManager.CalculateChannelFailureRate(upstream.BaseURL, upstream.APIKeys)\n\t\t\tprefix := kindSchedulerLogPrefix(kind)\n\t\t\tlog.Printf(\"[%s-Promotion] 促销期优先选择渠道: [%d] %s (失败率: %.1f%%, 绕过健康检查)\", prefix, promotedChannel.Index, upstream.Name, failureRate*100)\n\t\t\treturn &SelectionResult{\n\t\t\t\tUpstream:     upstream,\n\t\t\t\tChannelIndex: promotedChannel.Index,\n\t\t\t\tReason:       \"promotion_priority\",\n\t\t\t}, nil\n\t\t} else if upstream != nil {\n\t\t\tprefix := kindSchedulerLogPrefix(kind)\n\t\t\tlog.Printf(\"[%s-Promotion] 警告: 促销渠道 [%d] %s 无可用密钥，跳过\", prefix, promotedChannel.Index, upstream.Name)\n\t\t}\n\t} else if promotedChannel != nil {\n\t\tprefix := kindSchedulerLogPrefix(kind)\n\t\tlog.Printf(\"[%s-Promotion] 警告: 促销渠道 [%d] %s 已在本次请求中失败，跳过\", prefix, promotedChannel.Index, promotedChannel.Name)\n\t}\n\n\t// 1. 检查 Trace 亲和性（促销渠道失败时或无促销渠道时）\n\tif userID != \"\" {\n\t\tif preferredIdx, ok := s.traceAffinity.GetPreferredChannel(userID); ok {\n\t\t\tfor _, ch := range activeChannels {\n\t\t\t\tif ch.Index == preferredIdx && !failedChannels[preferredIdx] {\n\t\t\t\t\t// 检查渠道状态：只有 active 状态才使用亲和性\n\t\t\t\t\tif ch.Status != \"active\" {\n\t\t\t\t\t\tprefix := kindSchedulerLogPrefix(kind)\n\t\t\t\t\t\tlog.Printf(\"[%s-Affinity] 跳过亲和渠道 [%d] %s: 状态为 %s (user: %s)\", prefix, preferredIdx, ch.Name, ch.Status, maskUserID(userID))\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\t// 检查渠道是否健康\n\t\t\t\t\tupstream := s.getUpstreamByIndex(preferredIdx, kind)\n\t\t\t\t\tif upstream != nil && metricsManager.IsChannelHealthyWithKeys(upstream.BaseURL, upstream.APIKeys) {\n\t\t\t\t\t\tprefix := kindSchedulerLogPrefix(kind)\n\t\t\t\t\t\tlog.Printf(\"[%s-Affinity] Trace亲和选择渠道: [%d] %s (user: %s)\", prefix, preferredIdx, upstream.Name, maskUserID(userID))\n\t\t\t\t\t\treturn &SelectionResult{\n\t\t\t\t\t\t\tUpstream:     upstream,\n\t\t\t\t\t\t\tChannelIndex: preferredIdx,\n\t\t\t\t\t\t\tReason:       \"trace_affinity\",\n\t\t\t\t\t\t}, nil\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// 2. 按优先级遍历活跃渠道\n\tfor _, ch := range activeChannels {\n\t\t// 跳过本次请求已经失败的渠道\n\t\tif failedChannels[ch.Index] {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 跳过非 active 状态的渠道（suspended 等）\n\t\tif ch.Status != \"active\" {\n\t\t\tprefix := kindSchedulerLogPrefix(kind)\n\t\t\tlog.Printf(\"[%s-Channel] 跳过非活跃渠道: [%d] %s (状态: %s)\", prefix, ch.Index, ch.Name, ch.Status)\n\t\t\tcontinue\n\t\t}\n\n\t\tupstream := s.getUpstreamByIndex(ch.Index, kind)\n\t\tif upstream == nil || len(upstream.APIKeys) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 跳过失败率过高的渠道（已熔断或即将熔断）\n\t\tif !metricsManager.IsChannelHealthyWithKeys(upstream.BaseURL, upstream.APIKeys) {\n\t\t\tfailureRate := metricsManager.CalculateChannelFailureRate(upstream.BaseURL, upstream.APIKeys)\n\t\t\tprefix := kindSchedulerLogPrefix(kind)\n\t\t\tlog.Printf(\"[%s-Channel] 警告: 跳过不健康渠道: [%d] %s (失败率: %.1f%%)\", prefix, ch.Index, ch.Name, failureRate*100)\n\t\t\tcontinue\n\t\t}\n\n\t\tprefix := kindSchedulerLogPrefix(kind)\n\t\tlog.Printf(\"[%s-Channel] 选择渠道: [%d] %s (优先级: %d)\", prefix, ch.Index, upstream.Name, ch.Priority)\n\t\treturn &SelectionResult{\n\t\t\tUpstream:     upstream,\n\t\t\tChannelIndex: ch.Index,\n\t\t\tReason:       \"priority_order\",\n\t\t}, nil\n\t}\n\n\t// 3. 所有健康渠道都失败，选择失败率最低的作为降级\n\treturn s.selectFallbackChannel(activeChannels, failedChannels, kind)\n}\n\n// findPromotedChannel 查找处于促销期的渠道\nfunc (s *ChannelScheduler) findPromotedChannel(activeChannels []ChannelInfo, kind ChannelKind) *ChannelInfo {\n\tfor i := range activeChannels {\n\t\tch := &activeChannels[i]\n\t\tif ch.Status != \"active\" {\n\t\t\tcontinue\n\t\t}\n\t\tupstream := s.getUpstreamByIndex(ch.Index, kind)\n\t\tif upstream != nil {\n\t\t\tif config.IsChannelInPromotion(upstream) {\n\t\t\t\tprefix := kindSchedulerLogPrefix(kind)\n\t\t\t\tlog.Printf(\"[%s-Promotion] 找到促销渠道: [%d] %s (promotionUntil: %v)\", prefix, ch.Index, upstream.Name, upstream.PromotionUntil)\n\t\t\t\treturn ch\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\n// selectFallbackChannel 选择降级渠道（失败率最低的）\nfunc (s *ChannelScheduler) selectFallbackChannel(\n\tactiveChannels []ChannelInfo,\n\tfailedChannels map[int]bool,\n\tkind ChannelKind,\n) (*SelectionResult, error) {\n\tmetricsManager := s.getMetricsManager(kind)\n\tvar bestChannel *ChannelInfo\n\tvar bestUpstream *config.UpstreamConfig\n\tbestFailureRate := float64(2) // 初始化为不可能的值\n\n\tfor i := range activeChannels {\n\t\tch := &activeChannels[i]\n\t\tif failedChannels[ch.Index] {\n\t\t\tcontinue\n\t\t}\n\t\t// 跳过非 active 状态的渠道\n\t\tif ch.Status != \"active\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tupstream := s.getUpstreamByIndex(ch.Index, kind)\n\t\tif upstream == nil || len(upstream.APIKeys) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tfailureRate := metricsManager.CalculateChannelFailureRate(upstream.BaseURL, upstream.APIKeys)\n\t\tif failureRate < bestFailureRate {\n\t\t\tbestFailureRate = failureRate\n\t\t\tbestChannel = ch\n\t\t\tbestUpstream = upstream\n\t\t}\n\t}\n\n\tif bestChannel != nil && bestUpstream != nil {\n\t\tprefix := kindSchedulerLogPrefix(kind)\n\t\tlog.Printf(\"[%s-Fallback] 警告: 降级选择渠道: [%d] %s (失败率: %.1f%%)\",\n\t\t\tprefix, bestChannel.Index, bestUpstream.Name, bestFailureRate*100)\n\t\treturn &SelectionResult{\n\t\t\tUpstream:     bestUpstream,\n\t\t\tChannelIndex: bestChannel.Index,\n\t\t\tReason:       \"fallback\",\n\t\t}, nil\n\t}\n\n\treturn nil, fmt.Errorf(\"所有渠道都不可用\")\n}\n\n// ChannelInfo 渠道信息（用于排序）\ntype ChannelInfo struct {\n\tIndex    int\n\tName     string\n\tPriority int\n\tStatus   string\n}\n\n// getActiveChannels 获取活跃渠道列表（按优先级排序）\nfunc (s *ChannelScheduler) getActiveChannels(kind ChannelKind) []ChannelInfo {\n\tcfg := s.configManager.GetConfig()\n\n\tvar upstreams []config.UpstreamConfig\n\tswitch kind {\n\tcase ChannelKindResponses:\n\t\tupstreams = cfg.ResponsesUpstream\n\tcase ChannelKindGemini:\n\t\tupstreams = cfg.GeminiUpstream\n\tdefault:\n\t\tupstreams = cfg.Upstream\n\t}\n\n\t// 筛选活跃渠道\n\tvar activeChannels []ChannelInfo\n\tfor i, upstream := range upstreams {\n\t\tstatus := upstream.Status\n\t\tif status == \"\" {\n\t\t\tstatus = \"active\" // 默认为活跃\n\t\t}\n\n\t\t// 只选择 active 状态的渠道（suspended 也算在活跃序列中，但会被健康检查过滤）\n\t\tif status != \"disabled\" {\n\t\t\tpriority := upstream.Priority\n\t\t\tif priority == 0 {\n\t\t\t\tpriority = i // 默认优先级为索引\n\t\t\t}\n\n\t\t\tactiveChannels = append(activeChannels, ChannelInfo{\n\t\t\t\tIndex:    i,\n\t\t\t\tName:     upstream.Name,\n\t\t\t\tPriority: priority,\n\t\t\t\tStatus:   status,\n\t\t\t})\n\t\t}\n\t}\n\n\t// 按优先级排序（数字越小优先级越高）\n\tsort.Slice(activeChannels, func(i, j int) bool {\n\t\treturn activeChannels[i].Priority < activeChannels[j].Priority\n\t})\n\n\treturn activeChannels\n}\n\n// getUpstreamByIndex 根据索引获取上游配置\n// 注意：返回的是副本，避免指向 slice 元素的指针在 slice 重分配后失效\nfunc (s *ChannelScheduler) getUpstreamByIndex(index int, kind ChannelKind) *config.UpstreamConfig {\n\tcfg := s.configManager.GetConfig()\n\n\tvar upstreams []config.UpstreamConfig\n\tswitch kind {\n\tcase ChannelKindResponses:\n\t\tupstreams = cfg.ResponsesUpstream\n\tcase ChannelKindGemini:\n\t\tupstreams = cfg.GeminiUpstream\n\tdefault:\n\t\tupstreams = cfg.Upstream\n\t}\n\n\tif index >= 0 && index < len(upstreams) {\n\t\t// 返回副本，避免返回指向 slice 元素的指针\n\t\tupstream := upstreams[index]\n\t\treturn &upstream\n\t}\n\treturn nil\n}\n\n// RecordSuccess 记录渠道成功（使用 baseURL + apiKey）\nfunc (s *ChannelScheduler) RecordSuccess(baseURL, apiKey string, kind ChannelKind) {\n\ts.getMetricsManager(kind).RecordSuccess(baseURL, apiKey)\n}\n\n// RecordSuccessWithUsage 记录渠道成功（带 Usage 数据）\nfunc (s *ChannelScheduler) RecordSuccessWithUsage(baseURL, apiKey string, usage *types.Usage, kind ChannelKind) {\n\ts.getMetricsManager(kind).RecordSuccessWithUsage(baseURL, apiKey, usage)\n}\n\n// RecordFailure 记录渠道失败（使用 baseURL + apiKey）\nfunc (s *ChannelScheduler) RecordFailure(baseURL, apiKey string, kind ChannelKind) {\n\ts.getMetricsManager(kind).RecordFailure(baseURL, apiKey)\n}\n\n// RecordRequestStart 记录请求开始\nfunc (s *ChannelScheduler) RecordRequestStart(baseURL, apiKey string, kind ChannelKind) {\n\ts.getMetricsManager(kind).RecordRequestStart(baseURL, apiKey)\n}\n\n// RecordRequestEnd 记录请求结束\nfunc (s *ChannelScheduler) RecordRequestEnd(baseURL, apiKey string, kind ChannelKind) {\n\ts.getMetricsManager(kind).RecordRequestEnd(baseURL, apiKey)\n}\n\n// SetTraceAffinity 设置 Trace 亲和\nfunc (s *ChannelScheduler) SetTraceAffinity(userID string, channelIndex int) {\n\tif userID != \"\" {\n\t\ts.traceAffinity.SetPreferredChannel(userID, channelIndex)\n\t}\n}\n\n// UpdateTraceAffinity 更新 Trace 亲和时间（续期）\nfunc (s *ChannelScheduler) UpdateTraceAffinity(userID string) {\n\tif userID != \"\" {\n\t\ts.traceAffinity.UpdateLastUsed(userID)\n\t}\n}\n\n// GetMessagesMetricsManager 获取 Messages 渠道指标管理器\nfunc (s *ChannelScheduler) GetMessagesMetricsManager() *metrics.MetricsManager {\n\treturn s.messagesMetricsManager\n}\n\n// GetResponsesMetricsManager 获取 Responses 渠道指标管理器\nfunc (s *ChannelScheduler) GetResponsesMetricsManager() *metrics.MetricsManager {\n\treturn s.responsesMetricsManager\n}\n\n// GetGeminiMetricsManager 获取 Gemini 渠道指标管理器\nfunc (s *ChannelScheduler) GetGeminiMetricsManager() *metrics.MetricsManager {\n\treturn s.geminiMetricsManager\n}\n\n// GetTraceAffinityManager 获取 Trace 亲和性管理器\nfunc (s *ChannelScheduler) GetTraceAffinityManager() *session.TraceAffinityManager {\n\treturn s.traceAffinity\n}\n\n// ResetChannelMetrics 重置渠道所有 Key 的熔断/失败状态（保留历史统计）\n// 用于：1) 手动恢复熔断 2) 更换 API Key 后重置熔断状态\nfunc (s *ChannelScheduler) ResetChannelMetrics(channelIndex int, kind ChannelKind) {\n\tupstream := s.getUpstreamByIndex(channelIndex, kind)\n\tif upstream == nil {\n\t\treturn\n\t}\n\tmetricsManager := s.getMetricsManager(kind)\n\tfor _, baseURL := range upstream.GetAllBaseURLs() {\n\t\tfor _, apiKey := range upstream.APIKeys {\n\t\t\tmetricsManager.ResetKeyFailureState(baseURL, apiKey)\n\t\t}\n\t}\n\tprefix := kindSchedulerLogPrefix(kind)\n\tlog.Printf(\"[%s-Reset] 渠道 [%d] %s 的熔断状态已重置（保留历史统计）\", prefix, channelIndex, upstream.Name)\n}\n\n// ResetKeyMetrics 重置单个 Key 的指标\nfunc (s *ChannelScheduler) ResetKeyMetrics(baseURL, apiKey string, kind ChannelKind) {\n\ts.getMetricsManager(kind).ResetKey(baseURL, apiKey)\n}\n\n// DeleteChannelMetrics 删除渠道的所有指标数据（内存 + 持久化）\n// 用于删除渠道时清理相关的统计数据\nfunc (s *ChannelScheduler) DeleteChannelMetrics(upstream *config.UpstreamConfig, kind ChannelKind) {\n\tif upstream == nil {\n\t\treturn\n\t}\n\tmetricsManager := s.getMetricsManager(kind)\n\t// 合并活跃 Key 和历史 Key，一起清理\n\tallKeys := append([]string{}, upstream.APIKeys...)\n\tallKeys = append(allKeys, upstream.HistoricalAPIKeys...)\n\t// MetricsManager 内部已有 apiType，无需外部传递\n\tmetricsManager.DeleteChannelMetrics(upstream.GetAllBaseURLs(), allKeys)\n\tprefix := kindSchedulerLogPrefix(kind)\n\tlog.Printf(\"[%s-Delete] 渠道 %s 的指标数据已清理\", prefix, upstream.Name)\n}\n\n// GetActiveChannelCount 获取活跃渠道数量\nfunc (s *ChannelScheduler) GetActiveChannelCount(kind ChannelKind) int {\n\treturn len(s.getActiveChannels(kind))\n}\n\n// IsMultiChannelMode 判断是否为多渠道模式\nfunc (s *ChannelScheduler) IsMultiChannelMode(kind ChannelKind) bool {\n\treturn s.GetActiveChannelCount(kind) > 1\n}\n\n// maskUserID 掩码 user_id（保护隐私）\nfunc maskUserID(userID string) string {\n\tif len(userID) <= 16 {\n\t\treturn \"***\"\n\t}\n\treturn userID[:8] + \"***\" + userID[len(userID)-4:]\n}\n\n// GetSortedURLsForChannel 获取渠道排序后的 URL 列表（非阻塞，立即返回）\n// 返回按动态排序的 URL 结果列表，包含原始索引用于指标记录\nfunc (s *ChannelScheduler) GetSortedURLsForChannel(\n\tkind ChannelKind,\n\tchannelIndex int,\n\turls []string,\n) []warmup.URLLatencyResult {\n\tif s.urlManager == nil || len(urls) <= 1 {\n\t\t// 无 URL 管理器或单 URL，返回默认结果\n\t\tresults := make([]warmup.URLLatencyResult, len(urls))\n\t\tfor i, url := range urls {\n\t\t\tresults[i] = warmup.URLLatencyResult{\n\t\t\t\tURL:         url,\n\t\t\t\tOriginalIdx: i,\n\t\t\t\tSuccess:     true,\n\t\t\t}\n\t\t}\n\t\treturn results\n\t}\n\treturn s.urlManager.GetSortedURLs(urlManagerChannelKey(kind, channelIndex), urls)\n}\n\n// MarkURLSuccess 标记 URL 成功\nfunc (s *ChannelScheduler) MarkURLSuccess(kind ChannelKind, channelIndex int, url string) {\n\tif s.urlManager != nil {\n\t\ts.urlManager.MarkSuccess(urlManagerChannelKey(kind, channelIndex), url)\n\t}\n}\n\n// MarkURLFailure 标记 URL 失败，触发动态排序\nfunc (s *ChannelScheduler) MarkURLFailure(kind ChannelKind, channelIndex int, url string) {\n\tif s.urlManager != nil {\n\t\ts.urlManager.MarkFailure(urlManagerChannelKey(kind, channelIndex), url)\n\t}\n}\n\n// InvalidateURLCache 使渠道 URL 状态失效\nfunc (s *ChannelScheduler) InvalidateURLCache(kind ChannelKind, channelIndex int) {\n\tif s.urlManager != nil {\n\t\ts.urlManager.InvalidateChannel(urlManagerChannelKey(kind, channelIndex))\n\t}\n}\n\n// GetURLManagerStats 获取 URL 管理器统计\nfunc (s *ChannelScheduler) GetURLManagerStats() map[string]interface{} {\n\tif s.urlManager != nil {\n\t\treturn s.urlManager.GetStats()\n\t}\n\treturn nil\n}\n\nfunc kindSchedulerLogPrefix(kind ChannelKind) string {\n\tswitch kind {\n\tcase ChannelKindResponses:\n\t\treturn \"Scheduler-Responses\"\n\tcase ChannelKindGemini:\n\t\treturn \"Scheduler-Gemini\"\n\tdefault:\n\t\treturn \"Scheduler\"\n\t}\n}\n\nfunc urlManagerChannelKey(kind ChannelKind, channelIndex int) int {\n\tconst stride = 1_000_000\n\treturn urlManagerChannelKeyOrdinal(kind)*stride + channelIndex\n}\n\nfunc urlManagerChannelKeyOrdinal(kind ChannelKind) int {\n\tswitch kind {\n\tcase ChannelKindResponses:\n\t\treturn 1\n\tcase ChannelKindGemini:\n\t\treturn 2\n\tdefault:\n\t\treturn 0\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/scheduler/channel_scheduler_test.go",
    "content": "package scheduler\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/metrics\"\n\t\"github.com/BenedictKing/claude-proxy/internal/session\"\n\t\"github.com/BenedictKing/claude-proxy/internal/warmup\"\n)\n\n// createTestConfigManager 创建测试用配置管理器\nfunc createTestConfigManager(t *testing.T, cfg config.Config) (*config.ConfigManager, func()) {\n\tt.Helper()\n\n\t// 创建临时目录\n\ttmpDir, err := os.MkdirTemp(\"\", \"scheduler-test-*\")\n\tif err != nil {\n\t\tt.Fatalf(\"创建临时目录失败: %v\", err)\n\t}\n\n\t// 创建临时配置文件\n\tconfigFile := filepath.Join(tmpDir, \"config.json\")\n\tdata, err := json.MarshalIndent(cfg, \"\", \"  \")\n\tif err != nil {\n\t\tos.RemoveAll(tmpDir)\n\t\tt.Fatalf(\"序列化配置失败: %v\", err)\n\t}\n\n\tif err := os.WriteFile(configFile, data, 0644); err != nil {\n\t\tos.RemoveAll(tmpDir)\n\t\tt.Fatalf(\"写入配置文件失败: %v\", err)\n\t}\n\n\t// 创建配置管理器\n\tcfgManager, err := config.NewConfigManager(configFile)\n\tif err != nil {\n\t\tos.RemoveAll(tmpDir)\n\t\tt.Fatalf(\"创建配置管理器失败: %v\", err)\n\t}\n\n\tcleanup := func() {\n\t\tcfgManager.Close()\n\t\tos.RemoveAll(tmpDir)\n\t}\n\n\treturn cfgManager, cleanup\n}\n\n// createTestScheduler 创建测试用调度器\nfunc createTestScheduler(t *testing.T, cfg config.Config) (*ChannelScheduler, func()) {\n\tt.Helper()\n\n\tcfgManager, cleanup := createTestConfigManager(t, cfg)\n\tmessagesMetrics := metrics.NewMetricsManager()\n\tresponsesMetrics := metrics.NewMetricsManager()\n\tgeminiMetrics := metrics.NewMetricsManager()\n\ttraceAffinity := session.NewTraceAffinityManager()\n\turlManager := warmup.NewURLManager(30*time.Second, 3)\n\n\tscheduler := NewChannelScheduler(cfgManager, messagesMetrics, responsesMetrics, geminiMetrics, traceAffinity, urlManager)\n\n\treturn scheduler, func() {\n\t\tmessagesMetrics.Stop()\n\t\tresponsesMetrics.Stop()\n\t\tgeminiMetrics.Stop()\n\t\tcleanup()\n\t}\n}\n\n// TestPromotedChannelBypassesHealthCheck 测试促销渠道绕过健康检查\nfunc TestPromotedChannelBypassesHealthCheck(t *testing.T) {\n\t// 设置促销截止时间为 5 分钟后\n\tpromotionUntil := time.Now().Add(5 * time.Minute)\n\n\tcfg := config.Config{\n\t\tUpstream: []config.UpstreamConfig{\n\t\t\t{\n\t\t\t\tName:     \"normal-channel\",\n\t\t\t\tBaseURL:  \"https://normal.example.com\",\n\t\t\t\tAPIKeys:  []string{\"sk-normal-key\"},\n\t\t\t\tStatus:   \"active\",\n\t\t\t\tPriority: 1,\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:           \"promoted-channel\",\n\t\t\t\tBaseURL:        \"https://promoted.example.com\",\n\t\t\t\tAPIKeys:        []string{\"sk-promoted-key\"},\n\t\t\t\tStatus:         \"active\",\n\t\t\t\tPriority:       2,\n\t\t\t\tPromotionUntil: &promotionUntil,\n\t\t\t},\n\t\t},\n\t}\n\n\tscheduler, cleanup := createTestScheduler(t, cfg)\n\tdefer cleanup()\n\n\t// 模拟促销渠道之前有高失败率（使其不健康）\n\tmetricsManager := scheduler.messagesMetricsManager\n\tfor i := 0; i < 10; i++ {\n\t\tmetricsManager.RecordFailure(\"https://promoted.example.com\", \"sk-promoted-key\")\n\t}\n\n\t// 验证促销渠道确实不健康\n\tisHealthy := metricsManager.IsChannelHealthyWithKeys(\"https://promoted.example.com\", []string{\"sk-promoted-key\"})\n\tif isHealthy {\n\t\tt.Fatal(\"促销渠道应该被标记为不健康\")\n\t}\n\n\t// 选择渠道 - 促销渠道应该被选中，即使它不健康\n\tresult, err := scheduler.SelectChannel(context.Background(), \"test-user\", make(map[int]bool), ChannelKindMessages)\n\tif err != nil {\n\t\tt.Fatalf(\"选择渠道失败: %v\", err)\n\t}\n\n\tif result.ChannelIndex != 1 {\n\t\tt.Errorf(\"期望选择促销渠道 (index=1)，实际选择了 index=%d\", result.ChannelIndex)\n\t}\n\n\tif result.Reason != \"promotion_priority\" {\n\t\tt.Errorf(\"期望选择原因为 promotion_priority，实际为 %s\", result.Reason)\n\t}\n\n\tif result.Upstream.Name != \"promoted-channel\" {\n\t\tt.Errorf(\"期望选择 promoted-channel，实际选择了 %s\", result.Upstream.Name)\n\t}\n}\n\n// TestPromotedChannelSkippedAfterFailure 测试促销渠道在本次请求失败后被跳过\nfunc TestPromotedChannelSkippedAfterFailure(t *testing.T) {\n\tpromotionUntil := time.Now().Add(5 * time.Minute)\n\n\tcfg := config.Config{\n\t\tUpstream: []config.UpstreamConfig{\n\t\t\t{\n\t\t\t\tName:     \"normal-channel\",\n\t\t\t\tBaseURL:  \"https://normal.example.com\",\n\t\t\t\tAPIKeys:  []string{\"sk-normal-key\"},\n\t\t\t\tStatus:   \"active\",\n\t\t\t\tPriority: 1,\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:           \"promoted-channel\",\n\t\t\t\tBaseURL:        \"https://promoted.example.com\",\n\t\t\t\tAPIKeys:        []string{\"sk-promoted-key\"},\n\t\t\t\tStatus:         \"active\",\n\t\t\t\tPriority:       2,\n\t\t\t\tPromotionUntil: &promotionUntil,\n\t\t\t},\n\t\t},\n\t}\n\n\tscheduler, cleanup := createTestScheduler(t, cfg)\n\tdefer cleanup()\n\n\t// 模拟促销渠道在本次请求中已经失败\n\tfailedChannels := map[int]bool{\n\t\t1: true, // 促销渠道已失败\n\t}\n\n\t// 选择渠道 - 应该跳过促销渠道，选择正常渠道\n\tresult, err := scheduler.SelectChannel(context.Background(), \"test-user\", failedChannels, ChannelKindMessages)\n\tif err != nil {\n\t\tt.Fatalf(\"选择渠道失败: %v\", err)\n\t}\n\n\tif result.ChannelIndex != 0 {\n\t\tt.Errorf(\"期望选择正常渠道 (index=0)，实际选择了 index=%d\", result.ChannelIndex)\n\t}\n\n\tif result.Upstream.Name != \"normal-channel\" {\n\t\tt.Errorf(\"期望选择 normal-channel，实际选择了 %s\", result.Upstream.Name)\n\t}\n}\n\n// TestNonPromotedChannelStillChecksHealth 测试非促销渠道仍然检查健康状态\nfunc TestNonPromotedChannelStillChecksHealth(t *testing.T) {\n\tcfg := config.Config{\n\t\tUpstream: []config.UpstreamConfig{\n\t\t\t{\n\t\t\t\tName:     \"unhealthy-channel\",\n\t\t\t\tBaseURL:  \"https://unhealthy.example.com\",\n\t\t\t\tAPIKeys:  []string{\"sk-unhealthy-key\"},\n\t\t\t\tStatus:   \"active\",\n\t\t\t\tPriority: 1,\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:     \"healthy-channel\",\n\t\t\t\tBaseURL:  \"https://healthy.example.com\",\n\t\t\t\tAPIKeys:  []string{\"sk-healthy-key\"},\n\t\t\t\tStatus:   \"active\",\n\t\t\t\tPriority: 2,\n\t\t\t},\n\t\t},\n\t}\n\n\tscheduler, cleanup := createTestScheduler(t, cfg)\n\tdefer cleanup()\n\n\t// 模拟第一个渠道不健康\n\tmetricsManager := scheduler.messagesMetricsManager\n\tfor i := 0; i < 10; i++ {\n\t\tmetricsManager.RecordFailure(\"https://unhealthy.example.com\", \"sk-unhealthy-key\")\n\t}\n\n\t// 选择渠道 - 应该跳过不健康的渠道，选择健康的渠道\n\tresult, err := scheduler.SelectChannel(context.Background(), \"test-user\", make(map[int]bool), ChannelKindMessages)\n\tif err != nil {\n\t\tt.Fatalf(\"选择渠道失败: %v\", err)\n\t}\n\n\tif result.ChannelIndex != 1 {\n\t\tt.Errorf(\"期望选择健康渠道 (index=1)，实际选择了 index=%d\", result.ChannelIndex)\n\t}\n\n\tif result.Upstream.Name != \"healthy-channel\" {\n\t\tt.Errorf(\"期望选择 healthy-channel，实际选择了 %s\", result.Upstream.Name)\n\t}\n}\n\n// TestExpiredPromotionNotBypassHealthCheck 测试过期的促销不绕过健康检查\nfunc TestExpiredPromotionNotBypassHealthCheck(t *testing.T) {\n\t// 设置促销截止时间为过去\n\tpromotionUntil := time.Now().Add(-5 * time.Minute)\n\n\tcfg := config.Config{\n\t\tUpstream: []config.UpstreamConfig{\n\t\t\t{\n\t\t\t\tName:     \"healthy-channel\",\n\t\t\t\tBaseURL:  \"https://healthy.example.com\",\n\t\t\t\tAPIKeys:  []string{\"sk-healthy-key\"},\n\t\t\t\tStatus:   \"active\",\n\t\t\t\tPriority: 1,\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:           \"expired-promoted-channel\",\n\t\t\t\tBaseURL:        \"https://expired.example.com\",\n\t\t\t\tAPIKeys:        []string{\"sk-expired-key\"},\n\t\t\t\tStatus:         \"active\",\n\t\t\t\tPriority:       2,\n\t\t\t\tPromotionUntil: &promotionUntil, // 已过期\n\t\t\t},\n\t\t},\n\t}\n\n\tscheduler, cleanup := createTestScheduler(t, cfg)\n\tdefer cleanup()\n\n\t// 模拟过期促销渠道不健康\n\tmetricsManager := scheduler.messagesMetricsManager\n\tfor i := 0; i < 10; i++ {\n\t\tmetricsManager.RecordFailure(\"https://expired.example.com\", \"sk-expired-key\")\n\t}\n\n\t// 选择渠道 - 过期促销渠道不应该被优先选择，应该选择健康的渠道\n\tresult, err := scheduler.SelectChannel(context.Background(), \"test-user\", make(map[int]bool), ChannelKindMessages)\n\tif err != nil {\n\t\tt.Fatalf(\"选择渠道失败: %v\", err)\n\t}\n\n\tif result.ChannelIndex != 0 {\n\t\tt.Errorf(\"期望选择健康渠道 (index=0)，实际选择了 index=%d\", result.ChannelIndex)\n\t}\n\n\tif result.Upstream.Name != \"healthy-channel\" {\n\t\tt.Errorf(\"期望选择 healthy-channel，实际选择了 %s\", result.Upstream.Name)\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/session/manager.go",
    "content": "package session\n\nimport (\n\t\"crypto/rand\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"log\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n)\n\n// Session 会话数据结构\ntype Session struct {\n\tID             string                // sess_xxxxx\n\tMessages       []types.ResponsesItem // 完整对话历史\n\tLastResponseID string                // 最后一个 response ID\n\tCreatedAt      time.Time\n\tLastAccessAt   time.Time\n\tTotalTokens    int\n}\n\n// SessionManager 会话管理器\ntype SessionManager struct {\n\tsessions        map[string]*Session // sessionID → Session\n\tresponseMapping map[string]string   // responseID → sessionID\n\tmu              sync.RWMutex\n\n\t// 清理配置\n\tmaxAge      time.Duration // 24小时\n\tmaxMessages int           // 100条\n\tmaxTokens   int           // 100k\n}\n\n// NewSessionManager 创建会话管理器\nfunc NewSessionManager(maxAge time.Duration, maxMessages int, maxTokens int) *SessionManager {\n\tsm := &SessionManager{\n\t\tsessions:        make(map[string]*Session),\n\t\tresponseMapping: make(map[string]string),\n\t\tmaxAge:          maxAge,\n\t\tmaxMessages:     maxMessages,\n\t\tmaxTokens:       maxTokens,\n\t}\n\n\t// 启动定期清理\n\tgo sm.cleanupLoop()\n\n\treturn sm\n}\n\n// GetOrCreateSession 获取或创建会话\nfunc (sm *SessionManager) GetOrCreateSession(previousResponseID string) (*Session, error) {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\n\t// 如果提供了 previousResponseID，尝试查找对应的会话\n\tif previousResponseID != \"\" {\n\t\tif sessionID, ok := sm.responseMapping[previousResponseID]; ok {\n\t\t\tif session, exists := sm.sessions[sessionID]; exists {\n\t\t\t\tsession.LastAccessAt = time.Now()\n\t\t\t\treturn session, nil\n\t\t\t}\n\t\t}\n\t\t// 如果找不到对应会话，返回错误\n\t\treturn nil, fmt.Errorf(\"无效的 previous_response_id: %s\", previousResponseID)\n\t}\n\n\t// 创建新会话\n\tsessionID := generateID(\"sess\")\n\tsession := &Session{\n\t\tID:           sessionID,\n\t\tMessages:     []types.ResponsesItem{},\n\t\tCreatedAt:    time.Now(),\n\t\tLastAccessAt: time.Now(),\n\t\tTotalTokens:  0,\n\t}\n\n\tsm.sessions[sessionID] = session\n\tlog.Printf(\"[Session-Create] 创建新会话: %s\", sessionID)\n\n\treturn session, nil\n}\n\n// RecordResponseMapping 记录 responseID 到 sessionID 的映射\nfunc (sm *SessionManager) RecordResponseMapping(responseID, sessionID string) {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\n\tsm.responseMapping[responseID] = sessionID\n\tlog.Printf(\"[Session-Mapping] 记录映射: %s -> %s\", responseID, sessionID)\n}\n\n// AppendMessage 追加消息到会话\nfunc (sm *SessionManager) AppendMessage(sessionID string, item types.ResponsesItem, tokensUsed int) error {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\n\tsession, exists := sm.sessions[sessionID]\n\tif !exists {\n\t\treturn fmt.Errorf(\"会话不存在: %s\", sessionID)\n\t}\n\n\tsession.Messages = append(session.Messages, item)\n\tsession.TotalTokens += tokensUsed\n\tsession.LastAccessAt = time.Now()\n\n\treturn nil\n}\n\n// UpdateLastResponseID 更新会话的最后一个 responseID\nfunc (sm *SessionManager) UpdateLastResponseID(sessionID, responseID string) error {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\n\tsession, exists := sm.sessions[sessionID]\n\tif !exists {\n\t\treturn fmt.Errorf(\"会话不存在: %s\", sessionID)\n\t}\n\n\tsession.LastResponseID = responseID\n\treturn nil\n}\n\n// GetSession 获取会话（只读）\nfunc (sm *SessionManager) GetSession(sessionID string) (*Session, error) {\n\tsm.mu.RLock()\n\tdefer sm.mu.RUnlock()\n\n\tsession, exists := sm.sessions[sessionID]\n\tif !exists {\n\t\treturn nil, fmt.Errorf(\"会话不存在: %s\", sessionID)\n\t}\n\n\treturn session, nil\n}\n\n// cleanupLoop 定期清理过期会话\nfunc (sm *SessionManager) cleanupLoop() {\n\tticker := time.NewTicker(5 * time.Minute)\n\tdefer ticker.Stop()\n\n\tfor range ticker.C {\n\t\tsm.cleanup()\n\t}\n}\n\n// cleanup 执行清理逻辑\nfunc (sm *SessionManager) cleanup() {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\n\tnow := time.Now()\n\tremovedSessions := 0\n\tremovedMappings := 0\n\n\t// 清理过期会话\n\tfor sessionID, session := range sm.sessions {\n\t\tshouldRemove := false\n\n\t\t// 时间过期\n\t\tif now.Sub(session.LastAccessAt) > sm.maxAge {\n\t\t\tshouldRemove = true\n\t\t\tlog.Printf(\"[Session-Cleanup] 清理过期会话 (时间): %s (最后访问: %v 前)\", sessionID, now.Sub(session.LastAccessAt))\n\t\t}\n\n\t\t// 消息数超限\n\t\tif len(session.Messages) > sm.maxMessages {\n\t\t\tshouldRemove = true\n\t\t\tlog.Printf(\"[Session-Cleanup] 清理过期会话 (消息数): %s (%d 条)\", sessionID, len(session.Messages))\n\t\t}\n\n\t\t// Token 超限\n\t\tif session.TotalTokens > sm.maxTokens {\n\t\t\tshouldRemove = true\n\t\t\tlog.Printf(\"[Session-Cleanup] 清理过期会话 (Token): %s (%d tokens)\", sessionID, session.TotalTokens)\n\t\t}\n\n\t\tif shouldRemove {\n\t\t\tdelete(sm.sessions, sessionID)\n\t\t\tremovedSessions++\n\t\t}\n\t}\n\n\t// 清理孤立的 responseID 映射\n\tfor responseID, sessionID := range sm.responseMapping {\n\t\tif _, exists := sm.sessions[sessionID]; !exists {\n\t\t\tdelete(sm.responseMapping, responseID)\n\t\t\tremovedMappings++\n\t\t}\n\t}\n\n\tif removedSessions > 0 || removedMappings > 0 {\n\t\tlog.Printf(\"[Session-Cleanup] 清理完成: 删除 %d 个会话, %d 个映射\", removedSessions, removedMappings)\n\t\tlog.Printf(\"[Session-Stats] 当前活跃会话: %d 个, 映射: %d 个\", len(sm.sessions), len(sm.responseMapping))\n\t}\n}\n\n// GetStats 获取统计信息\nfunc (sm *SessionManager) GetStats() map[string]interface{} {\n\tsm.mu.RLock()\n\tdefer sm.mu.RUnlock()\n\n\treturn map[string]interface{}{\n\t\t\"total_sessions\": len(sm.sessions),\n\t\t\"total_mappings\": len(sm.responseMapping),\n\t}\n}\n\n// generateID 生成唯一ID\nfunc generateID(prefix string) string {\n\tbytes := make([]byte, 16)\n\tif _, err := rand.Read(bytes); err != nil {\n\t\t// 降级方案：使用时间戳\n\t\treturn fmt.Sprintf(\"%s_%d\", prefix, time.Now().UnixNano())\n\t}\n\treturn fmt.Sprintf(\"%s_%s\", prefix, hex.EncodeToString(bytes))\n}\n"
  },
  {
    "path": "backend-go/internal/session/trace_affinity.go",
    "content": "package session\n\nimport (\n\t\"log\"\n\t\"os\"\n\t\"sync\"\n\t\"time\"\n)\n\n// affinityDebug 控制亲和性日志是否输出\n// 通过环境变量 AFFINITY_DEBUG=true 启用\nvar affinityDebug = os.Getenv(\"AFFINITY_DEBUG\") == \"true\"\n\n// TraceAffinity 记录 trace 与渠道的亲和关系\ntype TraceAffinity struct {\n\tChannelIndex int\n\tLastUsedAt   time.Time\n}\n\n// TraceAffinityManager 管理 trace 与渠道的亲和性\ntype TraceAffinityManager struct {\n\tmu       sync.RWMutex\n\taffinity map[string]*TraceAffinity // key: user_id\n\tttl      time.Duration\n\tstopCh   chan struct{} // 用于停止清理 goroutine\n}\n\n// NewTraceAffinityManager 创建 Trace 亲和性管理器\nfunc NewTraceAffinityManager() *TraceAffinityManager {\n\tmgr := &TraceAffinityManager{\n\t\taffinity: make(map[string]*TraceAffinity),\n\t\tttl:      30 * time.Minute, // 默认 30 分钟无活动后过期\n\t\tstopCh:   make(chan struct{}),\n\t}\n\n\t// 启动定期清理\n\tgo mgr.cleanupLoop()\n\n\treturn mgr\n}\n\n// NewTraceAffinityManagerWithTTL 创建带自定义 TTL 的管理器\nfunc NewTraceAffinityManagerWithTTL(ttl time.Duration) *TraceAffinityManager {\n\tif ttl <= 0 {\n\t\tttl = 30 * time.Minute\n\t}\n\n\tmgr := &TraceAffinityManager{\n\t\taffinity: make(map[string]*TraceAffinity),\n\t\tttl:      ttl,\n\t\tstopCh:   make(chan struct{}),\n\t}\n\n\tgo mgr.cleanupLoop()\n\n\treturn mgr\n}\n\n// GetPreferredChannel 获取 user_id 偏好的渠道\n// 返回渠道索引和是否存在\nfunc (m *TraceAffinityManager) GetPreferredChannel(userID string) (int, bool) {\n\tif userID == \"\" {\n\t\treturn -1, false\n\t}\n\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\taffinity, exists := m.affinity[userID]\n\tif !exists {\n\t\treturn -1, false\n\t}\n\n\t// 检查是否过期\n\tif time.Since(affinity.LastUsedAt) > m.ttl {\n\t\treturn -1, false\n\t}\n\n\treturn affinity.ChannelIndex, true\n}\n\n// SetPreferredChannel 设置 user_id 偏好的渠道\nfunc (m *TraceAffinityManager) SetPreferredChannel(userID string, channelIndex int) {\n\tif userID == \"\" {\n\t\treturn\n\t}\n\n\tvar logType int // 0=无, 1=新建, 2=变更\n\tvar oldChannel int\n\n\tm.mu.Lock()\n\toldAffinity, existed := m.affinity[userID]\n\tif existed && oldAffinity.ChannelIndex != channelIndex {\n\t\tlogType, oldChannel = 2, oldAffinity.ChannelIndex\n\t} else if !existed {\n\t\tlogType = 1\n\t}\n\tm.affinity[userID] = &TraceAffinity{\n\t\tChannelIndex: channelIndex,\n\t\tLastUsedAt:   time.Now(),\n\t}\n\tm.mu.Unlock()\n\n\tif affinityDebug {\n\t\tif logType == 2 {\n\t\t\tlog.Printf(\"[Affinity-Set] 用户亲和变更: %s -> 渠道[%d] (原渠道[%d])\", maskUserID(userID), channelIndex, oldChannel)\n\t\t} else if logType == 1 {\n\t\t\tlog.Printf(\"[Affinity-Set] 新建用户亲和: %s -> 渠道[%d]\", maskUserID(userID), channelIndex)\n\t\t}\n\t}\n}\n\n// UpdateLastUsed 更新最后使用时间（续期）\nfunc (m *TraceAffinityManager) UpdateLastUsed(userID string) {\n\tif userID == \"\" {\n\t\treturn\n\t}\n\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tif affinity, exists := m.affinity[userID]; exists {\n\t\taffinity.LastUsedAt = time.Now()\n\t}\n}\n\n// Remove 移除 user_id 的亲和记录\nfunc (m *TraceAffinityManager) Remove(userID string) {\n\tvar oldChannel int\n\tvar existed bool\n\n\tm.mu.Lock()\n\tif affinity, exists := m.affinity[userID]; exists {\n\t\toldChannel, existed = affinity.ChannelIndex, true\n\t\tdelete(m.affinity, userID)\n\t}\n\tm.mu.Unlock()\n\n\tif affinityDebug && existed {\n\t\tlog.Printf(\"[Affinity-Remove] 移除用户亲和: %s (原渠道[%d])\", maskUserID(userID), oldChannel)\n\t}\n}\n\n// RemoveByChannel 移除指定渠道的所有亲和记录\n// 用于渠道被禁用或删除时\nfunc (m *TraceAffinityManager) RemoveByChannel(channelIndex int) {\n\tm.mu.Lock()\n\tremoved := 0\n\tfor userID, affinity := range m.affinity {\n\t\tif affinity.ChannelIndex == channelIndex {\n\t\t\tdelete(m.affinity, userID)\n\t\t\tremoved++\n\t\t}\n\t}\n\tm.mu.Unlock()\n\n\tif affinityDebug && removed > 0 {\n\t\tlog.Printf(\"[Affinity-RemoveByChannel] 渠道[%d]被移除，清理了 %d 条亲和记录\", channelIndex, removed)\n\t}\n}\n\n// Cleanup 清理过期的亲和记录\nfunc (m *TraceAffinityManager) Cleanup() int {\n\tm.mu.Lock()\n\tnow := time.Now()\n\tcleaned := 0\n\tfor userID, affinity := range m.affinity {\n\t\tif now.Sub(affinity.LastUsedAt) > m.ttl {\n\t\t\tdelete(m.affinity, userID)\n\t\t\tcleaned++\n\t\t}\n\t}\n\tttl := m.ttl\n\tm.mu.Unlock()\n\n\tif affinityDebug && cleaned > 0 {\n\t\tlog.Printf(\"[Affinity-Cleanup] 清理了 %d 条过期亲和记录 (TTL: %v)\", cleaned, ttl)\n\t}\n\n\treturn cleaned\n}\n\n// cleanupLoop 定期清理过期记录\nfunc (m *TraceAffinityManager) cleanupLoop() {\n\tticker := time.NewTicker(5 * time.Minute) // 每 5 分钟清理一次\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase <-ticker.C:\n\t\t\tm.Cleanup()\n\t\tcase <-m.stopCh:\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// Stop 停止清理 goroutine，释放资源\nfunc (m *TraceAffinityManager) Stop() {\n\tclose(m.stopCh)\n}\n\n// Size 返回当前亲和记录数量\nfunc (m *TraceAffinityManager) Size() int {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\treturn len(m.affinity)\n}\n\n// GetTTL 获取 TTL 设置\nfunc (m *TraceAffinityManager) GetTTL() time.Duration {\n\treturn m.ttl\n}\n\n// GetAll 获取所有亲和记录（用于调试）\nfunc (m *TraceAffinityManager) GetAll() map[string]TraceAffinity {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tresult := make(map[string]TraceAffinity, len(m.affinity))\n\tfor userID, affinity := range m.affinity {\n\t\tresult[userID] = *affinity\n\t}\n\treturn result\n}\n\n// maskUserID 掩码 user_id（保护隐私）\n// 使用 rune 切片确保 UTF-8 安全\nfunc maskUserID(userID string) string {\n\tif userID == \"\" {\n\t\treturn \"***\"\n\t}\n\trunes := []rune(userID)\n\tn := len(runes)\n\tswitch {\n\tcase n <= 4:\n\t\treturn string(runes[:1]) + \"***\"\n\tcase n <= 8:\n\t\treturn string(runes[:2]) + \"***\" + string(runes[n-1:])\n\tcase n <= 16:\n\t\treturn string(runes[:3]) + \"***\" + string(runes[n-2:])\n\tdefault:\n\t\treturn string(runes[:8]) + \"***\" + string(runes[n-4:])\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/types/gemini.go",
    "content": "package types\n\nimport \"encoding/json\"\n\n// ============================================================================\n// Gemini API 常量\n// ============================================================================\n\n// DummyThoughtSignature 用于跳过 Gemini thought_signature 验证\n// 参考: https://ai.google.dev/gemini-api/docs/thought-signatures\nconst DummyThoughtSignature = \"skip_thought_signature_validator\"\n\n// StripThoughtSignatureMarker 特殊标记，表示需要完全移除 thought_signature 字段\n// 用于 stripThoughtSignature 函数标记需要移除的字段\nconst StripThoughtSignatureMarker = \"__STRIP_THOUGHT_SIGNATURE__\"\n\n// ============================================================================\n// Gemini API 请求结构\n// ============================================================================\n\n// GeminiRequest Gemini API 请求\ntype GeminiRequest struct {\n\tContents          []GeminiContent         `json:\"contents\"`\n\tSystemInstruction *GeminiContent          `json:\"systemInstruction,omitempty\"`\n\tTools             []GeminiTool            `json:\"tools,omitempty\"`\n\tGenerationConfig  *GeminiGenerationConfig `json:\"generationConfig,omitempty\"`\n\tSafetySettings    []GeminiSafetySetting   `json:\"safetySettings,omitempty\"`\n}\n\n// GeminiContent Gemini 内容\ntype GeminiContent struct {\n\tParts []GeminiPart `json:\"parts\"`\n\tRole  string       `json:\"role,omitempty\"` // \"user\" 或 \"model\"\n}\n\n// GeminiPart Gemini 内容块\ntype GeminiPart struct {\n\tText             string                  `json:\"text,omitempty\"`\n\tInlineData       *GeminiInlineData       `json:\"inlineData,omitempty\"`\n\tFunctionCall     *GeminiFunctionCall     `json:\"functionCall,omitempty\"`\n\tFunctionResponse *GeminiFunctionResponse `json:\"functionResponse,omitempty\"`\n\tFileData         *GeminiFileData         `json:\"fileData,omitempty\"`\n\tThought          bool                    `json:\"thought,omitempty\"` // 是否为 thinking 内容\n}\n\n// UnmarshalJSON 自定义反序列化，兼容部分客户端将 thoughtSignature 放在 part 层级的情况（而非 functionCall 内部）\n// 示例（Gemini CLI）：\n//\n//\t{\n//\t  \"functionCall\": { ... },\n//\t  \"thoughtSignature\": \"...\"\n//\t}\nfunc (p *GeminiPart) UnmarshalJSON(data []byte) error {\n\ttype partAlias GeminiPart\n\tvar raw struct {\n\t\tpartAlias\n\t\tThoughtSignatureCamel string `json:\"thoughtSignature,omitempty\"`\n\t\tThoughtSignatureSnake string `json:\"thought_signature,omitempty\"`\n\t}\n\tif err := json.Unmarshal(data, &raw); err != nil {\n\t\treturn err\n\t}\n\n\t*p = GeminiPart(raw.partAlias)\n\n\t// 兼容：当签名出现在 part 层级时，将其归一化到 functionCall 内部（内部存储即可）\n\tif p.FunctionCall == nil || p.FunctionCall.ThoughtSignature != \"\" {\n\t\treturn nil\n\t}\n\tif raw.ThoughtSignatureSnake != \"\" {\n\t\tp.FunctionCall.ThoughtSignature = raw.ThoughtSignatureSnake\n\t} else if raw.ThoughtSignatureCamel != \"\" {\n\t\tp.FunctionCall.ThoughtSignature = raw.ThoughtSignatureCamel\n\t}\n\n\treturn nil\n}\n\n// MarshalJSON 自定义序列化：Gemini thoughtSignature 字段位于 part 层级（与 functionCall 同级）。\nfunc (p GeminiPart) MarshalJSON() ([]byte, error) {\n\ttype partAlias GeminiPart\n\tout := struct {\n\t\tpartAlias\n\t\tThoughtSignature string `json:\"thoughtSignature,omitempty\"`\n\t}{\n\t\tpartAlias: partAlias(p),\n\t}\n\n\tif p.FunctionCall != nil {\n\t\tsig := p.FunctionCall.ThoughtSignature\n\t\tif sig != \"\" && sig != StripThoughtSignatureMarker {\n\t\t\tout.ThoughtSignature = sig\n\t\t}\n\t}\n\n\treturn json.Marshal(out)\n}\n\n// GeminiInlineData 内联数据（图片、音频等）\ntype GeminiInlineData struct {\n\tMimeType string `json:\"mimeType\"`\n\tData     string `json:\"data\"` // base64 编码\n}\n\n// GeminiFileData 文件引用（File API）\ntype GeminiFileData struct {\n\tMimeType string `json:\"mimeType,omitempty\"`\n\tFileURI  string `json:\"fileUri\"`\n}\n\n// GeminiFunctionCall 函数调用\n// 注意：thought_signature 有两种格式：\n// - 下划线格式（thought_signature）：Google 官方 API\n// - 驼峰格式（thoughtSignature）：Gemini CLI 等第三方客户端\n// 为了保持透传，我们记录原始格式并在输出时使用相同格式\ntype GeminiFunctionCall struct {\n\tName             string                 `json:\"name\"`\n\tArgs             map[string]interface{} `json:\"args\"`\n\tThoughtSignature string                 `json:\"-\"` // thoughtSignature 位于 part 层级，仅内部使用\n}\n\n// GeminiFunctionResponse 函数响应\ntype GeminiFunctionResponse struct {\n\tName     string                 `json:\"name\"`\n\tResponse map[string]interface{} `json:\"response\"`\n}\n\n// GeminiTool 工具定义\ntype GeminiTool struct {\n\tFunctionDeclarations []GeminiFunctionDeclaration `json:\"functionDeclarations,omitempty\"`\n}\n\n// GeminiFunctionDeclaration 函数声明\ntype GeminiFunctionDeclaration struct {\n\tName        string      `json:\"name\"`\n\tDescription string      `json:\"description,omitempty\"`\n\tParameters  interface{} `json:\"parameters,omitempty\"` // JSON Schema\n}\n\n// UnmarshalJSON 自定义反序列化：\n// - 支持 parameters（官方字段）\n// - 兼容部分客户端使用 parametersJsonSchema（例如 Gemini CLI）\n// 为了让上游模型正确理解参数结构，统一写入 Parameters，并在序列化时输出为 parameters。\nfunc (fd *GeminiFunctionDeclaration) UnmarshalJSON(data []byte) error {\n\tvar raw map[string]json.RawMessage\n\tif err := json.Unmarshal(data, &raw); err != nil {\n\t\treturn err\n\t}\n\n\tif nameRaw, ok := raw[\"name\"]; ok {\n\t\tif err := json.Unmarshal(nameRaw, &fd.Name); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tif descRaw, ok := raw[\"description\"]; ok {\n\t\tif err := json.Unmarshal(descRaw, &fd.Description); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tvar paramsRaw json.RawMessage\n\tif v, ok := raw[\"parameters\"]; ok {\n\t\tparamsRaw = v\n\t} else if v, ok := raw[\"parametersJsonSchema\"]; ok {\n\t\tparamsRaw = v\n\t}\n\tif paramsRaw != nil {\n\t\tvar params interface{}\n\t\tif err := json.Unmarshal(paramsRaw, &params); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfd.Parameters = sanitizeGeminiToolSchema(params)\n\t}\n\n\treturn nil\n}\n\n// sanitizeGeminiToolSchema 清洗工具参数 schema，以兼容部分上游对 parameters 字段的严格校验。\n//\n// 已知不兼容字段：\n// - $schema\n// - additionalProperties\n// - const（转换为 enum: [const]）\nfunc sanitizeGeminiToolSchema(v interface{}) interface{} {\n\tswitch vv := v.(type) {\n\tcase map[string]interface{}:\n\t\tout := make(map[string]interface{}, len(vv))\n\t\tvar constValue interface{}\n\t\thasConst := false\n\n\t\tfor k, val := range vv {\n\t\t\tswitch k {\n\t\t\tcase \"$schema\", \"additionalProperties\":\n\t\t\t\tcontinue\n\t\t\tcase \"const\":\n\t\t\t\tconstValue = val\n\t\t\t\thasConst = true\n\t\t\t\tcontinue\n\t\t\tdefault:\n\t\t\t\tout[k] = sanitizeGeminiToolSchema(val)\n\t\t\t}\n\t\t}\n\n\t\tif hasConst {\n\t\t\tif _, ok := out[\"enum\"]; !ok {\n\t\t\t\tout[\"enum\"] = []interface{}{sanitizeGeminiToolSchema(constValue)}\n\t\t\t}\n\t\t}\n\n\t\treturn out\n\tcase []interface{}:\n\t\tout := make([]interface{}, len(vv))\n\t\tfor i := range vv {\n\t\t\tout[i] = sanitizeGeminiToolSchema(vv[i])\n\t\t}\n\t\treturn out\n\tdefault:\n\t\treturn v\n\t}\n}\n\n// GeminiGenerationConfig 生成配置\ntype GeminiGenerationConfig struct {\n\tTemperature        *float64              `json:\"temperature,omitempty\"`\n\tTopP               *float64              `json:\"topP,omitempty\"`\n\tTopK               *int                  `json:\"topK,omitempty\"`\n\tMaxOutputTokens    int                   `json:\"maxOutputTokens,omitempty\"`\n\tStopSequences      []string              `json:\"stopSequences,omitempty\"`\n\tResponseMimeType   string                `json:\"responseMimeType,omitempty\"`   // \"application/json\" / \"text/plain\"\n\tResponseModalities []string              `json:\"responseModalities,omitempty\"` // [\"TEXT\", \"IMAGE\", \"AUDIO\"]\n\tThinkingConfig     *GeminiThinkingConfig `json:\"thinkingConfig,omitempty\"`\n}\n\n// GeminiThinkingConfig 推理配置\ntype GeminiThinkingConfig struct {\n\tIncludeThoughts bool   `json:\"includeThoughts,omitempty\"`\n\tThinkingBudget  *int32 `json:\"thinkingBudget,omitempty\"` // 推理 token 预算\n\tThinkingLevel   string `json:\"thinkingLevel,omitempty\"`  // 或使用 level 替代 budget\n}\n\n// GeminiSafetySetting 安全设置\ntype GeminiSafetySetting struct {\n\tCategory  string `json:\"category\"`\n\tThreshold string `json:\"threshold\"`\n}\n\n// ============================================================================\n// Gemini API 响应结构\n// ============================================================================\n\n// GeminiResponse Gemini API 响应\ntype GeminiResponse struct {\n\tCandidates     []GeminiCandidate     `json:\"candidates\"`\n\tPromptFeedback *GeminiPromptFeedback `json:\"promptFeedback,omitempty\"`\n\tUsageMetadata  *GeminiUsageMetadata  `json:\"usageMetadata,omitempty\"`\n\tModelVersion   string                `json:\"modelVersion,omitempty\"`\n}\n\n// GeminiCandidate 候选响应\ntype GeminiCandidate struct {\n\tContent       *GeminiContent       `json:\"content,omitempty\"`\n\tFinishReason  string               `json:\"finishReason,omitempty\"` // \"STOP\", \"MAX_TOKENS\", \"SAFETY\", \"RECITATION\"\n\tSafetyRatings []GeminiSafetyRating `json:\"safetyRatings,omitempty\"`\n\tIndex         int                  `json:\"index,omitempty\"`\n}\n\n// GeminiPromptFeedback 提示反馈\ntype GeminiPromptFeedback struct {\n\tBlockReason   string               `json:\"blockReason,omitempty\"`\n\tSafetyRatings []GeminiSafetyRating `json:\"safetyRatings,omitempty\"`\n}\n\n// GeminiSafetyRating 安全评级\ntype GeminiSafetyRating struct {\n\tCategory    string `json:\"category\"`\n\tProbability string `json:\"probability\"`\n}\n\n// GeminiUsageMetadata 使用统计\ntype GeminiUsageMetadata struct {\n\tPromptTokenCount        int `json:\"promptTokenCount\"`\n\tCandidatesTokenCount    int `json:\"candidatesTokenCount\"`\n\tTotalTokenCount         int `json:\"totalTokenCount\"`\n\tCachedContentTokenCount int `json:\"cachedContentTokenCount,omitempty\"`\n\tThoughtsTokenCount      int `json:\"thoughtsTokenCount,omitempty\"` // 推理 tokens\n}\n\n// ============================================================================\n// Gemini 流式响应结构\n// ============================================================================\n\n// GeminiStreamChunk Gemini 流式响应块\ntype GeminiStreamChunk struct {\n\tCandidates    []GeminiCandidate    `json:\"candidates,omitempty\"`\n\tUsageMetadata *GeminiUsageMetadata `json:\"usageMetadata,omitempty\"`\n}\n\n// ============================================================================\n// Gemini 错误响应结构\n// ============================================================================\n\n// GeminiError Gemini 错误响应\ntype GeminiError struct {\n\tError GeminiErrorDetail `json:\"error\"`\n}\n\n// GeminiErrorDetail Gemini 错误详情\ntype GeminiErrorDetail struct {\n\tCode    int    `json:\"code\"`\n\tMessage string `json:\"message\"`\n\tStatus  string `json:\"status\"`\n}\n"
  },
  {
    "path": "backend-go/internal/types/gemini_test.go",
    "content": "package types\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n)\n\nfunc TestGeminiPart_UnmarshalJSON_ThoughtSignatureAtPartLevel(t *testing.T) {\n\tt.Run(\"驼峰 thoughtSignature 在 part 层级时归一化到 functionCall，并在 part 层级输出 thoughtSignature\", func(t *testing.T) {\n\t\tinput := `{\"functionCall\":{\"name\":\"list_directory\",\"args\":{\"path\":\".\"}},\"thoughtSignature\":\"sig_camel\"}`\n\n\t\tvar part GeminiPart\n\t\tif err := json.Unmarshal([]byte(input), &part); err != nil {\n\t\t\tt.Fatalf(\"UnmarshalJSON 失败: %v\", err)\n\t\t}\n\t\tif part.FunctionCall == nil {\n\t\t\tt.Fatalf(\"FunctionCall 为空\")\n\t\t}\n\t\tif part.FunctionCall.ThoughtSignature != \"sig_camel\" {\n\t\t\tt.Fatalf(\"ThoughtSignature=%q, want=%q\", part.FunctionCall.ThoughtSignature, \"sig_camel\")\n\t\t}\n\n\t\toutBytes, err := json.Marshal(part)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Marshal 失败: %v\", err)\n\t\t}\n\n\t\tvar got map[string]interface{}\n\t\tif err := json.Unmarshal(outBytes, &got); err != nil {\n\t\t\tt.Fatalf(\"解析输出 JSON 失败: %v\", err)\n\t\t}\n\t\tif v, ok := got[\"thoughtSignature\"]; !ok || v != \"sig_camel\" {\n\t\t\tt.Fatalf(\"part.thoughtSignature=%v, want=%v\", v, \"sig_camel\")\n\t\t}\n\t\tfc, ok := got[\"functionCall\"].(map[string]interface{})\n\t\tif !ok {\n\t\t\tt.Fatalf(\"functionCall 类型=%T, want=map[string]interface{}\", got[\"functionCall\"])\n\t\t}\n\t\tif _, ok := fc[\"thoughtSignature\"]; ok {\n\t\t\tt.Fatalf(\"不应在 functionCall 内输出 thoughtSignature: %v\", fc)\n\t\t}\n\t\tif _, ok := fc[\"thought_signature\"]; ok {\n\t\t\tt.Fatalf(\"不应在 functionCall 内输出 thought_signature: %v\", fc)\n\t\t}\n\t})\n\n\tt.Run(\"下划线 thought_signature 在 part 层级时归一化到 functionCall，并在 part 层级输出 thoughtSignature\", func(t *testing.T) {\n\t\tinput := `{\"functionCall\":{\"name\":\"list_directory\",\"args\":{\"path\":\".\"}},\"thought_signature\":\"sig_snake\"}`\n\n\t\tvar part GeminiPart\n\t\tif err := json.Unmarshal([]byte(input), &part); err != nil {\n\t\t\tt.Fatalf(\"UnmarshalJSON 失败: %v\", err)\n\t\t}\n\t\tif part.FunctionCall == nil {\n\t\t\tt.Fatalf(\"FunctionCall 为空\")\n\t\t}\n\t\tif part.FunctionCall.ThoughtSignature != \"sig_snake\" {\n\t\t\tt.Fatalf(\"ThoughtSignature=%q, want=%q\", part.FunctionCall.ThoughtSignature, \"sig_snake\")\n\t\t}\n\n\t\toutBytes, err := json.Marshal(part)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Marshal 失败: %v\", err)\n\t\t}\n\n\t\tvar got map[string]interface{}\n\t\tif err := json.Unmarshal(outBytes, &got); err != nil {\n\t\t\tt.Fatalf(\"解析输出 JSON 失败: %v\", err)\n\t\t}\n\t\tif v, ok := got[\"thoughtSignature\"]; !ok || v != \"sig_snake\" {\n\t\t\tt.Fatalf(\"part.thoughtSignature=%v, want=%v\", v, \"sig_snake\")\n\t\t}\n\t\tif _, ok := got[\"thought_signature\"]; ok {\n\t\t\tt.Fatalf(\"不应在 part 层级输出 thought_signature: %v\", got)\n\t\t}\n\n\t\tfc, ok := got[\"functionCall\"].(map[string]interface{})\n\t\tif !ok {\n\t\t\tt.Fatalf(\"functionCall 类型=%T, want=map[string]interface{}\", got[\"functionCall\"])\n\t\t}\n\t\tif _, ok := fc[\"thoughtSignature\"]; ok {\n\t\t\tt.Fatalf(\"不应在 functionCall 内输出 thoughtSignature: %v\", fc)\n\t\t}\n\t\tif _, ok := fc[\"thought_signature\"]; ok {\n\t\t\tt.Fatalf(\"不应在 functionCall 内输出 thought_signature: %v\", fc)\n\t\t}\n\t})\n}\n\nfunc TestGeminiFunctionDeclaration_UnmarshalJSON_ParametersJsonSchema(t *testing.T) {\n\tinput := `{\n\t  \"name\": \"list_directory\",\n\t  \"description\": \"Lists the names of files and subdirectories directly within a specified directory path.\",\n\t  \"parametersJsonSchema\": {\n\t    \"type\": \"object\",\n\t    \"properties\": {\n\t      \"dir_path\": { \"type\": \"string\" }\n\t    },\n\t    \"required\": [\"dir_path\"]\n\t  }\n\t}`\n\n\tvar decl GeminiFunctionDeclaration\n\tif err := json.Unmarshal([]byte(input), &decl); err != nil {\n\t\tt.Fatalf(\"UnmarshalJSON 失败: %v\", err)\n\t}\n\tif decl.Name != \"list_directory\" {\n\t\tt.Fatalf(\"Name=%q, want=%q\", decl.Name, \"list_directory\")\n\t}\n\tif decl.Parameters == nil {\n\t\tt.Fatalf(\"Parameters 为空，期望从 parametersJsonSchema 读取\")\n\t}\n\n\toutBytes, err := json.Marshal(decl)\n\tif err != nil {\n\t\tt.Fatalf(\"Marshal 失败: %v\", err)\n\t}\n\tvar got map[string]interface{}\n\tif err := json.Unmarshal(outBytes, &got); err != nil {\n\t\tt.Fatalf(\"解析输出 JSON 失败: %v\", err)\n\t}\n\tif _, ok := got[\"parameters\"]; !ok {\n\t\tt.Fatalf(\"输出缺少 parameters 字段: %v\", got)\n\t}\n\tif _, ok := got[\"parametersJsonSchema\"]; ok {\n\t\tt.Fatalf(\"不应输出 parametersJsonSchema 字段: %v\", got)\n\t}\n}\n\nfunc TestGeminiFunctionDeclaration_UnmarshalJSON_SanitizeParametersSchema(t *testing.T) {\n\tinput := `{\n  \"name\": \"delegate_to_agent\",\n  \"description\": \"Delegates a task to a specialized sub-agent.\",\n  \"parametersJsonSchema\": {\n    \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n    \"type\": \"object\",\n    \"additionalProperties\": false,\n    \"properties\": {\n      \"agent_name\": {\n        \"type\": \"string\",\n        \"const\": \"codebase_investigator\",\n        \"additionalProperties\": false\n      }\n    },\n    \"required\": [\"agent_name\"]\n  }\n}`\n\n\tvar decl GeminiFunctionDeclaration\n\tif err := json.Unmarshal([]byte(input), &decl); err != nil {\n\t\tt.Fatalf(\"UnmarshalJSON 失败: %v\", err)\n\t}\n\n\tparams, ok := decl.Parameters.(map[string]interface{})\n\tif !ok {\n\t\tt.Fatalf(\"Parameters 类型=%T, want=map[string]interface{}\", decl.Parameters)\n\t}\n\tif _, ok := params[\"$schema\"]; ok {\n\t\tt.Fatalf(\"不应包含 $schema: %v\", params)\n\t}\n\tif _, ok := params[\"additionalProperties\"]; ok {\n\t\tt.Fatalf(\"不应包含 additionalProperties: %v\", params)\n\t}\n\n\tprops, ok := params[\"properties\"].(map[string]interface{})\n\tif !ok {\n\t\tt.Fatalf(\"properties 类型=%T, want=map[string]interface{}\", params[\"properties\"])\n\t}\n\tagentName, ok := props[\"agent_name\"].(map[string]interface{})\n\tif !ok {\n\t\tt.Fatalf(\"properties.agent_name 类型=%T, want=map[string]interface{}\", props[\"agent_name\"])\n\t}\n\tif _, ok := agentName[\"const\"]; ok {\n\t\tt.Fatalf(\"不应包含 const: %v\", agentName)\n\t}\n\tif _, ok := agentName[\"additionalProperties\"]; ok {\n\t\tt.Fatalf(\"不应包含 additionalProperties(嵌套): %v\", agentName)\n\t}\n\tenum, ok := agentName[\"enum\"].([]interface{})\n\tif !ok || len(enum) != 1 || enum[0] != \"codebase_investigator\" {\n\t\tt.Fatalf(\"agent_name.enum=%v, want=%v\", agentName[\"enum\"], []interface{}{\"codebase_investigator\"})\n\t}\n\n\toutBytes, err := json.Marshal(decl)\n\tif err != nil {\n\t\tt.Fatalf(\"Marshal 失败: %v\", err)\n\t}\n\tvar got map[string]interface{}\n\tif err := json.Unmarshal(outBytes, &got); err != nil {\n\t\tt.Fatalf(\"解析输出 JSON 失败: %v\", err)\n\t}\n\toutParams, ok := got[\"parameters\"].(map[string]interface{})\n\tif !ok {\n\t\tt.Fatalf(\"输出 parameters 类型=%T, want=map[string]interface{}\", got[\"parameters\"])\n\t}\n\tif _, ok := outParams[\"$schema\"]; ok {\n\t\tt.Fatalf(\"输出不应包含 $schema: %v\", outParams)\n\t}\n\tif _, ok := outParams[\"additionalProperties\"]; ok {\n\t\tt.Fatalf(\"输出不应包含 additionalProperties: %v\", outParams)\n\t}\n\toutProps, ok := outParams[\"properties\"].(map[string]interface{})\n\tif !ok {\n\t\tt.Fatalf(\"输出 properties 类型=%T, want=map[string]interface{}\", outParams[\"properties\"])\n\t}\n\toutAgentName, ok := outProps[\"agent_name\"].(map[string]interface{})\n\tif !ok {\n\t\tt.Fatalf(\"输出 properties.agent_name 类型=%T, want=map[string]interface{}\", outProps[\"agent_name\"])\n\t}\n\tif _, ok := outAgentName[\"const\"]; ok {\n\t\tt.Fatalf(\"输出不应包含 const: %v\", outAgentName)\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/types/responses.go",
    "content": "package types\n\n// ============== Responses API 类型定义 ==============\n\n// ResponsesRequest Responses API 请求\ntype ResponsesRequest struct {\n\tModel              string      `json:\"model\"`\n\tInstructions       string      `json:\"instructions,omitempty\"` // 系统指令（映射为 system message）\n\tInput              interface{} `json:\"input\"`                  // string 或 []ResponsesItem\n\tPreviousResponseID string      `json:\"previous_response_id,omitempty\"`\n\tStore              *bool       `json:\"store,omitempty\"`             // 默认 true\n\tMaxTokens          int         `json:\"max_tokens,omitempty\"`        // 最大 tokens\n\tTemperature        float64     `json:\"temperature,omitempty\"`       // 温度参数\n\tTopP               float64     `json:\"top_p,omitempty\"`             // top_p 参数\n\tFrequencyPenalty   float64     `json:\"frequency_penalty,omitempty\"` // 频率惩罚\n\tPresencePenalty    float64     `json:\"presence_penalty,omitempty\"`  // 存在惩罚\n\tStream             bool        `json:\"stream,omitempty\"`            // 是否流式输出\n\tStop               interface{} `json:\"stop,omitempty\"`              // 停止序列 (string 或 []string)\n\tUser               string      `json:\"user,omitempty\"`              // 用户标识\n\tStreamOptions      interface{} `json:\"stream_options,omitempty\"`    // 流式选项\n\n\t// TransformerMetadata 转换器元数据（仅内存使用，不序列化）\n\t// 用于在单次请求的转换流程中保留原始格式信息，如 system 数组格式等\n\t// 注意：此字段不会通过 JSON 序列化保留，仅在同一请求处理链中有效\n\tTransformerMetadata map[string]interface{} `json:\"-\"`\n}\n\n// ResponsesItem Responses API 消息项\ntype ResponsesItem struct {\n\tType    string      `json:\"type\"`           // message, text, tool_call, tool_result\n\tRole    string      `json:\"role,omitempty\"` // user, assistant (用于 type=message)\n\tContent interface{} `json:\"content\"`        // string 或 []ContentBlock\n\tToolUse *ToolUse    `json:\"tool_use,omitempty\"`\n}\n\n// ContentBlock 内容块（用于嵌套 content 数组）\ntype ContentBlock struct {\n\tType string `json:\"type\"` // input_text, output_text\n\tText string `json:\"text\"`\n}\n\n// ToolUse 工具使用定义\ntype ToolUse struct {\n\tID    string      `json:\"id\"`\n\tName  string      `json:\"name\"`\n\tInput interface{} `json:\"input\"`\n}\n\n// ResponsesResponse Responses API 响应\ntype ResponsesResponse struct {\n\tID         string          `json:\"id\"`\n\tModel      string          `json:\"model\"`\n\tOutput     []ResponsesItem `json:\"output\"`\n\tStatus     string          `json:\"status\"` // completed, failed\n\tPreviousID string          `json:\"previous_id,omitempty\"`\n\tUsage      ResponsesUsage  `json:\"usage\"`\n\tCreated    int64           `json:\"created,omitempty\"`\n}\n\n// ResponsesUsage Responses API 使用统计\n// 完整支持 OpenAI Responses API 和 Claude API 的详细 usage 字段\n// 参考 claude-code-hub 实现，支持缓存 TTL 细分 (5m/1h)\ntype ResponsesUsage struct {\n\tInputTokens         int                  `json:\"input_tokens\"`\n\tInputTokensDetails  *InputTokensDetails  `json:\"input_tokens_details,omitempty\"`\n\tOutputTokens        int                  `json:\"output_tokens\"`\n\tOutputTokensDetails *OutputTokensDetails `json:\"output_tokens_details,omitempty\"`\n\tTotalTokens         int                  `json:\"total_tokens\"`\n\n\t// Claude 扩展字段（缓存创建统计，用于精确计费）\n\tCacheCreationInputTokens   int    `json:\"cache_creation_input_tokens,omitempty\"`\n\tCacheCreation5mInputTokens int    `json:\"cache_creation_5m_input_tokens,omitempty\"` // 5分钟 TTL\n\tCacheCreation1hInputTokens int    `json:\"cache_creation_1h_input_tokens,omitempty\"` // 1小时 TTL\n\tCacheReadInputTokens       int    `json:\"cache_read_input_tokens,omitempty\"`\n\tCacheTTL                   string `json:\"cache_ttl,omitempty\"` // \"5m\" | \"1h\" | \"mixed\"\n}\n\n// InputTokensDetails 输入 Token 详细统计\ntype InputTokensDetails struct {\n\tCachedTokens int `json:\"cached_tokens\"`\n}\n\n// OutputTokensDetails 输出 Token 详细统计\ntype OutputTokensDetails struct {\n\tReasoningTokens int `json:\"reasoning_tokens\"`\n}\n\n// ResponsesStreamEvent Responses API 流式事件\ntype ResponsesStreamEvent struct {\n\tID         string          `json:\"id,omitempty\"`\n\tModel      string          `json:\"model,omitempty\"`\n\tOutput     []ResponsesItem `json:\"output,omitempty\"`\n\tStatus     string          `json:\"status,omitempty\"`\n\tPreviousID string          `json:\"previous_id,omitempty\"`\n\tUsage      *ResponsesUsage `json:\"usage,omitempty\"`\n\tType       string          `json:\"type,omitempty\"` // delta, done\n\tDelta      *ResponsesDelta `json:\"delta,omitempty\"`\n}\n\n// ResponsesDelta 流式增量数据\ntype ResponsesDelta struct {\n\tType    string      `json:\"type,omitempty\"`\n\tContent interface{} `json:\"content,omitempty\"`\n}\n"
  },
  {
    "path": "backend-go/internal/types/types.go",
    "content": "package types\n\n// ClaudeRequest Claude 请求结构\ntype ClaudeRequest struct {\n\tModel       string                 `json:\"model\"`\n\tMessages    []ClaudeMessage        `json:\"messages\"`\n\tSystem      interface{}            `json:\"system,omitempty\"` // string 或 content 数组\n\tMaxTokens   int                    `json:\"max_tokens,omitempty\"`\n\tTemperature float64                `json:\"temperature,omitempty\"`\n\tStream      bool                   `json:\"stream,omitempty\"`\n\tTools       []ClaudeTool           `json:\"tools,omitempty\"`\n\tMetadata    map[string]interface{} `json:\"metadata,omitempty\"` // Claude Code CLI 等客户端发送的元数据\n}\n\n// ClaudeMessage Claude 消息\ntype ClaudeMessage struct {\n\tRole    string      `json:\"role\"`\n\tContent interface{} `json:\"content\"` // string 或 content 数组\n}\n\n// CacheControl Anthropic 缓存控制\n// 用于 Claude API 请求，会序列化到 JSON（仅在发送给 Anthropic 时有效）\ntype CacheControl struct {\n\tType string `json:\"type,omitempty\"` // \"ephemeral\"\n}\n\n// ClaudeContent Claude 内容块\ntype ClaudeContent struct {\n\tType         string        `json:\"type\"` // text, tool_use, tool_result\n\tText         string        `json:\"text,omitempty\"`\n\tID           string        `json:\"id,omitempty\"`\n\tName         string        `json:\"name,omitempty\"`\n\tInput        interface{}   `json:\"input,omitempty\"`\n\tToolUseID    string        `json:\"tool_use_id,omitempty\"`\n\tCacheControl *CacheControl `json:\"cache_control,omitempty\"`\n}\n\n// ClaudeTool Claude 工具定义\ntype ClaudeTool struct {\n\tName         string        `json:\"name\"`\n\tDescription  string        `json:\"description,omitempty\"`\n\tInputSchema  interface{}   `json:\"input_schema\"`\n\tCacheControl *CacheControl `json:\"cache_control,omitempty\"`\n}\n\n// ClaudeResponse Claude 响应\ntype ClaudeResponse struct {\n\tID         string          `json:\"id\"`\n\tType       string          `json:\"type\"`\n\tRole       string          `json:\"role\"`\n\tContent    []ClaudeContent `json:\"content\"`\n\tStopReason string          `json:\"stop_reason,omitempty\"`\n\tUsage      *Usage          `json:\"usage,omitempty\"`\n}\n\n// OpenAIRequest OpenAI 请求结构\ntype OpenAIRequest struct {\n\tModel               string          `json:\"model\"`\n\tMessages            []OpenAIMessage `json:\"messages\"`\n\tMaxCompletionTokens int             `json:\"max_completion_tokens,omitempty\"`\n\tTemperature         float64         `json:\"temperature,omitempty\"`\n\tStream              bool            `json:\"stream,omitempty\"`\n\tTools               []OpenAITool    `json:\"tools,omitempty\"`\n\tToolChoice          string          `json:\"tool_choice,omitempty\"`\n}\n\n// OpenAIMessage OpenAI 消息\ntype OpenAIMessage struct {\n\tRole       string           `json:\"role\"`\n\tContent    interface{}      `json:\"content\"` // string 或 null\n\tToolCalls  []OpenAIToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string           `json:\"tool_call_id,omitempty\"`\n}\n\n// OpenAIToolCall OpenAI 工具调用\ntype OpenAIToolCall struct {\n\tID       string                 `json:\"id\"`\n\tType     string                 `json:\"type\"`\n\tFunction OpenAIToolCallFunction `json:\"function\"`\n}\n\n// OpenAIToolCallFunction OpenAI 工具调用函数\ntype OpenAIToolCallFunction struct {\n\tName      string `json:\"name\"`\n\tArguments string `json:\"arguments\"`\n}\n\n// OpenAITool OpenAI 工具定义\ntype OpenAITool struct {\n\tType     string             `json:\"type\"`\n\tFunction OpenAIToolFunction `json:\"function\"`\n}\n\n// OpenAIToolFunction OpenAI 工具函数\ntype OpenAIToolFunction struct {\n\tName        string      `json:\"name\"`\n\tDescription string      `json:\"description,omitempty\"`\n\tParameters  interface{} `json:\"parameters\"`\n}\n\n// OpenAIResponse OpenAI 响应\ntype OpenAIResponse struct {\n\tID      string         `json:\"id\"`\n\tChoices []OpenAIChoice `json:\"choices\"`\n\tUsage   *Usage         `json:\"usage,omitempty\"`\n}\n\n// OpenAIChoice OpenAI 选择\ntype OpenAIChoice struct {\n\tMessage      OpenAIMessage `json:\"message\"`\n\tFinishReason string        `json:\"finish_reason,omitempty\"`\n}\n\n// Usage 使用情况统计\n// 完整支持 Claude API 的详细 usage 字段，包括缓存 TTL 细分\ntype Usage struct {\n\tInputTokens              int `json:\"input_tokens,omitempty\"`\n\tOutputTokens             int `json:\"output_tokens,omitempty\"`\n\tCacheCreationInputTokens int `json:\"cache_creation_input_tokens,omitempty\"`\n\tCacheReadInputTokens     int `json:\"cache_read_input_tokens,omitempty\"`\n\t// 缓存 TTL 细分（参考 claude-code-hub）\n\tCacheCreation5mInputTokens int    `json:\"cache_creation_5m_input_tokens,omitempty\"` // 5分钟 TTL\n\tCacheCreation1hInputTokens int    `json:\"cache_creation_1h_input_tokens,omitempty\"` // 1小时 TTL\n\tCacheTTL                   string `json:\"cache_ttl,omitempty\"`                      // \"5m\" | \"1h\" | \"mixed\"\n\t// OpenAI 兼容字段\n\tPromptTokens     int `json:\"prompt_tokens,omitempty\"`\n\tCompletionTokens int `json:\"completion_tokens,omitempty\"`\n}\n\n// ProviderRequest 提供商请求（通用）\ntype ProviderRequest struct {\n\tURL     string\n\tMethod  string\n\tHeaders map[string]string\n\tBody    interface{}\n}\n\n// ProviderResponse 提供商响应（通用）\ntype ProviderResponse struct {\n\tStatusCode int\n\tHeaders    map[string][]string\n\tBody       []byte\n\tStream     bool\n}\n"
  },
  {
    "path": "backend-go/internal/utils/compression.go",
    "content": "package utils\n\nimport (\n\t\"bytes\"\n\t\"compress/gzip\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n)\n\n// DecompressGzipIfNeeded 检测并解压缩 gzip 响应体\n// 这是一个兜底机制，用于处理错误响应等特殊场景\n// 正常情况下，Go 的 http.Client 会自动处理 gzip 解压缩\nfunc DecompressGzipIfNeeded(resp *http.Response, bodyBytes []byte) []byte {\n\t// 检查 Content-Encoding 头\n\tif resp.Header.Get(\"Content-Encoding\") != \"gzip\" {\n\t\treturn bodyBytes\n\t}\n\n\t// 尝试解压缩\n\treader, err := gzip.NewReader(bytes.NewReader(bodyBytes))\n\tif err != nil {\n\t\tlog.Printf(\"[Gzip] 警告: 创建 gzip reader 失败: %v\", err)\n\t\treturn bodyBytes\n\t}\n\tdefer reader.Close()\n\n\tdecompressed, err := io.ReadAll(reader)\n\tif err != nil {\n\t\tlog.Printf(\"[Gzip] 警告: 解压缩 gzip 响应体失败: %v\", err)\n\t\treturn bodyBytes\n\t}\n\n\treturn decompressed\n}\n"
  },
  {
    "path": "backend-go/internal/utils/headers.go",
    "content": "package utils\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// PrepareUpstreamHeaders 准备上游请求头（统一头部处理逻辑）\n// 保留原始请求头，移除代理相关头部，设置认证头\n// 注意：此函数适用于Claude类型渠道，对于其他类型请使用 PrepareMinimalHeaders\nfunc PrepareUpstreamHeaders(c *gin.Context, targetHost string) http.Header {\n\theaders := c.Request.Header.Clone()\n\n\t// 设置正确的Host头部\n\theaders.Set(\"Host\", targetHost)\n\n\t// 移除代理相关头部\n\theaders.Del(\"x-proxy-key\")\n\theaders.Del(\"X-Forwarded-Host\")\n\theaders.Del(\"X-Forwarded-Proto\")\n\n\t// 移除 Accept-Encoding，让 Go 的 http.Client 自动处理 gzip 压缩/解压缩\n\t// 这样可以避免在原始请求包含 Accept-Encoding 时 Go 不自动解压缩的问题\n\theaders.Del(\"Accept-Encoding\")\n\n\treturn headers\n}\n\n// PrepareMinimalHeaders 准备最小化请求头（适用于非Claude渠道如OpenAI、Gemini等）\n// 只保留必要的头部：Content-Type和Host，不包含任何Anthropic特定头部\n// 注意：不设置Accept-Encoding，让Go的http.Client自动处理gzip压缩\nfunc PrepareMinimalHeaders(targetHost string) http.Header {\n\theaders := http.Header{}\n\n\t// 只设置最基本的头部\n\theaders.Set(\"Host\", targetHost)\n\theaders.Set(\"Content-Type\", \"application/json\")\n\t// 不显式设置Accept-Encoding，让Go的http.Client自动添加并处理gzip解压\n\n\treturn headers\n}\n\n// SetAuthenticationHeader 设置认证头部（根据密钥格式智能选择）\nfunc SetAuthenticationHeader(headers http.Header, apiKey string) {\n\t// 移除旧的认证头\n\theaders.Del(\"authorization\")\n\theaders.Del(\"x-api-key\")\n\theaders.Del(\"x-goog-api-key\")\n\n\t// Claude 官方密钥格式（sk-ant-api03-xxx）使用 x-api-key\n\t// 符合 Claude API 官方推荐的认证方式\n\tif strings.HasPrefix(apiKey, \"sk-ant-\") {\n\t\theaders.Set(\"x-api-key\", apiKey)\n\t} else {\n\t\t// 其他格式密钥使用 Authorization: Bearer\n\t\t// 适用于 OpenAI、自定义密钥等\n\t\theaders.Set(\"Authorization\", \"Bearer \"+apiKey)\n\t}\n}\n\n// SetGeminiAuthenticationHeader 设置Gemini认证头部\nfunc SetGeminiAuthenticationHeader(headers http.Header, apiKey string) {\n\theaders.Del(\"authorization\")\n\theaders.Del(\"x-api-key\")\n\theaders.Set(\"x-goog-api-key\", apiKey)\n}\n\n// EnsureCompatibleUserAgent 确保兼容的User-Agent（仅在必要时设置）\nfunc EnsureCompatibleUserAgent(headers http.Header, serviceType string) {\n\tuserAgent := headers.Get(\"User-Agent\")\n\n\t// 仅在Claude服务类型且用户未设置或设置不正确时才修改\n\tif serviceType == \"claude\" {\n\t\tif userAgent == \"\" || !strings.HasPrefix(strings.ToLower(userAgent), \"claude-cli\") {\n\t\t\theaders.Set(\"User-Agent\", \"claude-cli/2.0.34 (external, cli)\")\n\t\t}\n\t}\n}\n\n// ForwardResponseHeaders 转发上游响应头到客户端\n// 作为透明代理，应该转发所有响应头，只过滤框架自动处理的头部\nfunc ForwardResponseHeaders(upstreamHeaders http.Header, clientWriter http.ResponseWriter) {\n\t// 不应转发的头部列表（由框架或代理层自动处理）\n\tskipHeaders := map[string]bool{\n\t\t\"transfer-encoding\": true, // 由框架自动处理\n\t\t\"content-length\":    true, // 由框架自动处理\n\t\t\"connection\":        true, // 代理层控制\n\t\t\"content-encoding\":  true, // 如果已解压则不应转发\n\t}\n\n\t// 复制所有上游响应头到客户端\n\tfor key, values := range upstreamHeaders {\n\t\tlowerKey := strings.ToLower(key)\n\n\t\t// 跳过不应转发的头部\n\t\tif skipHeaders[lowerKey] {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 转发头部（可能有多个值）\n\t\tfor _, value := range values {\n\t\t\tclientWriter.Header().Add(key, value)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/utils/headers_test.go",
    "content": "package utils\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc TestPrepareUpstreamHeaders(t *testing.T) {\n\tgin.SetMode(gin.TestMode)\n\n\ttests := []struct {\n\t\tname        string\n\t\theaders     map[string]string\n\t\ttargetHost  string\n\t\twantHost    string\n\t\tshouldExist map[string]bool\n\t}{\n\t\t{\n\t\t\tname: \"移除代理相关头部\",\n\t\t\theaders: map[string]string{\n\t\t\t\t\"Content-Type\":      \"application/json\",\n\t\t\t\t\"x-proxy-key\":       \"secret\",\n\t\t\t\t\"X-Forwarded-Host\":  \"original.host\",\n\t\t\t\t\"X-Forwarded-Proto\": \"https\",\n\t\t\t},\n\t\t\ttargetHost: \"upstream.api.com\",\n\t\t\twantHost:   \"upstream.api.com\",\n\t\t\tshouldExist: map[string]bool{\n\t\t\t\t\"Content-Type\":      true,\n\t\t\t\t\"x-proxy-key\":       false,\n\t\t\t\t\"X-Forwarded-Host\":  false,\n\t\t\t\t\"X-Forwarded-Proto\": false,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"保留其他头部\",\n\t\t\theaders: map[string]string{\n\t\t\t\t\"Content-Type\":  \"application/json\",\n\t\t\t\t\"User-Agent\":    \"TestClient/1.0\",\n\t\t\t\t\"Accept\":        \"*/*\",\n\t\t\t\t\"Custom-Header\": \"custom-value\",\n\t\t\t},\n\t\t\ttargetHost: \"api.example.com\",\n\t\t\twantHost:   \"api.example.com\",\n\t\t\tshouldExist: map[string]bool{\n\t\t\t\t\"Content-Type\":  true,\n\t\t\t\t\"User-Agent\":    true,\n\t\t\t\t\"Accept\":        true,\n\t\t\t\t\"Custom-Header\": true,\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// 创建测试请求\n\t\t\treq := httptest.NewRequest(\"POST\", \"/test\", nil)\n\t\t\tfor k, v := range tt.headers {\n\t\t\t\treq.Header.Set(k, v)\n\t\t\t}\n\n\t\t\t// 创建Gin上下文\n\t\t\tw := httptest.NewRecorder()\n\t\t\tc, _ := gin.CreateTestContext(w)\n\t\t\tc.Request = req\n\n\t\t\t// 调用函数\n\t\t\tresult := PrepareUpstreamHeaders(c, tt.targetHost)\n\n\t\t\t// 验证Host头部\n\t\t\tif result.Get(\"Host\") != tt.wantHost {\n\t\t\t\tt.Errorf(\"Host = %v, want %v\", result.Get(\"Host\"), tt.wantHost)\n\t\t\t}\n\n\t\t\t// 验证头部是否存在\n\t\t\tfor header, shouldExist := range tt.shouldExist {\n\t\t\t\texists := result.Get(header) != \"\"\n\t\t\t\tif exists != shouldExist {\n\t\t\t\t\tt.Errorf(\"Header %s existence = %v, want %v\", header, exists, shouldExist)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSetAuthenticationHeader(t *testing.T) {\n\ttests := []struct {\n\t\tname              string\n\t\tapiKey            string\n\t\twantXApiKey       string\n\t\twantAuthorization string\n\t}{\n\t\t{\n\t\t\tname:              \"Claude官方格式密钥\",\n\t\t\tapiKey:            \"sk-ant-api03-1234567890\",\n\t\t\twantXApiKey:       \"sk-ant-api03-1234567890\",\n\t\t\twantAuthorization: \"\",\n\t\t},\n\t\t{\n\t\t\tname:              \"通用Bearer格式密钥\",\n\t\t\tapiKey:            \"sk-1234567890abcdef\",\n\t\t\twantXApiKey:       \"\",\n\t\t\twantAuthorization: \"Bearer sk-1234567890abcdef\",\n\t\t},\n\t\t{\n\t\t\tname:              \"其他格式密钥\",\n\t\t\tapiKey:            \"custom-key-format\",\n\t\t\twantXApiKey:       \"\",\n\t\t\twantAuthorization: \"Bearer custom-key-format\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\theaders := http.Header{}\n\t\t\tSetAuthenticationHeader(headers, tt.apiKey)\n\n\t\t\tif tt.wantXApiKey != \"\" {\n\t\t\t\tif got := headers.Get(\"x-api-key\"); got != tt.wantXApiKey {\n\t\t\t\t\tt.Errorf(\"x-api-key = %v, want %v\", got, tt.wantXApiKey)\n\t\t\t\t}\n\t\t\t\tif headers.Get(\"Authorization\") != \"\" {\n\t\t\t\t\tt.Errorf(\"Authorization should be empty, got %v\", headers.Get(\"Authorization\"))\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif got := headers.Get(\"Authorization\"); got != tt.wantAuthorization {\n\t\t\t\t\tt.Errorf(\"Authorization = %v, want %v\", got, tt.wantAuthorization)\n\t\t\t\t}\n\t\t\t\tif headers.Get(\"x-api-key\") != \"\" {\n\t\t\t\t\tt.Errorf(\"x-api-key should be empty, got %v\", headers.Get(\"x-api-key\"))\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSetGeminiAuthenticationHeader(t *testing.T) {\n\theaders := http.Header{}\n\tapiKey := \"AIzaSyABC123DEF456\"\n\n\tSetGeminiAuthenticationHeader(headers, apiKey)\n\n\tif got := headers.Get(\"x-goog-api-key\"); got != apiKey {\n\t\tt.Errorf(\"x-goog-api-key = %v, want %v\", got, apiKey)\n\t}\n\n\t// 验证其他认证头被删除\n\tif headers.Get(\"authorization\") != \"\" {\n\t\tt.Errorf(\"authorization should be empty, got %v\", headers.Get(\"authorization\"))\n\t}\n\tif headers.Get(\"x-api-key\") != \"\" {\n\t\tt.Errorf(\"x-api-key should be empty, got %v\", headers.Get(\"x-api-key\"))\n\t}\n}\n\nfunc TestEnsureCompatibleUserAgent(t *testing.T) {\n\ttests := []struct {\n\t\tname            string\n\t\tserviceType     string\n\t\tinitialUA       string\n\t\texpectedUA      string\n\t\tshouldBeChanged bool\n\t}{\n\t\t{\n\t\t\tname:            \"Claude服务 - 空User-Agent\",\n\t\t\tserviceType:     \"claude\",\n\t\t\tinitialUA:       \"\",\n\t\t\texpectedUA:      \"claude-cli/2.0.34 (external, cli)\",\n\t\t\tshouldBeChanged: true,\n\t\t},\n\t\t{\n\t\t\tname:            \"Claude服务 - 非Claude-CLI User-Agent\",\n\t\t\tserviceType:     \"claude\",\n\t\t\tinitialUA:       \"Mozilla/5.0\",\n\t\t\texpectedUA:      \"claude-cli/2.0.34 (external, cli)\",\n\t\t\tshouldBeChanged: true,\n\t\t},\n\t\t{\n\t\t\tname:            \"Claude服务 - 已有Claude-CLI User-Agent\",\n\t\t\tserviceType:     \"claude\",\n\t\t\tinitialUA:       \"claude-cli/2.0.34 (external, cli)\",\n\t\t\texpectedUA:      \"claude-cli/2.0.34 (external, cli)\",\n\t\t\tshouldBeChanged: false,\n\t\t},\n\t\t{\n\t\t\tname:            \"非Claude服务 - 保留原User-Agent\",\n\t\t\tserviceType:     \"openai\",\n\t\t\tinitialUA:       \"CustomClient/1.0\",\n\t\t\texpectedUA:      \"CustomClient/1.0\",\n\t\t\tshouldBeChanged: false,\n\t\t},\n\t\t{\n\t\t\tname:            \"Gemini服务 - 保留原User-Agent\",\n\t\t\tserviceType:     \"gemini\",\n\t\t\tinitialUA:       \"GeminiClient/2.0\",\n\t\t\texpectedUA:      \"GeminiClient/2.0\",\n\t\t\tshouldBeChanged: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\theaders := http.Header{}\n\t\t\tif tt.initialUA != \"\" {\n\t\t\t\theaders.Set(\"User-Agent\", tt.initialUA)\n\t\t\t}\n\n\t\t\tEnsureCompatibleUserAgent(headers, tt.serviceType)\n\n\t\t\tgot := headers.Get(\"User-Agent\")\n\t\t\tif got != tt.expectedUA {\n\t\t\t\tt.Errorf(\"User-Agent = %v, want %v\", got, tt.expectedUA)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/utils/json.go",
    "content": "package utils\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"strings\"\n)\n\n// MarshalJSONNoEscape 序列化 JSON 并禁用 HTML 字符转义\n// 使用 json.Encoder + SetEscapeHTML(false) 避免将 <, >, & 等字符转义为 \\u003c 等\n// 返回去除末尾换行符的字节数组\nfunc MarshalJSONNoEscape(v interface{}) ([]byte, error) {\n\tvar buf bytes.Buffer\n\tencoder := json.NewEncoder(&buf)\n\tencoder.SetEscapeHTML(false)\n\tif err := encoder.Encode(v); err != nil {\n\t\treturn nil, err\n\t}\n\t// json.Encoder.Encode 会在末尾添加换行符，需要去掉\n\treturn bytes.TrimSuffix(buf.Bytes(), []byte(\"\\n\")), nil\n}\n\n// TruncateJSONIntelligently 智能截断JSON中的长文本内容,保持结构完整\n// 只截断字符串值,不影响JSON结构\nfunc TruncateJSONIntelligently(data interface{}, maxTextLength int) interface{} {\n\tif data == nil {\n\t\treturn nil\n\t}\n\n\tswitch v := data.(type) {\n\tcase string:\n\t\tif len(v) > maxTextLength {\n\t\t\treturn v[:maxTextLength] + \"...\"\n\t\t}\n\t\treturn v\n\n\tcase float64, int, int64, bool:\n\t\treturn v\n\n\tcase []interface{}:\n\t\tresult := make([]interface{}, len(v))\n\t\tfor i, item := range v {\n\t\t\tresult[i] = TruncateJSONIntelligently(item, maxTextLength)\n\t\t}\n\t\treturn result\n\n\tcase map[string]interface{}:\n\t\tresult := make(map[string]interface{}, len(v))\n\t\tfor key, value := range v {\n\t\t\tresult[key] = TruncateJSONIntelligently(value, maxTextLength)\n\t\t}\n\t\treturn result\n\n\tdefault:\n\t\treturn v\n\t}\n}\n\n// SimplifyToolsArray 简化tools数组为名称列表,减少日志输出\n// 将完整的工具定义简化为只显示工具名称\nfunc SimplifyToolsArray(data interface{}) interface{} {\n\tif data == nil {\n\t\treturn nil\n\t}\n\n\tswitch v := data.(type) {\n\tcase []interface{}:\n\t\tresult := make([]interface{}, len(v))\n\t\tfor i, item := range v {\n\t\t\tresult[i] = SimplifyToolsArray(item)\n\t\t}\n\t\treturn result\n\n\tcase map[string]interface{}:\n\t\tresult := make(map[string]interface{}, len(v))\n\t\tfor key, value := range v {\n\t\t\t// 如果是tools字段且是数组,提取工具名称\n\t\t\tif key == \"tools\" {\n\t\t\t\tif toolsArray, ok := value.([]interface{}); ok {\n\t\t\t\t\tresult[key] = extractToolNames(toolsArray)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t\t// 如果是content字段且是数组,标记为需要紧凑显示\n\t\t\tif key == \"content\" {\n\t\t\t\tif contentArray, ok := value.([]interface{}); ok {\n\t\t\t\t\tresult[key] = compactContentArray(contentArray)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t\t// 如果是contents字段（Gemini格式）且是数组,紧凑显示\n\t\t\tif key == \"contents\" {\n\t\t\t\tif contentsArray, ok := value.([]interface{}); ok {\n\t\t\t\t\tresult[key] = compactGeminiContentsArray(contentsArray)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t\tresult[key] = SimplifyToolsArray(value)\n\t\t}\n\t\treturn result\n\n\tdefault:\n\t\treturn v\n\t}\n}\n\n// compactContentArray 紧凑显示content数组\n// 只保留type和text/id/name等关键字段的简短摘要\nfunc compactContentArray(contents []interface{}) []interface{} {\n\tresult := make([]interface{}, len(contents))\n\tfor i, item := range contents {\n\t\tif contentMap, ok := item.(map[string]interface{}); ok {\n\t\t\tcompact := make(map[string]interface{})\n\n\t\t\t// 保留type字段\n\t\t\tif contentType, ok := contentMap[\"type\"].(string); ok {\n\t\t\t\tcompact[\"type\"] = contentType\n\n\t\t\t\t// 根据类型保留关键信息\n\t\t\t\tswitch contentType {\n\t\t\t\tcase \"text\":\n\t\t\t\t\tif text, ok := contentMap[\"text\"].(string); ok {\n\t\t\t\t\t\t// 文本内容截断到前200个字符\n\t\t\t\t\t\tif len(text) > 200 {\n\t\t\t\t\t\t\tcompact[\"text\"] = text[:200] + \"...\"\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tcompact[\"text\"] = text\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\tcase \"input_text\", \"output_text\":\n\t\t\t\t\t// Responses API 的 input/output 类型\n\t\t\t\t\tif text, ok := contentMap[\"text\"].(string); ok {\n\t\t\t\t\t\tif len(text) > 200 {\n\t\t\t\t\t\t\tcompact[\"text\"] = text[:200] + \"...\"\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tcompact[\"text\"] = text\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\tcase \"tool_use\":\n\t\t\t\t\tif id, ok := contentMap[\"id\"].(string); ok {\n\t\t\t\t\t\tcompact[\"id\"] = id\n\t\t\t\t\t}\n\t\t\t\t\tif name, ok := contentMap[\"name\"].(string); ok {\n\t\t\t\t\t\tcompact[\"name\"] = name\n\t\t\t\t\t}\n\t\t\t\t\t// input字段紧凑显示 - 保留结构但截断长字符串值\n\t\t\t\t\tif input, ok := contentMap[\"input\"]; ok {\n\t\t\t\t\t\tcompactInput := truncateInputValues(input, 200)\n\t\t\t\t\t\tcompact[\"input\"] = compactInput\n\t\t\t\t\t}\n\t\t\t\tcase \"tool_result\":\n\t\t\t\t\tif toolUseID, ok := contentMap[\"tool_use_id\"].(string); ok {\n\t\t\t\t\t\tcompact[\"tool_use_id\"] = toolUseID\n\t\t\t\t\t}\n\t\t\t\t\t// content字段显示前200字符\n\t\t\t\t\tif content, ok := contentMap[\"content\"].(string); ok {\n\t\t\t\t\t\tif len(content) > 200 {\n\t\t\t\t\t\t\tcompact[\"content\"] = content[:200] + \"...\"\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tcompact[\"content\"] = content\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif isError, ok := contentMap[\"is_error\"].(bool); ok {\n\t\t\t\t\t\tcompact[\"is_error\"] = isError\n\t\t\t\t\t}\n\t\t\t\tcase \"image\":\n\t\t\t\t\tif source, ok := contentMap[\"source\"].(map[string]interface{}); ok {\n\t\t\t\t\t\tcompact[\"source\"] = map[string]interface{}{\n\t\t\t\t\t\t\t\"type\": source[\"type\"],\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\tcase \"reasoning\":\n\t\t\t\t\t// Codex Responses API 的 reasoning 类型\n\t\t\t\t\t// 保留 summary，截断 encrypted_content\n\t\t\t\t\tif summary, ok := contentMap[\"summary\"]; ok {\n\t\t\t\t\t\tcompact[\"summary\"] = summary\n\t\t\t\t\t}\n\t\t\t\t\tif encryptedContent, ok := contentMap[\"encrypted_content\"].(string); ok {\n\t\t\t\t\t\tif len(encryptedContent) > 100 {\n\t\t\t\t\t\t\tcompact[\"encrypted_content\"] = encryptedContent[:100] + \"...\"\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tcompact[\"encrypted_content\"] = encryptedContent\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\t// 保留其他可能的字段（如 content）\n\t\t\t\t\tif content, ok := contentMap[\"content\"]; ok {\n\t\t\t\t\t\tcompact[\"content\"] = content\n\t\t\t\t\t}\n\t\t\t\tcase \"function_call\":\n\t\t\t\t\t// Codex Responses API 的 function_call 类型\n\t\t\t\t\tif callID, ok := contentMap[\"call_id\"].(string); ok {\n\t\t\t\t\t\tcompact[\"call_id\"] = callID\n\t\t\t\t\t}\n\t\t\t\t\tif name, ok := contentMap[\"name\"].(string); ok {\n\t\t\t\t\t\tcompact[\"name\"] = name\n\t\t\t\t\t}\n\t\t\t\t\t// arguments 字段截断显示\n\t\t\t\t\tif args, ok := contentMap[\"arguments\"].(string); ok {\n\t\t\t\t\t\tif len(args) > 200 {\n\t\t\t\t\t\t\tcompact[\"arguments\"] = args[:200] + \"...\"\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tcompact[\"arguments\"] = args\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\tcase \"function_call_output\":\n\t\t\t\t\t// Codex Responses API 的 function_call_output 类型\n\t\t\t\t\tif callID, ok := contentMap[\"call_id\"].(string); ok {\n\t\t\t\t\t\tcompact[\"call_id\"] = callID\n\t\t\t\t\t}\n\t\t\t\t\t// output 字段截断显示\n\t\t\t\t\tif output, ok := contentMap[\"output\"].(string); ok {\n\t\t\t\t\t\tif len(output) > 200 {\n\t\t\t\t\t\t\tcompact[\"output\"] = output[:200] + \"...\"\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tcompact[\"output\"] = output\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tresult[i] = compact\n\t\t} else {\n\t\t\tresult[i] = item\n\t\t}\n\t}\n\treturn result\n}\n\n// compactGeminiContentsArray 紧凑显示Gemini contents数组\n// Gemini格式: contents[].{role, parts[].{text, functionCall, functionResponse}}\nfunc compactGeminiContentsArray(contents []interface{}) []interface{} {\n\tresult := make([]interface{}, len(contents))\n\tfor i, item := range contents {\n\t\tif contentMap, ok := item.(map[string]interface{}); ok {\n\t\t\tcompact := make(map[string]interface{})\n\n\t\t\t// 保留role字段\n\t\t\tif role, ok := contentMap[\"role\"].(string); ok {\n\t\t\t\tcompact[\"role\"] = role\n\t\t\t}\n\n\t\t\t// 处理parts数组\n\t\t\tif parts, ok := contentMap[\"parts\"].([]interface{}); ok {\n\t\t\t\tcompactParts := make([]interface{}, len(parts))\n\t\t\t\tfor j, part := range parts {\n\t\t\t\t\tif partMap, ok := part.(map[string]interface{}); ok {\n\t\t\t\t\t\tcompactPart := compactGeminiPart(partMap)\n\t\t\t\t\t\tcompactParts[j] = compactPart\n\t\t\t\t\t} else {\n\t\t\t\t\t\tcompactParts[j] = part\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tcompact[\"parts\"] = compactParts\n\t\t\t}\n\n\t\t\tresult[i] = compact\n\t\t} else {\n\t\t\tresult[i] = item\n\t\t}\n\t}\n\treturn result\n}\n\n// compactGeminiPart 紧凑显示单个Gemini part\nfunc compactGeminiPart(partMap map[string]interface{}) map[string]interface{} {\n\tcompact := make(map[string]interface{})\n\n\t// 处理text字段\n\tif text, ok := partMap[\"text\"].(string); ok {\n\t\tif len(text) > 200 {\n\t\t\tcompact[\"text\"] = text[:200] + \"...\"\n\t\t} else {\n\t\t\tcompact[\"text\"] = text\n\t\t}\n\t}\n\n\t// 处理functionCall字段\n\tif fc, ok := partMap[\"functionCall\"].(map[string]interface{}); ok {\n\t\tcompactFC := make(map[string]interface{})\n\t\tif name, ok := fc[\"name\"].(string); ok {\n\t\t\tcompactFC[\"name\"] = name\n\t\t}\n\t\t// args字段紧凑显示\n\t\tif args, ok := fc[\"args\"]; ok {\n\t\t\tcompactFC[\"args\"] = truncateInputValues(args, 200)\n\t\t}\n\t\tcompact[\"functionCall\"] = compactFC\n\t}\n\n\t// 处理functionResponse字段\n\tif fr, ok := partMap[\"functionResponse\"].(map[string]interface{}); ok {\n\t\tcompactFR := make(map[string]interface{})\n\t\tif name, ok := fr[\"name\"].(string); ok {\n\t\t\tcompactFR[\"name\"] = name\n\t\t}\n\t\t// response字段紧凑显示\n\t\tif response, ok := fr[\"response\"]; ok {\n\t\t\tcompactFR[\"response\"] = truncateInputValues(response, 200)\n\t\t}\n\t\tcompact[\"functionResponse\"] = compactFR\n\t}\n\n\t// 处理inlineData字段（图片等）\n\tif inlineData, ok := partMap[\"inlineData\"].(map[string]interface{}); ok {\n\t\tcompactInline := make(map[string]interface{})\n\t\tif mimeType, ok := inlineData[\"mimeType\"].(string); ok {\n\t\t\tcompactInline[\"mimeType\"] = mimeType\n\t\t}\n\t\t// data字段只显示前50个字符\n\t\tif data, ok := inlineData[\"data\"].(string); ok {\n\t\t\tif len(data) > 50 {\n\t\t\t\tcompactInline[\"data\"] = data[:50] + \"...[base64]\"\n\t\t\t} else {\n\t\t\t\tcompactInline[\"data\"] = data\n\t\t\t}\n\t\t}\n\t\tcompact[\"inlineData\"] = compactInline\n\t}\n\n\t// 处理fileData字段\n\tif fileData, ok := partMap[\"fileData\"].(map[string]interface{}); ok {\n\t\tcompact[\"fileData\"] = fileData\n\t}\n\n\t// 处理thought字段\n\tif thought, ok := partMap[\"thought\"].(bool); ok && thought {\n\t\tcompact[\"thought\"] = thought\n\t}\n\n\treturn compact\n}\n\n// truncateInputValues 递归截断input对象中的长字符串值\n// 保留JSON结构,只截断字符串值到指定长度\nfunc truncateInputValues(data interface{}, maxLength int) interface{} {\n\tswitch v := data.(type) {\n\tcase string:\n\t\tif len(v) > maxLength {\n\t\t\treturn v[:maxLength] + \"...\"\n\t\t}\n\t\treturn v\n\n\tcase map[string]interface{}:\n\t\tresult := make(map[string]interface{}, len(v))\n\t\tfor key, value := range v {\n\t\t\tresult[key] = truncateInputValues(value, maxLength)\n\t\t}\n\t\treturn result\n\n\tcase []interface{}:\n\t\tresult := make([]interface{}, len(v))\n\t\tfor i, item := range v {\n\t\t\tresult[i] = truncateInputValues(item, maxLength)\n\t\t}\n\t\treturn result\n\n\tdefault:\n\t\treturn v\n\t}\n}\n\n// extractToolNames 从tools数组中提取所有工具名称\n// 支持Claude格式、OpenAI格式和Gemini格式\nfunc extractToolNames(toolsArray []interface{}) []interface{} {\n\tvar names []interface{}\n\n\tfor _, tool := range toolsArray {\n\t\ttoolMap, ok := tool.(map[string]interface{})\n\t\tif !ok {\n\t\t\t// 如果不是 map，可能已经是简化后的名称字符串\n\t\t\tnames = append(names, tool)\n\t\t\tcontinue\n\t\t}\n\n\t\t// Gemini格式: tool.functionDeclarations[].name\n\t\tif funcDecls, ok := toolMap[\"functionDeclarations\"].([]interface{}); ok {\n\t\t\tfor _, funcDecl := range funcDecls {\n\t\t\t\tif declMap, ok := funcDecl.(map[string]interface{}); ok {\n\t\t\t\t\tif name, ok := declMap[\"name\"].(string); ok {\n\t\t\t\t\t\tnames = append(names, name)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\t// Claude格式: tool.name\n\t\tif name, ok := toolMap[\"name\"].(string); ok {\n\t\t\tnames = append(names, name)\n\t\t\tcontinue\n\t\t}\n\n\t\t// OpenAI格式: tool.function.name\n\t\tif function, ok := toolMap[\"function\"].(map[string]interface{}); ok {\n\t\t\tif name, ok := function[\"name\"].(string); ok {\n\t\t\t\tnames = append(names, name)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\t// 未知格式，保留原始对象\n\t\tnames = append(names, tool)\n\t}\n\n\treturn names\n}\n\n// extractToolName 从工具定义中提取名称（保留用于兼容）\n// 支持Claude格式(tool.name)和OpenAI格式(tool.function.name)\nfunc extractToolName(tool interface{}) interface{} {\n\ttoolMap, ok := tool.(map[string]interface{})\n\tif !ok {\n\t\treturn tool\n\t}\n\n\t// 检查Claude格式: tool.name\n\tif name, ok := toolMap[\"name\"].(string); ok {\n\t\treturn name\n\t}\n\n\t// 检查OpenAI格式: tool.function.name\n\tif function, ok := toolMap[\"function\"].(map[string]interface{}); ok {\n\t\tif name, ok := function[\"name\"].(string); ok {\n\t\t\treturn name\n\t\t}\n\t}\n\n\treturn tool\n}\n\n// SimplifyToolsInJSON 简化JSON字节数组中的tools字段\n// 这是一个便利函数,直接处理JSON字节\nfunc SimplifyToolsInJSON(jsonData []byte) []byte {\n\tvar data interface{}\n\tif err := json.Unmarshal(jsonData, &data); err != nil {\n\t\treturn jsonData // 如果不是有效JSON,返回原始数据\n\t}\n\n\tsimplifiedData := SimplifyToolsArray(data)\n\n\tsimplifiedBytes, err := json.Marshal(simplifiedData)\n\tif err != nil {\n\t\treturn jsonData // 如果序列化失败,返回原始数据\n\t}\n\n\treturn simplifiedBytes\n}\n\n// FormatJSONForLog 格式化JSON用于日志输出\n// 先简化tools,再截断长文本,最后美化格式\nfunc FormatJSONForLog(data interface{}, maxTextLength int) string {\n\t// 先简化tools和content数组\n\tsimplified := SimplifyToolsArray(data)\n\t// 再截断长文本\n\ttruncated := TruncateJSONIntelligently(simplified, maxTextLength)\n\n\t// 使用自定义格式化来实现content数组的紧凑显示\n\tresult := formatJSONWithCompactArrays(truncated, \"\", 0)\n\n\treturn result\n}\n\n// formatMapAsOneLine 将map格式化为单行JSON\nfunc formatMapAsOneLine(m map[string]interface{}) string {\n\tif len(m) == 0 {\n\t\treturn \"{}\"\n\t}\n\n\tvar pairs []string\n\t// 按照特定顺序输出字段（type优先，然后其他字段）\n\tif typeVal, ok := m[\"type\"]; ok {\n\t\ttypeJSON, _ := json.Marshal(typeVal)\n\t\tpairs = append(pairs, `\"type\": `+string(typeJSON))\n\t}\n\n\t// 其他字段按字母顺序\n\tfor k, v := range m {\n\t\tif k == \"type\" {\n\t\t\tcontinue // 已经处理过\n\t\t}\n\t\tkeyJSON, _ := json.Marshal(k)\n\n\t\t// 对于input字段，使用紧凑的单行显示\n\t\tif k == \"input\" {\n\t\t\tif inputMap, ok := v.(map[string]interface{}); ok {\n\t\t\t\tvalueStr := formatInputMapCompact(inputMap)\n\t\t\t\tpairs = append(pairs, string(keyJSON)+\": \"+valueStr)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\t// 对于长字符串字段（如 encrypted_content, arguments, output），进行截断\n\t\tif k == \"encrypted_content\" || k == \"arguments\" || k == \"output\" || k == \"text\" {\n\t\t\tif strVal, ok := v.(string); ok {\n\t\t\t\tmaxLen := 100\n\t\t\t\tif k == \"arguments\" || k == \"output\" || k == \"text\" {\n\t\t\t\t\tmaxLen = 200\n\t\t\t\t}\n\t\t\t\tif len(strVal) > maxLen {\n\t\t\t\t\ttruncated := strVal[:maxLen] + \"...\"\n\t\t\t\t\tvalueJSON, _ := json.Marshal(truncated)\n\t\t\t\t\tpairs = append(pairs, string(keyJSON)+\": \"+string(valueJSON))\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tvalueJSON, _ := json.Marshal(v)\n\t\tpairs = append(pairs, string(keyJSON)+\": \"+string(valueJSON))\n\t}\n\n\treturn \"{\" + strings.Join(pairs, \", \") + \"}\"\n}\n\n// formatInputMapCompact 将input map紧凑格式化为单行\nfunc formatInputMapCompact(m map[string]interface{}) string {\n\tif len(m) == 0 {\n\t\treturn \"{}\"\n\t}\n\n\tvar pairs []string\n\tfor k, v := range m {\n\t\tkeyJSON, _ := json.Marshal(k)\n\t\tvalueJSON, _ := json.Marshal(v)\n\t\tpairs = append(pairs, string(keyJSON)+\": \"+string(valueJSON))\n\t}\n\n\treturn \"{\" + strings.Join(pairs, \", \") + \"}\"\n}\n\n// formatMessageAsOneLine 将message对象（包含role和content/parts）格式化为紧凑的一行\n// 支持Claude格式：{role: \"user\", content: [...]}\n// 支持Gemini格式：{role: \"user\", parts: [...]}\nfunc formatMessageAsOneLine(m map[string]interface{}) string {\n\tvar parts []string\n\n\t// 先输出role\n\tif role, ok := m[\"role\"]; ok {\n\t\troleJSON, _ := json.Marshal(role)\n\t\tparts = append(parts, `\"role\": `+string(roleJSON))\n\t}\n\n\t// 处理content字段（Claude格式）\n\tif content, ok := m[\"content\"]; ok {\n\t\t// 如果content是字符串，直接输出\n\t\tif contentStr, isString := content.(string); isString {\n\t\t\tcontentJSON, _ := json.Marshal(contentStr)\n\t\t\tparts = append(parts, `\"content\": `+string(contentJSON))\n\t\t} else if contentArray, isArray := content.([]interface{}); isArray {\n\t\t\t// content数组已经是紧凑格式，直接格式化\n\t\t\tcontentItems := make([]string, len(contentArray))\n\t\t\tfor i, item := range contentArray {\n\t\t\t\tif itemMap, ok := item.(map[string]interface{}); ok {\n\t\t\t\t\tcontentItems[i] = formatMapAsOneLine(itemMap)\n\t\t\t\t} else {\n\t\t\t\t\titemJSON, _ := json.Marshal(item)\n\t\t\t\t\tcontentItems[i] = string(itemJSON)\n\t\t\t\t}\n\t\t\t}\n\t\t\tparts = append(parts, `\"content\": [`+strings.Join(contentItems, \", \")+`]`)\n\t\t}\n\t}\n\n\t// 处理parts字段（Gemini格式）\n\tif partsField, ok := m[\"parts\"]; ok {\n\t\tif partsArray, isArray := partsField.([]interface{}); isArray {\n\t\t\tpartsItems := make([]string, len(partsArray))\n\t\t\tfor i, item := range partsArray {\n\t\t\t\tif itemMap, ok := item.(map[string]interface{}); ok {\n\t\t\t\t\tpartsItems[i] = formatMapAsOneLine(itemMap)\n\t\t\t\t} else {\n\t\t\t\t\titemJSON, _ := json.Marshal(item)\n\t\t\t\t\tpartsItems[i] = string(itemJSON)\n\t\t\t\t}\n\t\t\t}\n\t\t\tparts = append(parts, `\"parts\": [`+strings.Join(partsItems, \", \")+`]`)\n\t\t}\n\t}\n\n\treturn \"{\" + strings.Join(parts, \", \") + \"}\"\n}\n\n// formatJSONWithCompactArrays 自定义JSON格式化,对content数组使用紧凑单行显示\nfunc formatJSONWithCompactArrays(data interface{}, indent string, depth int) string {\n\tswitch v := data.(type) {\n\tcase nil:\n\t\treturn \"null\"\n\n\tcase bool:\n\t\tif v {\n\t\t\treturn \"true\"\n\t\t}\n\t\treturn \"false\"\n\n\tcase float64:\n\t\tbytes, _ := json.Marshal(v)\n\t\treturn string(bytes)\n\n\tcase string:\n\t\tbytes, _ := json.Marshal(v)\n\t\treturn string(bytes)\n\n\tcase []interface{}:\n\t\tif len(v) == 0 {\n\t\t\treturn \"[]\"\n\t\t}\n\n\t\t// 检查是否是已经紧凑化的content数组\n\t\tisCompactContent := false\n\t\tisInputArray := false\n\t\tisToolsArray := false\n\n\t\tif len(v) > 0 {\n\t\t\t// 检查第一个元素判断数组类型\n\t\t\tif firstItem, ok := v[0].(map[string]interface{}); ok {\n\t\t\t\tif typeVal, ok := firstItem[\"type\"].(string); ok {\n\t\t\t\t\t// 如果第一个元素有type字段,且看起来是content项,使用紧凑格式\n\t\t\t\t\tif typeVal == \"text\" || typeVal == \"tool_use\" || typeVal == \"tool_result\" || typeVal == \"image\" ||\n\t\t\t\t\t\ttypeVal == \"input_text\" || typeVal == \"output_text\" {\n\t\t\t\t\t\tisCompactContent = true\n\t\t\t\t\t}\n\t\t\t\t\t// 检查是否是 Codex input 数组中的特殊类型对象\n\t\t\t\t\t// 这些对象应该被单独压缩成一行\n\t\t\t\t\tif typeVal == \"reasoning\" || typeVal == \"function_call\" || typeVal == \"function_call_output\" {\n\t\t\t\t\t\tisCompactContent = true\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// 检查是否是 input 数组（包含 message 对象，有 role 字段）\n\t\t\t\t// 或者包含 Codex 特殊类型对象\n\t\t\t\tif _, hasRole := firstItem[\"role\"]; hasRole {\n\t\t\t\t\tisInputArray = true\n\t\t\t\t} else if typeVal, ok := firstItem[\"type\"].(string); ok {\n\t\t\t\t\t// 如果数组包含 reasoning/function_call 等类型，也当作 input 数组处理\n\t\t\t\t\tif typeVal == \"reasoning\" || typeVal == \"function_call\" || typeVal == \"function_call_output\" {\n\t\t\t\t\t\tisInputArray = true\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if _, ok := v[0].(string); ok {\n\t\t\t\t// 如果数组元素都是字符串,可能是tools数组（已简化为工具名）\n\t\t\t\tisToolsArray = true\n\t\t\t\t// 验证是否所有元素都是字符串\n\t\t\t\tfor _, item := range v {\n\t\t\t\t\tif _, ok := item.(string); !ok {\n\t\t\t\t\t\tisToolsArray = false\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif isCompactContent {\n\t\t\t// 紧凑单行显示 - 每个content项压缩为单行\n\t\t\titems := make([]string, len(v))\n\t\t\tfor i, item := range v {\n\t\t\t\t// 将单个content项格式化为单行JSON\n\t\t\t\tif itemMap, ok := item.(map[string]interface{}); ok {\n\t\t\t\t\tcompactItem := formatMapAsOneLine(itemMap)\n\t\t\t\t\titems[i] = compactItem\n\t\t\t\t} else {\n\t\t\t\t\titems[i] = formatJSONWithCompactArrays(item, \"\", depth+1)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn \"[\\n\" + indent + \"  \" + strings.Join(items, \",\\n\"+indent+\"  \") + \"\\n\" + indent + \"]\"\n\t\t}\n\n\t\tif isInputArray {\n\t\t\t// input 数组（包含 message 对象和特殊类型对象）使用紧凑单行显示\n\t\t\titems := make([]string, len(v))\n\t\t\tfor i, item := range v {\n\t\t\t\tif itemMap, ok := item.(map[string]interface{}); ok {\n\t\t\t\t\t// 检查是否是 message 对象（有 role 字段）\n\t\t\t\t\tif _, hasRole := itemMap[\"role\"]; hasRole {\n\t\t\t\t\t\titems[i] = formatMessageAsOneLine(itemMap)\n\t\t\t\t\t} else if typeVal, hasType := itemMap[\"type\"].(string); hasType {\n\t\t\t\t\t\t// 检查是否是特殊类型对象（reasoning, function_call 等）\n\t\t\t\t\t\tif typeVal == \"reasoning\" || typeVal == \"function_call\" || typeVal == \"function_call_output\" {\n\t\t\t\t\t\t\titems[i] = formatMapAsOneLine(itemMap)\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\titems[i] = formatJSONWithCompactArrays(item, \"\", depth+1)\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\titems[i] = formatJSONWithCompactArrays(item, \"\", depth+1)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\titems[i] = formatJSONWithCompactArrays(item, \"\", depth+1)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn \"[\\n\" + indent + \"  \" + strings.Join(items, \",\\n\"+indent+\"  \") + \"\\n\" + indent + \"]\"\n\t\t}\n\n\t\tif isToolsArray {\n\t\t\t// tools数组使用紧凑的单行显示\n\t\t\titems := make([]string, len(v))\n\t\t\tfor i, item := range v {\n\t\t\t\titemJSON, _ := json.Marshal(item)\n\t\t\t\titems[i] = string(itemJSON)\n\t\t\t}\n\t\t\t// 始终使用单行显示所有工具\n\t\t\treturn \"[\" + strings.Join(items, \", \") + \"]\"\n\t\t}\n\n\t\t// 普通数组的多行显示\n\t\titems := make([]string, len(v))\n\t\tfor i, item := range v {\n\t\t\titems[i] = indent + \"  \" + formatJSONWithCompactArrays(item, indent+\"  \", depth+1)\n\t\t}\n\t\treturn \"[\\n\" + strings.Join(items, \",\\n\") + \"\\n\" + indent + \"]\"\n\n\tcase map[string]interface{}:\n\t\tif len(v) == 0 {\n\t\t\treturn \"{}\"\n\t\t}\n\n\t\t// 检查是否是message对象（包含role和content字段）\n\t\tif _, hasRole := v[\"role\"]; hasRole {\n\t\t\tif _, hasContent := v[\"content\"]; hasContent {\n\t\t\t\t// 这是一个message对象，使用紧凑的单行显示\n\t\t\t\treturn formatMessageAsOneLine(v)\n\t\t\t}\n\t\t}\n\n\t\t// 检查是否是包含 type 字段的特殊对象（reasoning, function_call, function_call_output 等）\n\t\tif typeVal, hasType := v[\"type\"].(string); hasType {\n\t\t\t// 这些类型的对象使用紧凑的单行显示\n\t\t\tif typeVal == \"reasoning\" || typeVal == \"function_call\" || typeVal == \"function_call_output\" ||\n\t\t\t\ttypeVal == \"text\" || typeVal == \"tool_use\" || typeVal == \"tool_result\" || typeVal == \"image\" ||\n\t\t\t\ttypeVal == \"input_text\" || typeVal == \"output_text\" {\n\t\t\t\treturn formatMapAsOneLine(v)\n\t\t\t}\n\t\t}\n\n\t\t// 对于普通map,使用多行显示\n\t\tvar keys []string\n\t\tfor k := range v {\n\t\t\tkeys = append(keys, k)\n\t\t}\n\n\t\titems := make([]string, len(keys))\n\t\tfor i, k := range keys {\n\t\t\tvalue := formatJSONWithCompactArrays(v[k], indent+\"  \", depth+1)\n\t\t\tkeyJSON, _ := json.Marshal(k)\n\t\t\titems[i] = indent + \"  \" + string(keyJSON) + \": \" + value\n\t\t}\n\t\treturn \"{\\n\" + strings.Join(items, \",\\n\") + \"\\n\" + indent + \"}\"\n\n\tdefault:\n\t\tbytes, _ := json.Marshal(v)\n\t\treturn string(bytes)\n\t}\n}\n\n// FormatJSONBytesForLog 格式化JSON字节数组用于日志输出\nfunc FormatJSONBytesForLog(jsonData []byte, maxTextLength int) string {\n\tvar data interface{}\n\tif err := json.Unmarshal(jsonData, &data); err != nil {\n\t\t// 如果不是有效JSON,按字符串处理\n\t\tstr := string(jsonData)\n\t\tif len(str) > 500 {\n\t\t\treturn str[:500] + \"...\"\n\t\t}\n\t\treturn str\n\t}\n\n\treturn FormatJSONForLog(data, maxTextLength)\n}\n\n// MaskSensitiveHeaders 脱敏敏感请求头\nfunc MaskSensitiveHeaders(headers map[string]string) map[string]string {\n\tsensitiveKeys := map[string]bool{\n\t\t\"authorization\":  true,\n\t\t\"x-api-key\":      true,\n\t\t\"x-goog-api-key\": true,\n\t}\n\n\tmasked := make(map[string]string, len(headers))\n\tfor key, value := range headers {\n\t\tlowerKey := strings.ToLower(key)\n\t\tif sensitiveKeys[lowerKey] {\n\t\t\tif lowerKey == \"authorization\" && strings.HasPrefix(value, \"Bearer \") {\n\t\t\t\ttoken := value[7:]\n\t\t\t\tmasked[key] = \"Bearer \" + MaskAPIKey(token)\n\t\t\t} else {\n\t\t\t\tmasked[key] = MaskAPIKey(value)\n\t\t\t}\n\t\t} else {\n\t\t\tmasked[key] = value\n\t\t}\n\t}\n\treturn masked\n}\n\n// MaskAPIKey 掩码API密钥\nfunc MaskAPIKey(key string) string {\n\tif key == \"\" {\n\t\treturn \"\"\n\t}\n\n\tlength := len(key)\n\tif length <= 10 {\n\t\tif length <= 5 {\n\t\t\treturn \"***\"\n\t\t}\n\t\treturn key[:3] + \"***\" + key[length-2:]\n\t}\n\n\treturn key[:8] + \"***\" + key[length-5:]\n}\n\n// FormatJSONBytesRaw 原始输出JSON字节数组（不缩进、不截断、不重排序）\nfunc FormatJSONBytesRaw(jsonData []byte) string {\n\treturn string(jsonData)\n}\n"
  },
  {
    "path": "backend-go/internal/utils/json_compact_test.go",
    "content": "package utils\n\nimport (\n\t\"encoding/json\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestCompactContentArray(t *testing.T) {\n\tinput := map[string]interface{}{\n\t\t\"model\": \"claude-3\",\n\t\t\"tools\": []interface{}{\"Tool1\", \"Tool2\", \"Tool3\"}, // 简化后的tools数组\n\t\t\"messages\": []interface{}{\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": []interface{}{\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\"text\": strings.Repeat(\"This is a very long text that should be truncated. \", 10),\n\t\t\t\t\t},\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"type\": \"tool_use\",\n\t\t\t\t\t\t\"id\":   \"toolu_123\",\n\t\t\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\t\t\"input\": map[string]interface{}{\n\t\t\t\t\t\t\t\"location\": \"San Francisco\",\n\t\t\t\t\t\t\t\"unit\":     \"celsius\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": []interface{}{\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"type\":        \"tool_result\",\n\t\t\t\t\t\t\"tool_use_id\": \"toolu_123\",\n\t\t\t\t\t\t\"content\":     \"Temperature: 18°C, Clear sky\",\n\t\t\t\t\t\t\"is_error\":    false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tresult := FormatJSONForLog(input, 500)\n\n\t// 验证content数组被紧凑显示\n\tif !strings.Contains(result, `\"type\": \"text\"`) {\n\t\tt.Error(\"应该包含type字段\")\n\t}\n\n\t// 验证文本被截断到200字符\n\tif strings.Contains(result, strings.Repeat(\"This is a very long text\", 8)) {\n\t\tt.Error(\"长文本应该被截断到200字符\")\n\t}\n\n\t// 验证tool_use的input显示JSON而不是{...}\n\tif !strings.Contains(result, `\"location\"`) || !strings.Contains(result, `\"San Francisco\"`) {\n\t\tt.Error(\"tool_use的input应该显示JSON内容\")\n\t}\n\n\t// 验证tools数组被紧凑显示（单行或少量换行）\n\tif strings.Contains(result, `\"tools\": [\"Tool1\", \"Tool2\", \"Tool3\"]`) ||\n\t\tstrings.Contains(result, `\"tools\": [\n  \"Tool1\", \"Tool2\", \"Tool3\"\n]`) {\n\t\tt.Log(\"[Test-OK] tools数组被紧凑显示\")\n\t}\n\n\t// 验证输出没有被截断（不应该出现\"需要�\"这种乱码）\n\tif strings.Contains(result, \"�\") {\n\t\tt.Error(\"输出包含乱码，可能是截断导致的\")\n\t}\n\n\tt.Logf(\"格式化后的输出:\\n%s\", result)\n}\n\nfunc TestContentArrayCompactFormat(t *testing.T) {\n\t// 测试各种content类型的紧凑显示\n\ttests := []struct {\n\t\tname    string\n\t\tcontent []interface{}\n\t\tchecks  []string // 应该包含的内容\n\t}{\n\t\t{\n\t\t\tname: \"文本类型 - 长文本截断\",\n\t\t\tcontent: []interface{}{\n\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"text\": strings.Repeat(\"This is a very long text that exceeds 200 characters and should be truncated. \", 5),\n\t\t\t\t},\n\t\t\t},\n\t\t\tchecks: []string{\n\t\t\t\t`\"type\": \"text\"`,\n\t\t\t\t// 文本应该被截断到200字符，包含省略号\n\t\t\t\t`...`,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"工具使用类型\",\n\t\t\tcontent: []interface{}{\n\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\"type\": \"tool_use\",\n\t\t\t\t\t\"id\":   \"toolu_abc123\",\n\t\t\t\t\t\"name\": \"calculator\",\n\t\t\t\t\t\"input\": map[string]interface{}{\n\t\t\t\t\t\t\"expression\": \"2 + 2\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tchecks: []string{\n\t\t\t\t`\"type\": \"tool_use\"`,\n\t\t\t\t`\"id\": \"toolu_abc123\"`,\n\t\t\t\t`\"name\": \"calculator\"`,\n\t\t\t\t// input应该显示JSON内容而不是{...}\n\t\t\t\t`\"expression\"`,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"工具结果类型\",\n\t\t\tcontent: []interface{}{\n\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\"type\":        \"tool_result\",\n\t\t\t\t\t\"tool_use_id\": \"toolu_abc123\",\n\t\t\t\t\t\"content\":     \"Result: 4\",\n\t\t\t\t\t\"is_error\":    false,\n\t\t\t\t},\n\t\t\t},\n\t\t\tchecks: []string{\n\t\t\t\t`\"type\": \"tool_result\"`,\n\t\t\t\t`\"tool_use_id\": \"toolu_abc123\"`,\n\t\t\t\t`\"content\": \"Result: 4\"`,\n\t\t\t\t`\"is_error\": false`,\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tinput := map[string]interface{}{\n\t\t\t\t\"messages\": []interface{}{\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"role\":    \"user\",\n\t\t\t\t\t\t\"content\": tt.content,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tresult := FormatJSONForLog(input, 500)\n\n\t\t\tfor _, check := range tt.checks {\n\t\t\t\tif !strings.Contains(result, check) {\n\t\t\t\t\tt.Errorf(\"输出应该包含: %s\\n实际输出:\\n%s\", check, result)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// 验证没有乱码\n\t\t\tif strings.Contains(result, \"�\") {\n\t\t\t\tt.Error(\"输出包含乱码\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNoTruncationInMiddleOfJSON(t *testing.T) {\n\t// 创建一个超大的JSON对象来测试截断逻辑\n\tlargeMessages := make([]interface{}, 100)\n\tfor i := 0; i < 100; i++ {\n\t\tlargeMessages[i] = map[string]interface{}{\n\t\t\t\"role\": \"user\",\n\t\t\t\"content\": []interface{}{\n\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\"text\": \"Message \" + strings.Repeat(\"x\", 100),\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t}\n\n\tinput := map[string]interface{}{\n\t\t\"model\":    \"claude-3\",\n\t\t\"messages\": largeMessages,\n\t}\n\n\tresult := FormatJSONForLog(input, 500)\n\n\t// 如果被截断，应该在换行符处截断\n\tif strings.Contains(result, \"... (输出已截断)\") {\n\t\t// 检查截断位置是否在合适的地方\n\t\ttruncateIndex := strings.Index(result, \"... (输出已截断)\")\n\t\tbeforeTruncate := result[:truncateIndex]\n\n\t\t// 应该在换行符后截断\n\t\tif !strings.HasSuffix(strings.TrimSpace(beforeTruncate), \"\\n\") &&\n\t\t\t!strings.HasSuffix(beforeTruncate, \"}\") &&\n\t\t\t!strings.HasSuffix(beforeTruncate, \"]\") {\n\t\t\t// 允许截断点不完美，但至少不应该在字符串中间\n\t\t\tif !strings.Contains(beforeTruncate[len(beforeTruncate)-20:], \"\\n\") {\n\t\t\t\tt.Error(\"截断位置不在合适的边界\")\n\t\t\t}\n\t\t}\n\n\t\tt.Logf(\"[Test-OK] 超长输出被正确截断，截断位置: %d\", truncateIndex)\n\t}\n}\n\nfunc TestFormatJSONBytesForLog(t *testing.T) {\n\tinput := map[string]interface{}{\n\t\t\"messages\": []interface{}{\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": []interface{}{\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\"text\": \"Hello, world!\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tjsonBytes, _ := json.Marshal(input)\n\tresult := FormatJSONBytesForLog(jsonBytes, 500)\n\n\t// 验证基本功能\n\tif !strings.Contains(result, `\"type\": \"text\"`) {\n\t\tt.Error(\"应该包含type字段\")\n\t}\n\n\tif !strings.Contains(result, `\"text\": \"Hello, world!\"`) {\n\t\tt.Error(\"应该包含完整的短文本\")\n\t}\n\n\t// 验证没有乱码\n\tif strings.Contains(result, \"�\") {\n\t\tt.Error(\"输出包含乱码\")\n\t}\n\n\tt.Logf(\"格式化结果:\\n%s\", result)\n}\n\n// TestCodexResponsesFormat 测试 Codex Responses API 格式的压缩显示\nfunc TestCodexResponsesFormat(t *testing.T) {\n\t// 模拟 Codex Responses API 的请求体\n\tinput := map[string]interface{}{\n\t\t\"input\": []interface{}{\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"role\": \"assistant\",\n\t\t\t\t\"content\": []interface{}{\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"type\": \"output_text\",\n\t\t\t\t\t\t\"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...\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"content\": []interface{}{\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"type\": \"input_text\",\n\t\t\t\t\t\t\"text\": \"[截屏2025-11-18 12.09.22.png 3022x2022] 失败的时候正在评分这个消息一直消不掉\",\n\t\t\t\t\t},\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"type\": \"input_image\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"type\":              \"reasoning\",\n\t\t\t\t\"content\":           nil,\n\t\t\t\t\"encrypted_content\": \"gAAAAABpG_GLKdJFoKhQfJKcN5k9efb8cQRy3md40ZemIZlJMlmuGgxhTjUtFPwmTAToAwIDtPsPMoOxV8SwDDLohrOqLqUMNEBgFV3ZBNgbNamdzu_jRW7JiFFpB8supDB4lIWyIhvh6HwuHP-8it62DBcdKp9U_V1GuSsP96C8GacKBEEyUmmcHbAcgXj341PxsVpiLx3y5xS18kXTXafmVK_EATeun9vLZ-A9m2BbbEfXoC4zb1AfUGQ_46sZyYXZNWr-v3gbbRkPug4Hq8j4d8vHMmDqNHGDuuScL5r63obEnrrhdTl9dbpOeSgIm7ag-fzmdofyP4I4XKx_SUxaEbTbWbHxunTYpA4lZy04Qw0b85TTvY62G6hcik5i-l5b6LgU0LTycR9lp_LE8OnAswvjLT3HQz6tFzZM288H1vWykftDb-eCyOX4pXn7WP4HFFNp_GvoVy1RPGJh_QbVxKAZCYiv0_7AaSjpv1_RS8EYbssy...\",\n\t\t\t\t\"summary\": []interface{}{\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"type\": \"summary_text\",\n\t\t\t\t\t\t\"text\": \"**Investigating status toast bug**\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"call_id\":   \"call_FIgewLLjtlkutO7mK5scikpN\",\n\t\t\t\t\"name\":      \"shell\",\n\t\t\t\t\"type\":      \"function_call\",\n\t\t\t\t\"arguments\": `{\"command\":[\"bash\",\"-lc\",\"ls frontend/src\"],\"workdir\":\"/Users/petaflops/projects/doc-scorer-wails\"}`,\n\t\t\t},\n\t\t},\n\t}\n\n\tresult := FormatJSONForLog(input, 500)\n\n\t// 验证 input 数组被压缩成单行\n\tlines := strings.Split(result, \"\\n\")\n\tvar inputArrayLines []string\n\tinInputArray := false\n\tfor _, line := range lines {\n\t\tif strings.Contains(line, `\"input\":`) {\n\t\t\tinInputArray = true\n\t\t}\n\t\tif inInputArray {\n\t\t\tinputArrayLines = append(inputArrayLines, line)\n\t\t\tif strings.Contains(line, \"]\") && !strings.Contains(line, \"[\") {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\t// 验证每个 message 对象都在单行\n\tinputArrayStr := strings.Join(inputArrayLines, \"\\n\")\n\tif !strings.Contains(inputArrayStr, `{\"role\": \"assistant\"`) {\n\t\tt.Error(\"input 数组中的 message 对象应该被压缩成单行\")\n\t}\n\n\t// 验证 reasoning 类型被压缩\n\tif !strings.Contains(result, `\"type\": \"reasoning\"`) {\n\t\tt.Error(\"应该包含 reasoning 类型\")\n\t}\n\n\t// 验证 function_call 类型被压缩\n\tif !strings.Contains(result, `\"type\": \"function_call\"`) {\n\t\tt.Error(\"应该包含 function_call 类型\")\n\t}\n\n\t// 验证 encrypted_content 被截断\n\tif strings.Contains(result, \"gAAAAABpG_GLKdJFoKhQfJKcN5k9efb8cQRy3md40ZemIZlJMlmuGgxhTjUtFPwmTAToAwIDtPsPMoOxV8SwDDLohrOqLqUMNEBgFV3ZBNgbNamdzu_jRW7JiFFpB8supDB4lIWyIhvh6HwuHP-8it62DBcdKp9U_V1GuSsP96C8GacKBEEyUmmcHbAcgXj341PxsVpiLx3y5xS18kXTXafmVK_EATeun9vLZ-A9m2BbbEfXoC4zb1AfUGQ_46sZyYXZNWr-v3gbbRkPug4Hq8j4d8vHMmDqNHGDuuScL5r63obEnrrhdTl9dbpOeSgIm7ag-fzmdofyP4I4XKx_SUxaEbTbWbHxunTYpA4lZy04Qw0b85TTvY62G6hcik5i-l5b6LgU0LTycR9lp_LE8OnAswvjLT3HQz6tFzZM288H1vWykftDb-eCyOX4pXn7WP4HFFNp_GvoVy1RPGJh_QbVxKAZCYiv0_7AaSjpv1_RS8EYbssy...\") {\n\t\tt.Error(\"encrypted_content 应该被截断\")\n\t}\n\n\tt.Logf(\"Codex Responses 格式化结果:\\n%s\", result)\n}\n\n// TestGeminiContentsFormat 测试 Gemini contents 数组的紧凑格式化\nfunc TestGeminiContentsFormat(t *testing.T) {\n\t// 模拟 Gemini 请求格式\n\tinput := map[string]interface{}{\n\t\t\"contents\": []interface{}{\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"parts\": []interface{}{\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"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),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"role\": \"model\",\n\t\t\t\t\"parts\": []interface{}{\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"text\": \"This is the model response.\",\n\t\t\t\t\t},\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"functionCall\": map[string]interface{}{\n\t\t\t\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\t\t\t\"args\": map[string]interface{}{\n\t\t\t\t\t\t\t\t\"location\": \"San Francisco\",\n\t\t\t\t\t\t\t\t\"unit\":     \"celsius\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"parts\": []interface{}{\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"functionResponse\": map[string]interface{}{\n\t\t\t\t\t\t\t\"name\": \"get_weather\",\n\t\t\t\t\t\t\t\"response\": map[string]interface{}{\n\t\t\t\t\t\t\t\t\"temperature\": 18,\n\t\t\t\t\t\t\t\t\"condition\":   \"Clear sky\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"generationConfig\": map[string]interface{}{\n\t\t\t\"maxOutputTokens\": 1024,\n\t\t},\n\t}\n\n\tresult := FormatJSONForLog(input, 500)\n\n\t// 验证 contents 数组被正确处理\n\tif !strings.Contains(result, `\"contents\"`) {\n\t\tt.Error(\"应该包含 contents 字段\")\n\t}\n\n\t// 验证 parts 字段被保留\n\tif !strings.Contains(result, `\"parts\"`) {\n\t\tt.Error(\"应该包含 parts 字段\")\n\t}\n\n\t// 验证 role 字段被保留\n\tif !strings.Contains(result, `\"role\": \"user\"`) {\n\t\tt.Error(\"应该包含 role 字段\")\n\t}\n\n\t// 验证 functionCall 被保留\n\tif !strings.Contains(result, `\"functionCall\"`) {\n\t\tt.Error(\"应该包含 functionCall 字段\")\n\t}\n\n\t// 验证 functionResponse 被保留\n\tif !strings.Contains(result, `\"functionResponse\"`) {\n\t\tt.Error(\"应该包含 functionResponse 字段\")\n\t}\n\n\t// 验证长文本被截断\n\tif 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.\") {\n\t\tt.Error(\"长文本应该被截断\")\n\t}\n\n\tt.Logf(\"Gemini contents 格式化结果:\\n%s\", result)\n}\n\n// TestGeminiToolsFormat 测试 Gemini tools 数组的简化显示\nfunc TestGeminiToolsFormat(t *testing.T) {\n\t// 模拟 Gemini 请求中的 tools 格式\n\tinput := map[string]interface{}{\n\t\t\"contents\": []interface{}{\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"role\": \"user\",\n\t\t\t\t\"parts\": []interface{}{\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"text\": \"List the files in the current directory\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"tools\": []interface{}{\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"functionDeclarations\": []interface{}{\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"name\":        \"list_directory\",\n\t\t\t\t\t\t\"description\": \"Lists the names of files and subdirectories directly within a specified directory path.\",\n\t\t\t\t\t\t\"parametersJsonSchema\": map[string]interface{}{\n\t\t\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\t\t\"properties\": map[string]interface{}{\n\t\t\t\t\t\t\t\t\"dir_path\": map[string]interface{}{\n\t\t\t\t\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"The path to the directory to list\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"required\": []interface{}{\"dir_path\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"name\":        \"read_file\",\n\t\t\t\t\t\t\"description\": \"Reads and returns the content of a specified file.\",\n\t\t\t\t\t\t\"parametersJsonSchema\": map[string]interface{}{\n\t\t\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\t\t\"properties\": map[string]interface{}{\n\t\t\t\t\t\t\t\t\"file_path\": map[string]interface{}{\n\t\t\t\t\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"The path to the file to read.\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"required\": []interface{}{\"file_path\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"name\":        \"write_file\",\n\t\t\t\t\t\t\"description\": \"Writes content to a specified file.\",\n\t\t\t\t\t\t\"parametersJsonSchema\": map[string]interface{}{\n\t\t\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\t\t\"properties\": map[string]interface{}{\n\t\t\t\t\t\t\t\t\"file_path\": map[string]interface{}{\n\t\t\t\t\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"The path to the file to write.\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"content\": map[string]interface{}{\n\t\t\t\t\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\t\t\t\t\"description\": \"The content to write.\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"required\": []interface{}{\"file_path\", \"content\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"generationConfig\": map[string]interface{}{\n\t\t\t\"maxOutputTokens\": 1024,\n\t\t},\n\t}\n\n\tresult := FormatJSONForLog(input, 500)\n\n\t// 验证 tools 数组被简化为工具名称列表\n\tif !strings.Contains(result, `\"tools\": [\"list_directory\", \"read_file\", \"write_file\"]`) {\n\t\tt.Errorf(\"Gemini tools 应该被简化为工具名称列表，实际输出:\\n%s\", result)\n\t}\n\n\t// 验证完整的 parametersJsonSchema 没有出现在输出中\n\tif strings.Contains(result, \"parametersJsonSchema\") {\n\t\tt.Error(\"tools 中不应该包含 parametersJsonSchema\")\n\t}\n\n\t// 验证完整的 description 没有出现在输出中\n\tif strings.Contains(result, \"Lists the names of files\") {\n\t\tt.Error(\"tools 中不应该包含完整的 description\")\n\t}\n\n\tt.Logf(\"Gemini tools 格式化结果:\\n%s\", result)\n}\n"
  },
  {
    "path": "backend-go/internal/utils/json_test.go",
    "content": "package utils\n\nimport (\n\t\"encoding/json\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestTruncateJSONIntelligently(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tinput          interface{}\n\t\tmaxTextLength  int\n\t\texpectTruncate bool\n\t}{\n\t\t{\n\t\t\tname:           \"短字符串不截断\",\n\t\t\tinput:          \"Hello\",\n\t\t\tmaxTextLength:  10,\n\t\t\texpectTruncate: false,\n\t\t},\n\t\t{\n\t\t\tname:           \"长字符串截断\",\n\t\t\tinput:          strings.Repeat(\"a\", 600),\n\t\t\tmaxTextLength:  500,\n\t\t\texpectTruncate: true,\n\t\t},\n\t\t{\n\t\t\tname: \"嵌套对象中的长字符串\",\n\t\t\tinput: map[string]interface{}{\n\t\t\t\t\"short\": \"test\",\n\t\t\t\t\"long\":  strings.Repeat(\"b\", 600),\n\t\t\t},\n\t\t\tmaxTextLength:  500,\n\t\t\texpectTruncate: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := TruncateJSONIntelligently(tt.input, tt.maxTextLength)\n\n\t\t\t// 转换为JSON检查\n\t\t\tjsonBytes, err := json.Marshal(result)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Marshal failed: %v\", err)\n\t\t\t}\n\n\t\t\tresultStr := string(jsonBytes)\n\t\t\tif tt.expectTruncate {\n\t\t\t\tif !strings.Contains(resultStr, \"...\") {\n\t\t\t\t\tt.Errorf(\"Expected truncation marker '...' not found\")\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSimplifyToolsArray(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    interface{}\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname: \"Claude格式工具\",\n\t\t\tinput: map[string]interface{}{\n\t\t\t\t\"tools\": []interface{}{\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"name\":        \"get_weather\",\n\t\t\t\t\t\t\"description\": \"Get weather info\",\n\t\t\t\t\t},\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"name\":        \"search\",\n\t\t\t\t\t\t\"description\": \"Search the web\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: `[\"get_weather\",\"search\"]`,\n\t\t},\n\t\t{\n\t\t\tname: \"OpenAI格式工具\",\n\t\t\tinput: map[string]interface{}{\n\t\t\t\t\"tools\": []interface{}{\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"type\": \"function\",\n\t\t\t\t\t\t\"function\": map[string]interface{}{\n\t\t\t\t\t\t\t\"name\":        \"calculator\",\n\t\t\t\t\t\t\t\"description\": \"Calculate math\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: `[\"calculator\"]`,\n\t\t},\n\t\t{\n\t\t\tname: \"非工具字段不受影响\",\n\t\t\tinput: map[string]interface{}{\n\t\t\t\t\"model\":    \"claude-3\",\n\t\t\t\t\"messages\": []interface{}{\"hello\"},\n\t\t\t},\n\t\t\texpected: `{\"messages\":[\"hello\"],\"model\":\"claude-3\"}`,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := SimplifyToolsArray(tt.input)\n\n\t\t\t// 提取tools字段检查\n\t\t\tif resultMap, ok := result.(map[string]interface{}); ok {\n\t\t\t\tif tools, exists := resultMap[\"tools\"]; exists {\n\t\t\t\t\ttoolsJSON, _ := json.Marshal(tools)\n\t\t\t\t\tif !strings.Contains(string(toolsJSON), tt.expected) {\n\t\t\t\t\t\tt.Errorf(\"Expected tools to contain %s, got %s\", tt.expected, string(toolsJSON))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFormatJSONForLog(t *testing.T) {\n\tinput := map[string]interface{}{\n\t\t\"model\": \"claude-3\",\n\t\t\"tools\": []interface{}{\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"name\":        \"get_weather\",\n\t\t\t\t\"description\": \"Get weather information\",\n\t\t\t\t\"parameters\": map[string]interface{}{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]interface{}{\n\t\t\t\t\t\t\"location\": map[string]interface{}{\n\t\t\t\t\t\t\t\"type\":        \"string\",\n\t\t\t\t\t\t\t\"description\": \"City name\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"messages\": []interface{}{\n\t\t\tmap[string]interface{}{\n\t\t\t\t\"role\":    \"user\",\n\t\t\t\t\"content\": strings.Repeat(\"Hello \", 200), // 长内容\n\t\t\t},\n\t\t},\n\t}\n\n\tresult := FormatJSONForLog(input, 100)\n\n\t// 检查tools被简化\n\tif !strings.Contains(result, `\"get_weather\"`) {\n\t\tt.Error(\"Tools should be simplified to names\")\n\t}\n\n\t// 检查长文本被截断\n\tif !strings.Contains(result, \"...\") {\n\t\tt.Error(\"Long content should be truncated\")\n\t}\n\n\t// 检查JSON格式化\n\tif !strings.Contains(result, \"\\n\") {\n\t\tt.Error(\"Output should be formatted with newlines\")\n\t}\n}\n\nfunc TestMaskAPIKey(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"空字符串\",\n\t\t\tinput:    \"\",\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"短密钥(5字符)\",\n\t\t\tinput:    \"abc12\",\n\t\t\texpected: \"***\", // 长度<=5时返回***\n\t\t},\n\t\t{\n\t\t\tname:     \"短密钥(6字符)\",\n\t\t\tinput:    \"abc123\",\n\t\t\texpected: \"abc***23\",\n\t\t},\n\t\t{\n\t\t\tname:     \"长密钥\",\n\t\t\tinput:    \"sk-1234567890abcdef\",\n\t\t\texpected: \"sk-12345***bcdef\",\n\t\t},\n\t\t{\n\t\t\tname:     \"超短密钥\",\n\t\t\tinput:    \"key\",\n\t\t\texpected: \"***\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := MaskAPIKey(tt.input)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"Expected %s, got %s\", tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/utils/stream_synthesizer.go",
    "content": "package utils\n\nimport (\n\t\"encoding/json\"\n\t\"regexp\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n)\n\n// StreamSynthesizer 流式响应内容合成器\ntype StreamSynthesizer struct {\n\tserviceType         string\n\tsynthesizedContent  strings.Builder\n\ttoolCallAccumulator map[int]*ToolCall\n\tparseFailed         bool\n\n\t// responses专用累积器\n\tresponsesText map[int]*strings.Builder\n}\n\n// ToolCall 工具调用累积器\ntype ToolCall struct {\n\tID        string\n\tName      string\n\tArguments string\n}\n\n// NewStreamSynthesizer 创建新的流合成器\nfunc NewStreamSynthesizer(serviceType string) *StreamSynthesizer {\n\treturn &StreamSynthesizer{\n\t\tserviceType:         serviceType,\n\t\ttoolCallAccumulator: make(map[int]*ToolCall),\n\t\tresponsesText:       make(map[int]*strings.Builder),\n\t}\n}\n\n// ProcessLine 处理SSE流的一行\nfunc (s *StreamSynthesizer) ProcessLine(line string) {\n\ttrimmedLine := strings.TrimSpace(line)\n\tif trimmedLine == \"\" {\n\t\treturn\n\t}\n\n\t// 使用正则匹配SSE data字段\n\tdataRegex := regexp.MustCompile(`^data:\\s*(.*)$`)\n\tmatches := dataRegex.FindStringSubmatch(trimmedLine)\n\tif len(matches) < 2 {\n\t\treturn\n\t}\n\n\tjsonStr := strings.TrimSpace(matches[1])\n\tif jsonStr == \"[DONE]\" || jsonStr == \"\" {\n\t\treturn\n\t}\n\n\t// 解析JSON - 不再因失败而停止处理\n\tvar data map[string]interface{}\n\tif err := json.Unmarshal([]byte(jsonStr), &data); err != nil {\n\t\t// 记录解析失败但继续处理后续行，而不是完全停止\n\t\tif !s.parseFailed {\n\t\t\ts.parseFailed = true\n\t\t\ts.synthesizedContent.WriteString(\"\\n[解析警告: 部分JSON解析失败，将显示原始文本内容]\")\n\t\t}\n\t\treturn\n\t}\n\n\t// 如果之前解析失败，但现在成功了，重置失败标记\n\tif s.parseFailed {\n\t\ts.parseFailed = false\n\t}\n\n\t// 根据服务类型解析\n\tswitch s.serviceType {\n\tcase \"gemini\":\n\t\ts.processGemini(data)\n\tcase \"openai\":\n\t\ts.processOpenAI(data)\n\tcase \"claude\":\n\t\ts.processClaude(data)\n\tcase \"responses\":\n\t\ts.processResponses(data)\n\t}\n}\n\n// processResponses 处理OpenAI Responses流\nfunc (s *StreamSynthesizer) processResponses(data map[string]interface{}) {\n\ttypeStr, _ := data[\"type\"].(string)\n\n\t// 辅助方法：获取对应 output_index 的 builder\n\tgetBuilder := func(index int) *strings.Builder {\n\t\tif s.responsesText[index] == nil {\n\t\t\ts.responsesText[index] = &strings.Builder{}\n\t\t}\n\t\treturn s.responsesText[index]\n\t}\n\n\t// 获取 output_index\n\tgetIndex := func() int {\n\t\tif idx, ok := data[\"output_index\"].(float64); ok {\n\t\t\treturn int(idx)\n\t\t}\n\t\treturn 0\n\t}\n\n\tswitch typeStr {\n\tcase \"response.output_text.delta\":\n\t\tif delta, ok := data[\"delta\"].(string); ok {\n\t\t\tbuilder := getBuilder(getIndex())\n\t\t\tbuilder.WriteString(delta)\n\t\t}\n\tcase \"response.output_text.done\":\n\t\tbuilder := getBuilder(getIndex())\n\t\tif text, ok := data[\"text\"].(string); ok && text != \"\" {\n\t\t\tbuilder.Reset()\n\t\t\tbuilder.WriteString(text)\n\t\t}\n\tcase \"response.completed\":\n\t\t// 兜底：从最终响应提取文本\n\t\tif respObj, ok := data[\"response\"].(map[string]interface{}); ok {\n\t\t\tif outputArr, ok := respObj[\"output\"].([]interface{}); ok {\n\t\t\t\tfor i, item := range outputArr {\n\t\t\t\t\titemMap, ok := item.(map[string]interface{})\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tif itemMap[\"type\"] != \"message\" {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tcontentArr, ok := itemMap[\"content\"].([]interface{})\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tfor _, c := range contentArr {\n\t\t\t\t\t\tcm, ok := c.(map[string]interface{})\n\t\t\t\t\t\tif !ok {\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif cm[\"type\"] != \"output_text\" {\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif text, ok := cm[\"text\"].(string); ok && text != \"\" {\n\t\t\t\t\t\t\tbuilder := getBuilder(i)\n\t\t\t\t\t\t\tbuilder.Reset()\n\t\t\t\t\t\t\tbuilder.WriteString(text)\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\tcase \"response.output_item.added\":\n\t\t// 记录函数调用元数据（用于后续拼接日志）\n\t\tif item, ok := data[\"item\"].(map[string]interface{}); ok {\n\t\t\tif itemType, _ := item[\"type\"].(string); itemType == \"function_call\" {\n\t\t\t\tindex := getIndex()\n\t\t\t\tif s.toolCallAccumulator[index] == nil {\n\t\t\t\t\ts.toolCallAccumulator[index] = &ToolCall{}\n\t\t\t\t}\n\t\t\t\tacc := s.toolCallAccumulator[index]\n\t\t\t\tif id, ok := item[\"id\"].(string); ok && id != \"\" {\n\t\t\t\t\tacc.ID = id\n\t\t\t\t}\n\t\t\t\tif name, ok := item[\"name\"].(string); ok && name != \"\" {\n\t\t\t\t\tacc.Name = name\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\tcase \"response.function_call_arguments.delta\":\n\t\tindex := getIndex()\n\t\tif s.toolCallAccumulator[index] == nil {\n\t\t\ts.toolCallAccumulator[index] = &ToolCall{}\n\t\t}\n\t\tacc := s.toolCallAccumulator[index]\n\t\tif id, ok := data[\"item_id\"].(string); ok && id != \"\" {\n\t\t\tacc.ID = id\n\t\t}\n\t\tif delta, ok := data[\"delta\"].(string); ok {\n\t\t\tacc.Arguments += delta\n\t\t}\n\tcase \"response.function_call_arguments.done\":\n\t\tindex := getIndex()\n\t\tif s.toolCallAccumulator[index] == nil {\n\t\t\ts.toolCallAccumulator[index] = &ToolCall{}\n\t\t}\n\t\tacc := s.toolCallAccumulator[index]\n\t\tif id, ok := data[\"item_id\"].(string); ok && id != \"\" {\n\t\t\tacc.ID = id\n\t\t}\n\t\tif args, ok := data[\"arguments\"].(string); ok && args != \"\" {\n\t\t\tacc.Arguments = args\n\t\t}\n\t\tif item, ok := data[\"item\"].(map[string]interface{}); ok {\n\t\t\tif name, ok := item[\"name\"].(string); ok && name != \"\" {\n\t\t\t\tacc.Name = name\n\t\t\t}\n\t\t}\n\t}\n}\n\n// processGemini 处理Gemini格式\nfunc (s *StreamSynthesizer) processGemini(data map[string]interface{}) {\n\tcandidates, ok := data[\"candidates\"].([]interface{})\n\tif !ok || len(candidates) == 0 {\n\t\treturn\n\t}\n\n\tcandidate, ok := candidates[0].(map[string]interface{})\n\tif !ok {\n\t\treturn\n\t}\n\n\tcontent, ok := candidate[\"content\"].(map[string]interface{})\n\tif !ok {\n\t\treturn\n\t}\n\n\tparts, ok := content[\"parts\"].([]interface{})\n\tif !ok {\n\t\treturn\n\t}\n\n\tfor _, part := range parts {\n\t\tpartMap, ok := part.(map[string]interface{})\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 文本内容\n\t\tif text, ok := partMap[\"text\"].(string); ok {\n\t\t\ts.synthesizedContent.WriteString(text)\n\t\t}\n\n\t\t// 函数调用\n\t\tif functionCall, ok := partMap[\"functionCall\"].(map[string]interface{}); ok {\n\t\t\tname, _ := functionCall[\"name\"].(string)\n\t\t\targs, _ := functionCall[\"args\"]\n\t\t\targsJSON, _ := json.Marshal(args)\n\t\t\ts.synthesizedContent.WriteString(\"\\nTool Call: \")\n\t\t\ts.synthesizedContent.WriteString(name)\n\t\t\ts.synthesizedContent.WriteString(\"(\")\n\t\t\ts.synthesizedContent.Write(argsJSON)\n\t\t\ts.synthesizedContent.WriteString(\")\")\n\t\t}\n\t}\n}\n\n// processOpenAI 处理OpenAI格式\nfunc (s *StreamSynthesizer) processOpenAI(data map[string]interface{}) {\n\tchoices, ok := data[\"choices\"].([]interface{})\n\tif !ok || len(choices) == 0 {\n\t\treturn\n\t}\n\n\tchoice, ok := choices[0].(map[string]interface{})\n\tif !ok {\n\t\treturn\n\t}\n\n\tdelta, ok := choice[\"delta\"].(map[string]interface{})\n\tif !ok {\n\t\treturn\n\t}\n\n\t// 文本内容\n\tif content, ok := delta[\"content\"].(string); ok {\n\t\ts.synthesizedContent.WriteString(content)\n\t}\n\n\t// 工具调用\n\tif toolCalls, ok := delta[\"tool_calls\"].([]interface{}); ok {\n\t\tfor _, tc := range toolCalls {\n\t\t\ttoolCallMap, ok := tc.(map[string]interface{})\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tindex := 0\n\t\t\tif idx, ok := toolCallMap[\"index\"].(float64); ok {\n\t\t\t\tindex = int(idx)\n\t\t\t}\n\n\t\t\tif s.toolCallAccumulator[index] == nil {\n\t\t\t\ts.toolCallAccumulator[index] = &ToolCall{}\n\t\t\t}\n\n\t\t\taccumulated := s.toolCallAccumulator[index]\n\n\t\t\tif id, ok := toolCallMap[\"id\"].(string); ok {\n\t\t\t\taccumulated.ID = id\n\t\t\t}\n\n\t\t\tif function, ok := toolCallMap[\"function\"].(map[string]interface{}); ok {\n\t\t\t\tif name, ok := function[\"name\"].(string); ok {\n\t\t\t\t\taccumulated.Name = name\n\t\t\t\t}\n\t\t\t\tif args, ok := function[\"arguments\"].(string); ok {\n\t\t\t\t\taccumulated.Arguments += args\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\n// processClaude 处理Claude格式\nfunc (s *StreamSynthesizer) processClaude(data map[string]interface{}) {\n\teventType, _ := data[\"type\"].(string)\n\n\tswitch eventType {\n\tcase \"message_start\":\n\t\t// 从 message_start 中提取初始内容（如果有）\n\t\tif msg, ok := data[\"message\"].(map[string]interface{}); ok {\n\t\t\tif content, ok := msg[\"content\"].([]interface{}); ok {\n\t\t\t\tfor _, c := range content {\n\t\t\t\t\tif cm, ok := c.(map[string]interface{}); ok {\n\t\t\t\t\t\tif text, ok := cm[\"text\"].(string); ok {\n\t\t\t\t\t\t\ts.synthesizedContent.WriteString(text)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\tcase \"content_block_start\":\n\t\tcontentBlock, ok := data[\"content_block\"].(map[string]interface{})\n\t\tif !ok {\n\t\t\treturn\n\t\t}\n\n\t\tblockIndex := 0\n\t\tif idx, ok := data[\"index\"].(float64); ok {\n\t\t\tblockIndex = int(idx)\n\t\t}\n\n\t\tblockType, _ := contentBlock[\"type\"].(string)\n\n\t\tswitch blockType {\n\t\tcase \"tool_use\":\n\t\t\tif s.toolCallAccumulator[blockIndex] == nil {\n\t\t\t\ts.toolCallAccumulator[blockIndex] = &ToolCall{}\n\t\t\t}\n\t\t\taccumulated := s.toolCallAccumulator[blockIndex]\n\t\t\tif id, ok := contentBlock[\"id\"].(string); ok {\n\t\t\t\taccumulated.ID = id\n\t\t\t}\n\t\t\tif name, ok := contentBlock[\"name\"].(string); ok {\n\t\t\t\taccumulated.Name = name\n\t\t\t}\n\t\tcase \"text\":\n\t\t\t// text 类型的 content_block_start 可能包含初始文本\n\t\t\tif text, ok := contentBlock[\"text\"].(string); ok && text != \"\" {\n\t\t\t\ts.synthesizedContent.WriteString(text)\n\t\t\t}\n\t\t}\n\n\tcase \"content_block_delta\":\n\t\tdelta, ok := data[\"delta\"].(map[string]interface{})\n\t\tif !ok {\n\t\t\treturn\n\t\t}\n\n\t\tdeltaType, _ := delta[\"type\"].(string)\n\n\t\tswitch deltaType {\n\t\tcase \"text_delta\":\n\t\t\tif text, ok := delta[\"text\"].(string); ok {\n\t\t\t\ts.synthesizedContent.WriteString(text)\n\t\t\t}\n\t\tcase \"input_json_delta\":\n\t\t\tif partialJSON, ok := delta[\"partial_json\"].(string); ok {\n\t\t\t\tblockIndex := 0\n\t\t\t\tif idx, ok := data[\"index\"].(float64); ok {\n\t\t\t\t\tblockIndex = int(idx)\n\t\t\t\t}\n\n\t\t\t\tif s.toolCallAccumulator[blockIndex] == nil {\n\t\t\t\t\ts.toolCallAccumulator[blockIndex] = &ToolCall{}\n\t\t\t\t}\n\n\t\t\t\taccumulated := s.toolCallAccumulator[blockIndex]\n\t\t\t\taccumulated.Arguments += partialJSON\n\t\t\t}\n\t\tcase \"thinking_delta\":\n\t\t\t// thinking 内容不记录到合成内容中（可选：如需记录可取消注释）\n\t\t\t// if thinking, ok := delta[\"thinking\"].(string); ok {\n\t\t\t// \ts.synthesizedContent.WriteString(thinking)\n\t\t\t// }\n\t\t}\n\n\tcase \"message_delta\":\n\t\t// message_delta 通常包含 stop_reason 和 usage，不包含文本内容\n\t\t// 但某些情况下可能有额外数据，这里做兜底处理\n\t\tif delta, ok := data[\"delta\"].(map[string]interface{}); ok {\n\t\t\tif text, ok := delta[\"text\"].(string); ok {\n\t\t\t\ts.synthesizedContent.WriteString(text)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// GetSynthesizedContent 获取合成的内容\nfunc (s *StreamSynthesizer) GetSynthesizedContent() string {\n\t// 不再完全失败，即使有解析错误也返回部分结果\n\tvar result string\n\n\tif s.serviceType == \"responses\" && len(s.responsesText) > 0 {\n\t\tvar builder strings.Builder\n\t\tkeys := make([]int, 0, len(s.responsesText))\n\t\tfor k := range s.responsesText {\n\t\t\tkeys = append(keys, k)\n\t\t}\n\t\tsort.Ints(keys)\n\n\t\tfor i, k := range keys {\n\t\t\ttext := s.responsesText[k].String()\n\t\t\tif text == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif i > 0 && builder.Len() > 0 {\n\t\t\t\tbuilder.WriteString(\"\\n\")\n\t\t\t}\n\t\t\tbuilder.WriteString(text)\n\t\t}\n\t\tresult = builder.String()\n\t} else {\n\t\tresult = s.synthesizedContent.String()\n\t}\n\n\t// 添加工具调用信息\n\tif len(s.toolCallAccumulator) > 0 {\n\t\t// 修复分裂的工具调用：检测并合并元数据和参数分离的情况\n\t\ts.mergeSplitToolCalls()\n\n\t\t// 按 index 排序输出，避免 map 遍历顺序不稳定\n\t\tindices := make([]int, 0, len(s.toolCallAccumulator))\n\t\tfor idx := range s.toolCallAccumulator {\n\t\t\tindices = append(indices, idx)\n\t\t}\n\t\tsort.Ints(indices)\n\n\t\tvar toolCallsBuilder strings.Builder\n\t\tfor _, index := range indices {\n\t\t\ttool := s.toolCallAccumulator[index]\n\t\t\targs := tool.Arguments\n\t\t\tif args == \"\" {\n\t\t\t\targs = \"{}\"\n\t\t\t}\n\n\t\t\tname := tool.Name\n\t\t\tif name == \"\" {\n\t\t\t\tname = \"unknown_function\"\n\t\t\t}\n\n\t\t\tid := tool.ID\n\t\t\tif id == \"\" {\n\t\t\t\tid = \"tool_\" + strconv.Itoa(index)\n\t\t\t}\n\n\t\t\ttoolCallsBuilder.WriteString(\"\\nTool Call: \")\n\t\t\ttoolCallsBuilder.WriteString(name)\n\t\t\ttoolCallsBuilder.WriteString(\"(\")\n\n\t\t\t// 尝试格式化JSON\n\t\t\tvar parsedArgs interface{}\n\t\t\tif err := json.Unmarshal([]byte(args), &parsedArgs); err == nil {\n\t\t\t\tprettyArgs, _ := json.Marshal(parsedArgs)\n\t\t\t\ttoolCallsBuilder.Write(prettyArgs)\n\t\t\t} else {\n\t\t\t\ttoolCallsBuilder.WriteString(args)\n\t\t\t}\n\n\t\t\ttoolCallsBuilder.WriteString(\") [ID: \")\n\t\t\ttoolCallsBuilder.WriteString(id)\n\t\t\ttoolCallsBuilder.WriteString(\"]\")\n\t\t}\n\n\t\tresult += toolCallsBuilder.String()\n\t}\n\n\treturn result\n}\n\n// mergeSplitToolCalls 修复分裂的工具调用\n// 问题场景：上游返回的工具调用被意外分成两个 content_block：\n// - 第一个 block 有 name 和 id，但参数为空 \"{}\"\n// - 第二个 block 没有 name（显示为 unknown_function），但有完整参数\n// 此方法检测并合并这种情况\nfunc (s *StreamSynthesizer) mergeSplitToolCalls() {\n\tif len(s.toolCallAccumulator) < 2 {\n\t\treturn\n\t}\n\n\t// 收集所有索引并排序\n\tindices := make([]int, 0, len(s.toolCallAccumulator))\n\tfor idx := range s.toolCallAccumulator {\n\t\tindices = append(indices, idx)\n\t}\n\tsort.Ints(indices)\n\n\t// 检测分裂模式：有 name 但参数为空/\"{}\" 的 block，后面紧跟无 name 但有参数的 block\n\ttoDelete := make(map[int]bool)\n\n\tfor i := 0; i < len(indices)-1; i++ {\n\t\tcurrIdx := indices[i]\n\t\tnextIdx := indices[i+1]\n\n\t\t// 约束：只合并连续的 index（防止误合并不相关的调用）\n\t\tif nextIdx != currIdx+1 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcurr := s.toolCallAccumulator[currIdx]\n\t\tnext := s.toolCallAccumulator[nextIdx]\n\n\t\t// 检测分裂条件：\n\t\t// 1. 当前 block 有 name 和 id，但参数为空或只有 \"{}\"\n\t\t// 2. 下一个 block 没有 name，但有实际参数\n\t\t// 3. 如果 next 有 ID，必须与 curr 相同（或 curr 无 ID）\n\t\tcurrArgsEmpty := curr.Arguments == \"\" || curr.Arguments == \"{}\"\n\t\tnextHasNoName := next.Name == \"\"\n\t\tnextHasArgs := next.Arguments != \"\" && next.Arguments != \"{}\"\n\t\tidMatch := next.ID == \"\" || curr.ID == \"\" || next.ID == curr.ID\n\n\t\tif curr.Name != \"\" && currArgsEmpty && nextHasNoName && nextHasArgs && idMatch {\n\t\t\t// 合并：将 next 的参数移到 curr，补全缺失字段\n\t\t\tcurr.Arguments = next.Arguments\n\t\t\tif curr.ID == \"\" && next.ID != \"\" {\n\t\t\t\tcurr.ID = next.ID\n\t\t\t}\n\t\t\ttoDelete[nextIdx] = true\n\t\t\t// 跳过下一个，因为已经处理了\n\t\t\ti++\n\t\t}\n\t}\n\n\t// 删除已合并的 block\n\tfor idx := range toDelete {\n\t\tdelete(s.toolCallAccumulator, idx)\n\t}\n}\n\n// IsParseFailed 检查解析是否失败\nfunc (s *StreamSynthesizer) IsParseFailed() bool {\n\treturn s.parseFailed\n}\n\n// HasToolCalls 检查是否有工具调用被处理\nfunc (s *StreamSynthesizer) HasToolCalls() bool {\n\treturn len(s.toolCallAccumulator) > 0\n}\n"
  },
  {
    "path": "backend-go/internal/utils/token_counter.go",
    "content": "package utils\n\nimport (\n\t\"encoding/json\"\n\t\"unicode\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n)\n\n// EstimateTokens 估算文本的 token 数量\n// 使用字符估算法：\n// - 中文/日文/韩文：约 1.5 字符/token\n// - 英文及其他：约 3.5 字符/token\nfunc EstimateTokens(text string) int {\n\tif text == \"\" {\n\t\treturn 0\n\t}\n\n\tcjkCount := 0\n\totherCount := 0\n\n\tfor _, r := range text {\n\t\tif isCJK(r) {\n\t\t\tcjkCount++\n\t\t} else if !unicode.IsSpace(r) {\n\t\t\totherCount++\n\t\t}\n\t}\n\n\t// CJK: ~1.5 字符/token, 其他: ~3.5 字符/token\n\tcjkTokens := float64(cjkCount) / 1.5\n\totherTokens := float64(otherCount) / 3.5\n\n\treturn int(cjkTokens + otherTokens + 0.5) // 四舍五入\n}\n\n// EstimateMessagesTokens 估算消息数组的 token 数量\nfunc EstimateMessagesTokens(messages interface{}) int {\n\tif messages == nil {\n\t\treturn 0\n\t}\n\n\t// 序列化为 JSON 后估算\n\tdata, err := json.Marshal(messages)\n\tif err != nil {\n\t\treturn 0\n\t}\n\n\t// 每条消息额外开销约 4 tokens\n\tmsgCount := 0\n\tif arr, ok := messages.([]interface{}); ok {\n\t\tmsgCount = len(arr)\n\t}\n\n\treturn EstimateTokens(string(data)) + msgCount*4\n}\n\n// EstimateRequestTokens 从请求体估算输入 token\nfunc EstimateRequestTokens(bodyBytes []byte) int {\n\tif len(bodyBytes) == 0 {\n\t\treturn 0\n\t}\n\n\tvar req map[string]interface{}\n\tif err := json.Unmarshal(bodyBytes, &req); err != nil {\n\t\treturn EstimateTokens(string(bodyBytes))\n\t}\n\n\ttotal := 0\n\n\t// system prompt\n\tif system, ok := req[\"system\"]; ok {\n\t\tif str, ok := system.(string); ok {\n\t\t\ttotal += EstimateTokens(str)\n\t\t} else if arr, ok := system.([]interface{}); ok {\n\t\t\tfor _, item := range arr {\n\t\t\t\tif m, ok := item.(map[string]interface{}); ok {\n\t\t\t\t\tif text, ok := m[\"text\"].(string); ok {\n\t\t\t\t\t\ttotal += EstimateTokens(text)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// messages\n\tif messages, ok := req[\"messages\"]; ok {\n\t\ttotal += EstimateMessagesTokens(messages)\n\t}\n\n\t// tools (每个工具约 100-200 tokens)\n\tif tools, ok := req[\"tools\"].([]interface{}); ok {\n\t\ttotal += len(tools) * 150\n\t}\n\n\treturn total\n}\n\n// EstimateResponseTokens 从响应内容估算输出 token\nfunc EstimateResponseTokens(content interface{}) int {\n\tif content == nil {\n\t\treturn 0\n\t}\n\n\t// 字符串内容\n\tif str, ok := content.(string); ok {\n\t\treturn EstimateTokens(str)\n\t}\n\n\t// 内容数组\n\tif arr, ok := content.([]interface{}); ok {\n\t\ttotal := 0\n\t\tfor _, item := range arr {\n\t\t\tif m, ok := item.(map[string]interface{}); ok {\n\t\t\t\tif text, ok := m[\"text\"].(string); ok {\n\t\t\t\t\ttotal += EstimateTokens(text)\n\t\t\t\t}\n\t\t\t\t// tool_use 的 input 也计入\n\t\t\t\tif input, ok := m[\"input\"]; ok {\n\t\t\t\t\tdata, _ := json.Marshal(input)\n\t\t\t\t\ttotal += EstimateTokens(string(data))\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn total\n\t}\n\n\t// 其他情况序列化后估算\n\tdata, err := json.Marshal(content)\n\tif err != nil {\n\t\treturn 0\n\t}\n\treturn EstimateTokens(string(data))\n}\n\n// isCJK 判断是否为中日韩字符\nfunc isCJK(r rune) bool {\n\treturn unicode.Is(unicode.Han, r) ||\n\t\tunicode.Is(unicode.Hiragana, r) ||\n\t\tunicode.Is(unicode.Katakana, r) ||\n\t\tunicode.Is(unicode.Hangul, r)\n}\n\n// ============== Responses API Token 估算 ==============\n\n// EstimateResponsesRequestTokens 从 Responses API 请求体估算输入 token\n// 支持 instructions、input (string 或 []item) 格式\nfunc EstimateResponsesRequestTokens(bodyBytes []byte) int {\n\tif len(bodyBytes) == 0 {\n\t\treturn 0\n\t}\n\n\tvar req map[string]interface{}\n\tif err := json.Unmarshal(bodyBytes, &req); err != nil {\n\t\treturn EstimateTokens(string(bodyBytes))\n\t}\n\n\ttotal := 0\n\n\t// instructions (系统指令)\n\tif instructions, ok := req[\"instructions\"].(string); ok {\n\t\ttotal += EstimateTokens(instructions)\n\t}\n\n\t// input 字段处理\n\tif input := req[\"input\"]; input != nil {\n\t\ttotal += estimateResponsesInputTokens(input)\n\t}\n\n\t// tools (每个工具约 100-200 tokens)\n\tif tools, ok := req[\"tools\"].([]interface{}); ok {\n\t\ttotal += len(tools) * 150\n\t}\n\n\treturn total\n}\n\n// estimateResponsesInputTokens 估算 Responses input 字段的 token\nfunc estimateResponsesInputTokens(input interface{}) int {\n\tswitch v := input.(type) {\n\tcase string:\n\t\t// 简单字符串输入\n\t\treturn EstimateTokens(v)\n\tcase []interface{}:\n\t\t// 消息数组格式\n\t\ttotal := 0\n\t\tfor _, item := range v {\n\t\t\tif m, ok := item.(map[string]interface{}); ok {\n\t\t\t\t// 每条消息额外开销约 4 tokens\n\t\t\t\ttotal += 4\n\n\t\t\t\t// 处理 content 字段\n\t\t\t\tif content := m[\"content\"]; content != nil {\n\t\t\t\t\ttotal += estimateContentTokens(content)\n\t\t\t\t}\n\n\t\t\t\t// 处理 tool_use\n\t\t\t\tif toolUse, ok := m[\"tool_use\"].(map[string]interface{}); ok {\n\t\t\t\t\tdata, _ := json.Marshal(toolUse)\n\t\t\t\t\ttotal += EstimateTokens(string(data))\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn total\n\tdefault:\n\t\t// 其他情况序列化后估算\n\t\tdata, err := json.Marshal(input)\n\t\tif err != nil {\n\t\t\treturn 0\n\t\t}\n\t\treturn EstimateTokens(string(data))\n\t}\n}\n\n// estimateContentTokens 估算 content 字段的 token\nfunc estimateContentTokens(content interface{}) int {\n\tswitch v := content.(type) {\n\tcase string:\n\t\treturn EstimateTokens(v)\n\tcase []interface{}:\n\t\ttotal := 0\n\t\tfor _, block := range v {\n\t\t\tif b, ok := block.(map[string]interface{}); ok {\n\t\t\t\tif text, ok := b[\"text\"].(string); ok {\n\t\t\t\t\ttotal += EstimateTokens(text)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn total\n\tdefault:\n\t\tdata, err := json.Marshal(content)\n\t\tif err != nil {\n\t\t\treturn 0\n\t\t}\n\t\treturn EstimateTokens(string(data))\n\t}\n}\n\n// EstimateResponsesOutputTokens 从 Responses API 响应估算输出 token\n// 支持 []ResponsesItem 格式\nfunc EstimateResponsesOutputTokens(output interface{}) int {\n\tif output == nil {\n\t\treturn 0\n\t}\n\n\t// 处理 []types.ResponsesItem 类型\n\tif items, ok := output.([]types.ResponsesItem); ok {\n\t\ttotal := 0\n\t\tfor _, item := range items {\n\t\t\ttotal += estimateResponsesItemTokens(item)\n\t\t}\n\t\treturn total\n\t}\n\n\t// 处理 []interface{} 类型\n\tif arr, ok := output.([]interface{}); ok {\n\t\ttotal := 0\n\t\tfor _, item := range arr {\n\t\t\tif m, ok := item.(map[string]interface{}); ok {\n\t\t\t\t// 处理 content 字段\n\t\t\t\tif content := m[\"content\"]; content != nil {\n\t\t\t\t\ttotal += estimateContentTokens(content)\n\t\t\t\t}\n\n\t\t\t\t// 处理 tool_use\n\t\t\t\tif toolUse, ok := m[\"tool_use\"].(map[string]interface{}); ok {\n\t\t\t\t\tdata, _ := json.Marshal(toolUse)\n\t\t\t\t\ttotal += EstimateTokens(string(data))\n\t\t\t\t}\n\n\t\t\t\t// 处理 function_call 类型\n\t\t\t\tif m[\"type\"] == \"function_call\" {\n\t\t\t\t\tif args, ok := m[\"arguments\"].(string); ok {\n\t\t\t\t\t\ttotal += EstimateTokens(args)\n\t\t\t\t\t}\n\t\t\t\t\tif name, ok := m[\"name\"].(string); ok {\n\t\t\t\t\t\ttotal += EstimateTokens(name) + 2 // 函数名 + 开销\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// 处理 reasoning 类型\n\t\t\t\tif m[\"type\"] == \"reasoning\" {\n\t\t\t\t\tif summary, ok := m[\"summary\"].([]interface{}); ok {\n\t\t\t\t\t\tfor _, s := range summary {\n\t\t\t\t\t\t\tif sm, ok := s.(map[string]interface{}); ok {\n\t\t\t\t\t\t\t\tif text, ok := sm[\"text\"].(string); ok {\n\t\t\t\t\t\t\t\t\ttotal += EstimateTokens(text)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn total\n\t}\n\n\t// 其他情况序列化后估算\n\tdata, err := json.Marshal(output)\n\tif err != nil {\n\t\treturn 0\n\t}\n\treturn EstimateTokens(string(data))\n}\n\n// estimateResponsesItemTokens 估算单个 ResponsesItem 的 token 数\nfunc estimateResponsesItemTokens(item types.ResponsesItem) int {\n\ttotal := 0\n\n\t// 处理 content 字段\n\tif item.Content != nil {\n\t\ttotal += estimateContentTokens(item.Content)\n\t}\n\n\t// 处理 tool_use\n\tif item.ToolUse != nil {\n\t\tdata, _ := json.Marshal(item.ToolUse)\n\t\ttotal += EstimateTokens(string(data))\n\t}\n\n\t// 如果是特殊类型且 content/tool_use 都为空，序列化整个结构估算\n\t// 这处理 function_call、reasoning 等类型，其数据可能在其他字段中\n\tif total == 0 && item.Type != \"\" && item.Type != \"message\" && item.Type != \"text\" {\n\t\tdata, _ := json.Marshal(item)\n\t\ttotal = EstimateTokens(string(data))\n\t}\n\n\treturn total\n}\n"
  },
  {
    "path": "backend-go/internal/utils/token_counter_test.go",
    "content": "package utils\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/types\"\n)\n\nfunc TestEstimateTokens(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\ttext     string\n\t\texpected int\n\t}{\n\t\t{\"empty\", \"\", 0},\n\t\t{\"english\", \"Hello world\", 3}, // ~11 chars / 3.5 = ~3\n\t\t{\"chinese\", \"你好世界\", 2},        // 4 chars / 1.5 = ~2.7 -> 3\n\t\t{\"mixed\", \"Hello 你好\", 3},      // 5 other + 2 cjk = ~1.4 + ~1.3 = ~3\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := EstimateTokens(tt.text)\n\t\t\t// 允许 ±2 的误差\n\t\t\tif result < tt.expected-2 || result > tt.expected+2 {\n\t\t\t\tt.Errorf(\"EstimateTokens(%q) = %d, want ~%d\", tt.text, result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEstimateResponsesRequestTokens(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\trequest     map[string]interface{}\n\t\tminExpected int\n\t}{\n\t\t{\n\t\t\tname: \"simple_string_input\",\n\t\t\trequest: map[string]interface{}{\n\t\t\t\t\"model\": \"gpt-4\",\n\t\t\t\t\"input\": \"Hello, how are you?\",\n\t\t\t},\n\t\t\tminExpected: 5,\n\t\t},\n\t\t{\n\t\t\tname: \"with_instructions\",\n\t\t\trequest: map[string]interface{}{\n\t\t\t\t\"model\":        \"gpt-4\",\n\t\t\t\t\"instructions\": \"You are a helpful assistant.\",\n\t\t\t\t\"input\":        \"Hello\",\n\t\t\t},\n\t\t\tminExpected: 8,\n\t\t},\n\t\t{\n\t\t\tname: \"with_array_input\",\n\t\t\trequest: map[string]interface{}{\n\t\t\t\t\"model\": \"gpt-4\",\n\t\t\t\t\"input\": []interface{}{\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"type\":    \"message\",\n\t\t\t\t\t\t\"role\":    \"user\",\n\t\t\t\t\t\t\"content\": \"Hello, how are you today?\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tminExpected: 6,\n\t\t},\n\t\t{\n\t\t\tname: \"with_tools\",\n\t\t\trequest: map[string]interface{}{\n\t\t\t\t\"model\": \"gpt-4\",\n\t\t\t\t\"input\": \"Use the tool\",\n\t\t\t\t\"tools\": []interface{}{\n\t\t\t\t\tmap[string]interface{}{\"name\": \"search\"},\n\t\t\t\t\tmap[string]interface{}{\"name\": \"compute\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tminExpected: 300, // 2 tools * 150 = 300\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tbodyBytes, _ := json.Marshal(tt.request)\n\t\t\tresult := EstimateResponsesRequestTokens(bodyBytes)\n\t\t\tif result < tt.minExpected {\n\t\t\t\tt.Errorf(\"EstimateResponsesRequestTokens() = %d, want >= %d\", result, tt.minExpected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEstimateResponsesOutputTokens(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\toutput      interface{}\n\t\tminExpected int\n\t}{\n\t\t{\n\t\t\tname:        \"nil_output\",\n\t\t\toutput:      nil,\n\t\t\tminExpected: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"message_with_text\",\n\t\t\toutput: []interface{}{\n\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\"type\": \"message\",\n\t\t\t\t\t\"content\": []interface{}{\n\t\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\t\"type\": \"output_text\",\n\t\t\t\t\t\t\t\"text\": \"Hello, I am doing well!\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tminExpected: 5,\n\t\t},\n\t\t{\n\t\t\tname: \"function_call\",\n\t\t\toutput: []interface{}{\n\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\"type\":      \"function_call\",\n\t\t\t\t\t\"name\":      \"search\",\n\t\t\t\t\t\"arguments\": `{\"query\": \"weather\"}`,\n\t\t\t\t},\n\t\t\t},\n\t\t\tminExpected: 5,\n\t\t},\n\t\t{\n\t\t\tname: \"reasoning_with_summary\",\n\t\t\toutput: []interface{}{\n\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\"type\": \"reasoning\",\n\t\t\t\t\t\"summary\": []interface{}{\n\t\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\t\"type\": \"summary_text\",\n\t\t\t\t\t\t\t\"text\": \"This is my reasoning process\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tminExpected: 5,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := EstimateResponsesOutputTokens(tt.output)\n\t\t\tif result < tt.minExpected {\n\t\t\t\tt.Errorf(\"EstimateResponsesOutputTokens() = %d, want >= %d\", result, tt.minExpected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEstimateResponsesOutputTokensWithTypedItems(t *testing.T) {\n\t// 测试 []types.ResponsesItem 类型的直接处理\n\titems := []types.ResponsesItem{\n\t\t{\n\t\t\tType:    \"message\",\n\t\t\tRole:    \"assistant\",\n\t\t\tContent: \"Hello, I am doing well!\",\n\t\t},\n\t\t{\n\t\t\tType: \"text\",\n\t\t\tContent: []types.ContentBlock{\n\t\t\t\t{Type: \"output_text\", Text: \"This is output text\"},\n\t\t\t},\n\t\t},\n\t}\n\n\tresult := EstimateResponsesOutputTokens(items)\n\tif result < 5 {\n\t\tt.Errorf(\"EstimateResponsesOutputTokens([]types.ResponsesItem) = %d, want >= 5\", result)\n\t}\n}\n\nfunc TestEstimateRequestTokens(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\trequest     map[string]interface{}\n\t\tminExpected int\n\t}{\n\t\t{\n\t\t\tname: \"messages_api_request\",\n\t\t\trequest: map[string]interface{}{\n\t\t\t\t\"model\":  \"claude-3\",\n\t\t\t\t\"system\": \"You are a helpful assistant.\",\n\t\t\t\t\"messages\": []interface{}{\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"role\":    \"user\",\n\t\t\t\t\t\t\"content\": \"Hello!\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tminExpected: 8,\n\t\t},\n\t\t{\n\t\t\tname: \"with_system_array\",\n\t\t\trequest: map[string]interface{}{\n\t\t\t\t\"model\": \"claude-3\",\n\t\t\t\t\"system\": []interface{}{\n\t\t\t\t\tmap[string]interface{}{\n\t\t\t\t\t\t\"type\": \"text\",\n\t\t\t\t\t\t\"text\": \"You are helpful.\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tminExpected: 4,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tbodyBytes, _ := json.Marshal(tt.request)\n\t\t\tresult := EstimateRequestTokens(bodyBytes)\n\t\t\tif result < tt.minExpected {\n\t\t\t\tt.Errorf(\"EstimateRequestTokens() = %d, want >= %d\", result, tt.minExpected)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "backend-go/internal/warmup/url_manager.go",
    "content": "// Package warmup 提供多端点渠道的 URL 管理和动态排序功能\npackage warmup\n\nimport (\n\t\"log\"\n\t\"sort\"\n\t\"sync\"\n\t\"time\"\n)\n\n// URLLatencyResult 单个 URL 的结果（兼容旧接口）\ntype URLLatencyResult struct {\n\tURL         string\n\tOriginalIdx int  // 原始索引（用于指标记录）\n\tSuccess     bool // 是否可用（未在冷却期内）\n}\n\n// URLState URL 状态信息\ntype URLState struct {\n\tURL             string\n\tOriginalIdx     int       // 原始索引（用于指标记录）\n\tFailCount       int       // 连续失败次数\n\tLastFailTime    time.Time // 最后失败时间\n\tLastSuccessTime time.Time // 最后成功时间\n\tTotalRequests   int64     // 总请求数\n\tTotalFailures   int64     // 总失败数\n}\n\n// ChannelURLState 渠道 URL 状态\ntype ChannelURLState struct {\n\tChannelIndex int\n\tURLs         []*URLState\n\tUpdatedAt    time.Time\n}\n\n// URLManager URL 管理器（非阻塞，基于 failover 动态排序）\ntype URLManager struct {\n\tmu              sync.RWMutex\n\tchannelStates   map[int]*ChannelURLState // key: channelIndex\n\tfailureCooldown time.Duration            // 失败冷却时间（过后允许重试）\n\tmaxFailCount    int                      // 最大连续失败次数（超过则移到末尾）\n}\n\n// NewURLManager 创建 URL 管理器\nfunc NewURLManager(failureCooldown time.Duration, maxFailCount int) *URLManager {\n\tif failureCooldown <= 0 {\n\t\tfailureCooldown = 30 * time.Second // 默认 30 秒冷却\n\t}\n\tif maxFailCount <= 0 {\n\t\tmaxFailCount = 3 // 默认连续 3 次失败后移到末尾\n\t}\n\treturn &URLManager{\n\t\tchannelStates:   make(map[int]*ChannelURLState),\n\t\tfailureCooldown: failureCooldown,\n\t\tmaxFailCount:    maxFailCount,\n\t}\n}\n\n// GetSortedURLs 获取排序后的 URL 列表（非阻塞，立即返回）\n// 排序规则：\n// 1. 成功的 URL 优先\n// 2. 冷却期过后的失败 URL 可重试\n// 3. 仍在冷却期的失败 URL 放到最后\nfunc (m *URLManager) GetSortedURLs(channelIndex int, urls []string) []URLLatencyResult {\n\tif len(urls) == 0 {\n\t\treturn nil\n\t}\n\tif len(urls) == 1 {\n\t\treturn []URLLatencyResult{{URL: urls[0], OriginalIdx: 0, Success: true}}\n\t}\n\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\t// 确保渠道状态存在并同步 URL 列表\n\tstate := m.ensureChannelState(channelIndex, urls)\n\n\t// 每次获取时重新排序，确保冷却期过后的 URL 能被正确提升\n\tm.sortURLs(state)\n\n\t// 构建排序后的结果\n\tnow := time.Now()\n\tresults := make([]URLLatencyResult, len(state.URLs))\n\n\tfor i, urlState := range state.URLs {\n\t\tresults[i] = URLLatencyResult{\n\t\t\tURL:         urlState.URL,\n\t\t\tOriginalIdx: urlState.OriginalIdx,\n\t\t\tSuccess:     urlState.FailCount == 0 || now.Sub(urlState.LastFailTime) >= m.failureCooldown,\n\t\t}\n\t}\n\n\treturn results\n}\n\n// MarkSuccess 标记 URL 成功\nfunc (m *URLManager) MarkSuccess(channelIndex int, url string) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tstate, ok := m.channelStates[channelIndex]\n\tif !ok {\n\t\treturn\n\t}\n\n\tfor _, urlState := range state.URLs {\n\t\tif urlState.URL == url {\n\t\t\turlState.FailCount = 0\n\t\t\turlState.LastSuccessTime = time.Now()\n\t\t\turlState.TotalRequests++\n\t\t\tbreak\n\t\t}\n\t}\n\n\t// 成功后重新排序：成功的 URL 提升到前面\n\tm.sortURLs(state)\n\tstate.UpdatedAt = time.Now()\n}\n\n// MarkFailure 标记 URL 失败\nfunc (m *URLManager) MarkFailure(channelIndex int, url string) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tstate, ok := m.channelStates[channelIndex]\n\tif !ok {\n\t\treturn\n\t}\n\n\tnow := time.Now()\n\tfor _, urlState := range state.URLs {\n\t\tif urlState.URL == url {\n\t\t\turlState.FailCount++\n\t\t\turlState.LastFailTime = now\n\t\t\turlState.TotalRequests++\n\t\t\turlState.TotalFailures++\n\t\t\tlog.Printf(\"[URLManager] URL 失败: 渠道 [%d], URL: %s, 连续失败: %d\", channelIndex, url, urlState.FailCount)\n\t\t\tbreak\n\t\t}\n\t}\n\n\t// 失败后重新排序：失败的 URL 移到后面\n\tm.sortURLs(state)\n\tstate.UpdatedAt = time.Now()\n}\n\n// ensureChannelState 确保渠道状态存在，并同步 URL 列表\nfunc (m *URLManager) ensureChannelState(channelIndex int, urls []string) *ChannelURLState {\n\tstate, ok := m.channelStates[channelIndex]\n\n\tif !ok {\n\t\t// 初始化新渠道状态\n\t\tstate = &ChannelURLState{\n\t\t\tChannelIndex: channelIndex,\n\t\t\tURLs:         make([]*URLState, len(urls)),\n\t\t\tUpdatedAt:    time.Now(),\n\t\t}\n\t\tfor i, url := range urls {\n\t\t\tstate.URLs[i] = &URLState{\n\t\t\t\tURL:         url,\n\t\t\t\tOriginalIdx: i,\n\t\t\t}\n\t\t}\n\t\tm.channelStates[channelIndex] = state\n\t\treturn state\n\t}\n\n\t// 检查 URL 列表是否变化（配置热重载场景）\n\tif !m.urlsMatch(state.URLs, urls) {\n\t\tlog.Printf(\"[URLManager] 检测到渠道 [%d] URL 配置变化，重置状态\", channelIndex)\n\t\tstate = &ChannelURLState{\n\t\t\tChannelIndex: channelIndex,\n\t\t\tURLs:         make([]*URLState, len(urls)),\n\t\t\tUpdatedAt:    time.Now(),\n\t\t}\n\t\tfor i, url := range urls {\n\t\t\tstate.URLs[i] = &URLState{\n\t\t\t\tURL:         url,\n\t\t\t\tOriginalIdx: i,\n\t\t\t}\n\t\t}\n\t\tm.channelStates[channelIndex] = state\n\t}\n\n\treturn state\n}\n\n// urlsMatch 检查 URL 列表是否匹配\nfunc (m *URLManager) urlsMatch(states []*URLState, urls []string) bool {\n\tif len(states) != len(urls) {\n\t\treturn false\n\t}\n\turlSet := make(map[string]bool)\n\tfor _, url := range urls {\n\t\turlSet[url] = true\n\t}\n\tfor _, state := range states {\n\t\tif !urlSet[state.URL] {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// sortURLs 对 URL 列表排序\n// 排序规则：\n// 1. 无失败记录的 URL 在最前（按原始索引排序）\n// 2. 冷却期已过的失败 URL 次之（按失败次数升序）\n// 3. 仍在冷却期的失败 URL 在最后（按冷却剩余时间升序）\nfunc (m *URLManager) sortURLs(state *ChannelURLState) {\n\tnow := time.Now()\n\n\tsort.SliceStable(state.URLs, func(i, j int) bool {\n\t\tui, uj := state.URLs[i], state.URLs[j]\n\n\t\t// 无失败记录的优先\n\t\tiNoFail := ui.FailCount == 0\n\t\tjNoFail := uj.FailCount == 0\n\t\tif iNoFail != jNoFail {\n\t\t\treturn iNoFail\n\t\t}\n\t\tif iNoFail && jNoFail {\n\t\t\t// 都无失败，按原始索引\n\t\t\treturn ui.OriginalIdx < uj.OriginalIdx\n\t\t}\n\n\t\t// 都有失败记录，检查冷却期\n\t\tiCooldownPassed := now.Sub(ui.LastFailTime) >= m.failureCooldown\n\t\tjCooldownPassed := now.Sub(uj.LastFailTime) >= m.failureCooldown\n\n\t\tif iCooldownPassed != jCooldownPassed {\n\t\t\treturn iCooldownPassed // 冷却期过了的优先\n\t\t}\n\n\t\tif iCooldownPassed && jCooldownPassed {\n\t\t\t// 都过了冷却期，失败次数少的优先\n\t\t\tif ui.FailCount != uj.FailCount {\n\t\t\t\treturn ui.FailCount < uj.FailCount\n\t\t\t}\n\t\t\treturn ui.OriginalIdx < uj.OriginalIdx\n\t\t}\n\n\t\t// 都在冷却期内，剩余冷却时间短的优先\n\t\tiRemaining := m.failureCooldown - now.Sub(ui.LastFailTime)\n\t\tjRemaining := m.failureCooldown - now.Sub(uj.LastFailTime)\n\t\treturn iRemaining < jRemaining\n\t})\n}\n\n// InvalidateChannel 使渠道状态失效\nfunc (m *URLManager) InvalidateChannel(channelIndex int) {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tdelete(m.channelStates, channelIndex)\n\tlog.Printf(\"[URLManager] 渠道 [%d] 状态已清除\", channelIndex)\n}\n\n// InvalidateAll 清除所有状态\nfunc (m *URLManager) InvalidateAll() {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\tm.channelStates = make(map[int]*ChannelURLState)\n\tlog.Printf(\"[URLManager] 所有渠道状态已清除\")\n}\n\n// GetStats 获取统计信息\nfunc (m *URLManager) GetStats() map[string]interface{} {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tchannelStats := make(map[int]interface{})\n\tfor idx, state := range m.channelStates {\n\t\turlStats := make([]map[string]interface{}, len(state.URLs))\n\t\tfor i, urlState := range state.URLs {\n\t\t\turlStats[i] = map[string]interface{}{\n\t\t\t\t\"url\":               urlState.URL,\n\t\t\t\t\"original_idx\":      urlState.OriginalIdx,\n\t\t\t\t\"fail_count\":        urlState.FailCount,\n\t\t\t\t\"total_requests\":    urlState.TotalRequests,\n\t\t\t\t\"total_failures\":    urlState.TotalFailures,\n\t\t\t\t\"last_fail_time\":    urlState.LastFailTime,\n\t\t\t\t\"last_success_time\": urlState.LastSuccessTime,\n\t\t\t}\n\t\t}\n\t\tchannelStats[idx] = map[string]interface{}{\n\t\t\t\"urls\":       urlStats,\n\t\t\t\"updated_at\": state.UpdatedAt,\n\t\t}\n\t}\n\n\treturn map[string]interface{}{\n\t\t\"total_channels\":   len(m.channelStates),\n\t\t\"failure_cooldown\": m.failureCooldown.String(),\n\t\t\"max_fail_count\":   m.maxFailCount,\n\t\t\"channels\":         channelStats,\n\t}\n}\n"
  },
  {
    "path": "backend-go/main.go",
    "content": "package main\n\nimport (\n\t\"context\"\n\t\"embed\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/BenedictKing/claude-proxy/internal/config\"\n\t\"github.com/BenedictKing/claude-proxy/internal/handlers\"\n\t\"github.com/BenedictKing/claude-proxy/internal/handlers/gemini\"\n\t\"github.com/BenedictKing/claude-proxy/internal/handlers/messages\"\n\t\"github.com/BenedictKing/claude-proxy/internal/handlers/responses\"\n\t\"github.com/BenedictKing/claude-proxy/internal/logger\"\n\t\"github.com/BenedictKing/claude-proxy/internal/metrics\"\n\t\"github.com/BenedictKing/claude-proxy/internal/middleware\"\n\t\"github.com/BenedictKing/claude-proxy/internal/scheduler\"\n\t\"github.com/BenedictKing/claude-proxy/internal/session\"\n\t\"github.com/BenedictKing/claude-proxy/internal/warmup\"\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/joho/godotenv\"\n)\n\n//go:embed all:frontend/dist\nvar frontendFS embed.FS\n\nfunc main() {\n\t// 加载环境变量\n\tif err := godotenv.Load(); err != nil {\n\t\tlog.Println(\"没有找到 .env 文件，使用环境变量或默认值\")\n\t}\n\n\t// 设置版本信息到 handlers 包\n\thandlers.SetVersionInfo(Version, BuildTime, GitCommit)\n\n\t// 初始化配置管理器\n\tenvCfg := config.NewEnvConfig()\n\n\t// 初始化日志系统（必须在其他初始化之前）\n\tlogCfg := &logger.Config{\n\t\tLogDir:     envCfg.LogDir,\n\t\tLogFile:    envCfg.LogFile,\n\t\tMaxSize:    envCfg.LogMaxSize,\n\t\tMaxBackups: envCfg.LogMaxBackups,\n\t\tMaxAge:     envCfg.LogMaxAge,\n\t\tCompress:   envCfg.LogCompress,\n\t\tConsole:    envCfg.LogToConsole,\n\t}\n\tif err := logger.Setup(logCfg); err != nil {\n\t\tlog.Fatalf(\"初始化日志系统失败: %v\", err)\n\t}\n\n\tcfgManager, err := config.NewConfigManager(\".config/config.json\")\n\tif err != nil {\n\t\tlog.Fatalf(\"初始化配置管理器失败: %v\", err)\n\t}\n\tdefer cfgManager.Close()\n\n\t// 初始化会话管理器（Responses API 专用）\n\tsessionManager := session.NewSessionManager(\n\t\t24*time.Hour, // 24小时过期\n\t\t100,          // 最多100条消息\n\t\t100000,       // 最多100k tokens\n\t)\n\tlog.Printf(\"[Session-Init] 会话管理器已初始化\")\n\n\t// 初始化指标持久化存储（可选）\n\tvar metricsStore *metrics.SQLiteStore\n\tif envCfg.MetricsPersistenceEnabled {\n\t\tvar err error\n\t\tmetricsStore, err = metrics.NewSQLiteStore(&metrics.SQLiteStoreConfig{\n\t\t\tDBPath:        \".config/metrics.db\",\n\t\t\tRetentionDays: envCfg.MetricsRetentionDays,\n\t\t})\n\t\tif err != nil {\n\t\t\tlog.Printf(\"[Metrics-Init] 警告: 初始化指标持久化存储失败: %v，将使用纯内存模式\", err)\n\t\t\tmetricsStore = nil\n\t\t}\n\t} else {\n\t\tlog.Printf(\"[Metrics-Init] 指标持久化已禁用，使用纯内存模式\")\n\t}\n\n\t// 初始化多渠道调度器（Messages、Responses 和 Gemini 使用独立的指标管理器）\n\tvar messagesMetricsManager, responsesMetricsManager, geminiMetricsManager *metrics.MetricsManager\n\tif metricsStore != nil {\n\t\tmessagesMetricsManager = metrics.NewMetricsManagerWithPersistence(\n\t\t\tenvCfg.MetricsWindowSize, envCfg.MetricsFailureThreshold, metricsStore, \"messages\")\n\t\tresponsesMetricsManager = metrics.NewMetricsManagerWithPersistence(\n\t\t\tenvCfg.MetricsWindowSize, envCfg.MetricsFailureThreshold, metricsStore, \"responses\")\n\t\tgeminiMetricsManager = metrics.NewMetricsManagerWithPersistence(\n\t\t\tenvCfg.MetricsWindowSize, envCfg.MetricsFailureThreshold, metricsStore, \"gemini\")\n\t} else {\n\t\tmessagesMetricsManager = metrics.NewMetricsManagerWithConfig(envCfg.MetricsWindowSize, envCfg.MetricsFailureThreshold)\n\t\tresponsesMetricsManager = metrics.NewMetricsManagerWithConfig(envCfg.MetricsWindowSize, envCfg.MetricsFailureThreshold)\n\t\tgeminiMetricsManager = metrics.NewMetricsManagerWithConfig(envCfg.MetricsWindowSize, envCfg.MetricsFailureThreshold)\n\t}\n\ttraceAffinityManager := session.NewTraceAffinityManager()\n\n\t// 初始化 URL 管理器（非阻塞，动态排序）\n\turlManager := warmup.NewURLManager(30*time.Second, 3) // 30秒冷却期，连续3次失败后移到末尾\n\tlog.Printf(\"[URLManager-Init] URL管理器已初始化 (冷却期: 30秒, 最大连续失败: 3)\")\n\n\tchannelScheduler := scheduler.NewChannelScheduler(cfgManager, messagesMetricsManager, responsesMetricsManager, geminiMetricsManager, traceAffinityManager, urlManager)\n\tlog.Printf(\"[Scheduler-Init] 多渠道调度器已初始化 (失败率阈值: %.0f%%, 滑动窗口: %d)\",\n\t\tmessagesMetricsManager.GetFailureThreshold()*100, messagesMetricsManager.GetWindowSize())\n\n\t// 设置 Gin 模式\n\tif envCfg.IsProduction() {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\n\t// 创建路由器（使用自定义 Logger，根据 QUIET_POLLING_LOGS 配置过滤轮询日志）\n\tr := gin.New()\n\tr.Use(middleware.FilteredLogger(envCfg))\n\tr.Use(gin.Recovery())\n\n\t// 配置 CORS\n\tr.Use(middleware.CORSMiddleware(envCfg))\n\n\t// Web UI 访问控制中间件\n\tr.Use(middleware.WebAuthMiddleware(envCfg, cfgManager))\n\n\t// 健康检查端点（固定路径 /health，与 Dockerfile HEALTHCHECK 保持一致）\n\tr.GET(\"/health\", handlers.HealthCheck(envCfg, cfgManager))\n\n\t// 配置保存端点\n\tr.POST(\"/admin/config/save\", handlers.SaveConfigHandler(cfgManager))\n\n\t// 开发信息端点\n\tif envCfg.IsDevelopment() {\n\t\tr.GET(\"/admin/dev/info\", handlers.DevInfo(envCfg, cfgManager))\n\t}\n\n\t// Web 管理界面 API 路由\n\tapiGroup := r.Group(\"/api\")\n\t{\n\t\t// Messages 渠道管理\n\t\tapiGroup.GET(\"/messages/channels\", messages.GetUpstreams(cfgManager))\n\t\tapiGroup.POST(\"/messages/channels\", messages.AddUpstream(cfgManager))\n\t\tapiGroup.PUT(\"/messages/channels/:id\", messages.UpdateUpstream(cfgManager, channelScheduler))\n\t\tapiGroup.DELETE(\"/messages/channels/:id\", messages.DeleteUpstream(cfgManager, channelScheduler))\n\t\tapiGroup.POST(\"/messages/channels/:id/keys\", messages.AddApiKey(cfgManager))\n\t\tapiGroup.DELETE(\"/messages/channels/:id/keys/:apiKey\", messages.DeleteApiKey(cfgManager))\n\t\tapiGroup.POST(\"/messages/channels/:id/keys/:apiKey/top\", messages.MoveApiKeyToTop(cfgManager))\n\t\tapiGroup.POST(\"/messages/channels/:id/keys/:apiKey/bottom\", messages.MoveApiKeyToBottom(cfgManager))\n\n\t\t// Messages 多渠道调度 API\n\t\tapiGroup.POST(\"/messages/channels/reorder\", messages.ReorderChannels(cfgManager))\n\t\tapiGroup.PATCH(\"/messages/channels/:id/status\", messages.SetChannelStatus(cfgManager))\n\t\tapiGroup.POST(\"/messages/channels/:id/resume\", handlers.ResumeChannel(channelScheduler, false))\n\t\tapiGroup.POST(\"/messages/channels/:id/promotion\", messages.SetChannelPromotion(cfgManager))\n\t\tapiGroup.GET(\"/messages/channels/metrics\", handlers.GetChannelMetricsWithConfig(messagesMetricsManager, cfgManager, false))\n\t\tapiGroup.GET(\"/messages/channels/metrics/history\", handlers.GetChannelMetricsHistory(messagesMetricsManager, cfgManager, false))\n\t\tapiGroup.GET(\"/messages/channels/:id/keys/metrics/history\", handlers.GetChannelKeyMetricsHistory(messagesMetricsManager, cfgManager, false))\n\t\tapiGroup.GET(\"/messages/channels/scheduler/stats\", handlers.GetSchedulerStats(channelScheduler))\n\t\tapiGroup.GET(\"/messages/global/stats/history\", handlers.GetGlobalStatsHistory(messagesMetricsManager))\n\t\tapiGroup.GET(\"/messages/channels/dashboard\", handlers.GetChannelDashboard(cfgManager, channelScheduler))\n\t\tapiGroup.GET(\"/messages/ping/:id\", messages.PingChannel(cfgManager))\n\t\tapiGroup.GET(\"/messages/ping\", messages.PingAllChannels(cfgManager))\n\n\t\t// Responses 渠道管理\n\t\tapiGroup.GET(\"/responses/channels\", responses.GetUpstreams(cfgManager))\n\t\tapiGroup.POST(\"/responses/channels\", responses.AddUpstream(cfgManager))\n\t\tapiGroup.PUT(\"/responses/channels/:id\", responses.UpdateUpstream(cfgManager, channelScheduler))\n\t\tapiGroup.DELETE(\"/responses/channels/:id\", responses.DeleteUpstream(cfgManager, channelScheduler))\n\t\tapiGroup.POST(\"/responses/channels/:id/keys\", responses.AddApiKey(cfgManager))\n\t\tapiGroup.DELETE(\"/responses/channels/:id/keys/:apiKey\", responses.DeleteApiKey(cfgManager))\n\t\tapiGroup.POST(\"/responses/channels/:id/keys/:apiKey/top\", responses.MoveApiKeyToTop(cfgManager))\n\t\tapiGroup.POST(\"/responses/channels/:id/keys/:apiKey/bottom\", responses.MoveApiKeyToBottom(cfgManager))\n\n\t\t// Responses 多渠道调度 API\n\t\tapiGroup.POST(\"/responses/channels/reorder\", responses.ReorderChannels(cfgManager))\n\t\tapiGroup.PATCH(\"/responses/channels/:id/status\", responses.SetChannelStatus(cfgManager))\n\t\tapiGroup.POST(\"/responses/channels/:id/resume\", handlers.ResumeChannel(channelScheduler, true))\n\t\tapiGroup.POST(\"/responses/channels/:id/promotion\", handlers.SetResponsesChannelPromotion(cfgManager))\n\t\tapiGroup.GET(\"/responses/channels/metrics\", handlers.GetChannelMetricsWithConfig(responsesMetricsManager, cfgManager, true))\n\t\tapiGroup.GET(\"/responses/channels/metrics/history\", handlers.GetChannelMetricsHistory(responsesMetricsManager, cfgManager, true))\n\t\tapiGroup.GET(\"/responses/channels/:id/keys/metrics/history\", handlers.GetChannelKeyMetricsHistory(responsesMetricsManager, cfgManager, true))\n\t\tapiGroup.GET(\"/responses/global/stats/history\", handlers.GetGlobalStatsHistory(responsesMetricsManager))\n\n\t\t// Gemini 渠道管理\n\t\tapiGroup.GET(\"/gemini/channels\", gemini.GetUpstreams(cfgManager))\n\t\tapiGroup.POST(\"/gemini/channels\", gemini.AddUpstream(cfgManager))\n\t\tapiGroup.PUT(\"/gemini/channels/:id\", gemini.UpdateUpstream(cfgManager, channelScheduler))\n\t\tapiGroup.DELETE(\"/gemini/channels/:id\", gemini.DeleteUpstream(cfgManager, channelScheduler))\n\t\tapiGroup.POST(\"/gemini/channels/:id/keys\", gemini.AddApiKey(cfgManager))\n\t\tapiGroup.DELETE(\"/gemini/channels/:id/keys/:apiKey\", gemini.DeleteApiKey(cfgManager))\n\t\tapiGroup.POST(\"/gemini/channels/:id/keys/:apiKey/top\", gemini.MoveApiKeyToTop(cfgManager))\n\t\tapiGroup.POST(\"/gemini/channels/:id/keys/:apiKey/bottom\", gemini.MoveApiKeyToBottom(cfgManager))\n\n\t\t// Gemini 多渠道调度 API\n\t\tapiGroup.POST(\"/gemini/channels/reorder\", gemini.ReorderChannels(cfgManager))\n\t\tapiGroup.PATCH(\"/gemini/channels/:id/status\", gemini.SetChannelStatus(cfgManager))\n\t\tapiGroup.POST(\"/gemini/channels/:id/promotion\", gemini.SetChannelPromotion(cfgManager))\n\t\tapiGroup.PUT(\"/gemini/loadbalance\", gemini.UpdateLoadBalance(cfgManager))\n\t\tapiGroup.GET(\"/gemini/channels/dashboard\", gemini.GetDashboard(cfgManager, channelScheduler))\n\t\tapiGroup.GET(\"/gemini/channels/metrics\", handlers.GetGeminiChannelMetrics(geminiMetricsManager, cfgManager))\n\t\tapiGroup.GET(\"/gemini/channels/metrics/history\", handlers.GetGeminiChannelMetricsHistory(geminiMetricsManager, cfgManager))\n\t\tapiGroup.GET(\"/gemini/channels/:id/keys/metrics/history\", handlers.GetGeminiChannelKeyMetricsHistory(geminiMetricsManager, cfgManager))\n\t\tapiGroup.GET(\"/gemini/global/stats/history\", handlers.GetGlobalStatsHistory(geminiMetricsManager))\n\t\tapiGroup.GET(\"/gemini/ping/:id\", gemini.PingChannel(cfgManager))\n\t\tapiGroup.GET(\"/gemini/ping\", gemini.PingAllChannels(cfgManager))\n\n\t\t// Fuzzy 模式设置\n\t\tapiGroup.GET(\"/settings/fuzzy-mode\", handlers.GetFuzzyMode(cfgManager))\n\t\tapiGroup.PUT(\"/settings/fuzzy-mode\", handlers.SetFuzzyMode(cfgManager))\n\t}\n\n\t// 代理端点 - Messages API\n\tr.POST(\"/v1/messages\", messages.Handler(envCfg, cfgManager, channelScheduler))\n\tr.POST(\"/v1/messages/count_tokens\", messages.CountTokensHandler(envCfg, cfgManager, channelScheduler))\n\n\t// 代理端点 - Models API（转发到上游）\n\tr.GET(\"/v1/models\", messages.ModelsHandler(envCfg, cfgManager, channelScheduler))\n\tr.GET(\"/v1/models/:model\", messages.ModelsDetailHandler(envCfg, cfgManager, channelScheduler))\n\n\t// 代理端点 - Responses API\n\tr.POST(\"/v1/responses\", responses.Handler(envCfg, cfgManager, sessionManager, channelScheduler))\n\tr.POST(\"/v1/responses/compact\", responses.CompactHandler(envCfg, cfgManager, sessionManager, channelScheduler))\n\n\t// 代理端点 - Gemini API (原生协议)\n\t// 使用通配符捕获 model:action 格式，如 gemini-pro:generateContent\n\t// 路径格式：/v1beta/models/{model}:generateContent (Gemini 原生格式)\n\tr.POST(\"/v1beta/models/*modelAction\", gemini.Handler(envCfg, cfgManager, channelScheduler))\n\n\t// 静态文件服务 (嵌入的前端)\n\tif envCfg.EnableWebUI {\n\t\thandlers.ServeFrontend(r, frontendFS)\n\t} else {\n\t\t// 纯 API 模式\n\t\tr.GET(\"/\", func(c *gin.Context) {\n\t\t\tc.JSON(200, gin.H{\n\t\t\t\t\"name\":    \"Claude API Proxy\",\n\t\t\t\t\"mode\":    \"API Only\",\n\t\t\t\t\"version\": \"1.0.0\",\n\t\t\t\t\"endpoints\": gin.H{\n\t\t\t\t\t\"health\": \"/health\",\n\t\t\t\t\t\"proxy\":  \"/v1/messages\",\n\t\t\t\t\t\"config\": \"/admin/config/save\",\n\t\t\t\t},\n\t\t\t\t\"message\": \"Web界面已禁用，此服务器运行在纯API模式下\",\n\t\t\t})\n\t\t})\n\t}\n\n\t// 启动服务器\n\taddr := fmt.Sprintf(\":%d\", envCfg.Port)\n\tfmt.Printf(\"\\n[Server-Startup] Claude API代理服务器已启动\\n\")\n\tfmt.Printf(\"[Server-Info] 版本: %s\\n\", Version)\n\tif BuildTime != \"unknown\" {\n\t\tfmt.Printf(\"[Server-Info] 构建时间: %s\\n\", BuildTime)\n\t}\n\tif GitCommit != \"unknown\" {\n\t\tfmt.Printf(\"[Server-Info] Git提交: %s\\n\", GitCommit)\n\t}\n\tfmt.Printf(\"[Server-Info] 管理界面: http://localhost:%d\\n\", envCfg.Port)\n\tfmt.Printf(\"[Server-Info] API 地址: http://localhost:%d/v1\\n\", envCfg.Port)\n\tfmt.Printf(\"[Server-Info] Claude Messages: POST /v1/messages\\n\")\n\tfmt.Printf(\"[Server-Info] Codex Responses: POST /v1/responses\\n\")\n\tfmt.Printf(\"[Server-Info] Gemini API: POST /v1beta/models/{model}:generateContent\\n\")\n\tfmt.Printf(\"[Server-Info] Gemini API: POST /v1beta/models/{model}:streamGenerateContent\\n\")\n\tfmt.Printf(\"[Server-Info] 健康检查: GET /health\\n\")\n\tfmt.Printf(\"[Server-Info] 环境: %s\\n\", envCfg.Env)\n\t// 检查是否使用默认密码，给予提示\n\tif envCfg.ProxyAccessKey == \"your-proxy-access-key\" {\n\t\tfmt.Printf(\"[Server-Warn] 访问密钥: your-proxy-access-key (默认值，建议通过 .env 文件修改)\\n\")\n\t}\n\tfmt.Printf(\"\\n\")\n\n\t// 创建 HTTP 服务器\n\tsrv := &http.Server{\n\t\tAddr:              addr,\n\t\tHandler:           r,\n\t\tReadHeaderTimeout: 10 * time.Second,\n\t\tIdleTimeout:       120 * time.Second,\n\t}\n\n\t// 用于传递关闭结果\n\tshutdownDone := make(chan struct{})\n\n\t// 优雅关闭：监听系统信号\n\tgo func() {\n\t\tsigChan := make(chan os.Signal, 1)\n\t\tsignal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)\n\t\t<-sigChan\n\t\tsignal.Stop(sigChan) // 停止信号监听，避免资源泄漏\n\n\t\tlog.Println(\"[Server-Shutdown] 收到关闭信号，正在优雅关闭服务器...\")\n\n\t\t// 创建超时上下文\n\t\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\t\tdefer cancel()\n\n\t\tif err := srv.Shutdown(ctx); err != nil {\n\t\t\tlog.Printf(\"[Server-Shutdown] 警告: 服务器关闭时发生错误: %v\", err)\n\t\t} else {\n\t\t\tlog.Println(\"[Server-Shutdown] 服务器已安全关闭\")\n\t\t}\n\n\t\t// 关闭指标持久化存储\n\t\tif metricsStore != nil {\n\t\t\tif err := metricsStore.Close(); err != nil {\n\t\t\t\tlog.Printf(\"[Metrics-Shutdown] 警告: 关闭指标存储时发生错误: %v\", err)\n\t\t\t} else {\n\t\t\t\tlog.Println(\"[Metrics-Shutdown] 指标存储已安全关闭\")\n\t\t\t}\n\t\t}\n\n\t\tclose(shutdownDone)\n\t}()\n\n\t// 启动服务器（阻塞直到关闭）\n\tif err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {\n\t\tlog.Fatalf(\"服务器启动失败: %v\", err)\n\t}\n\n\t// 等待关闭完成（带超时保护，避免死锁）\n\tselect {\n\tcase <-shutdownDone:\n\t\t// 正常关闭完成\n\tcase <-time.After(15 * time.Second):\n\t\tlog.Println(\"[Server-Shutdown] 警告: 等待关闭超时\")\n\t}\n}\n"
  },
  {
    "path": "backend-go/version.go",
    "content": "package main\n\n// 版本信息变量 - 在构建时通过 -ldflags 注入\n// 实际值从根目录 VERSION 文件读取\nvar (\n\t// Version 当前版本号（构建时从 VERSION 文件注入）\n\tVersion = \"v0.0.0-dev\"\n\n\t// BuildTime 构建时间（构建时注入）\n\tBuildTime = \"unknown\"\n\n\t// GitCommit Git提交哈希（构建时注入）\n\tGitCommit = \"unknown\"\n)\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  # Claude API 代理服务 (一体化架构: 后端 + 前端界面)\n  claude-proxy:\n    image: crpi-i19l8zl0ugidq97v.cn-hangzhou.personal.cr.aliyuncs.com/bene/claude-proxy:latest\n    # 本地构建使用以下配置（注释掉 image 行）:\n    # build:\n    #   context: .\n    #   dockerfile: Dockerfile\n    #   # 中国网络使用:\n    #   # dockerfile: Dockerfile_China\n    #   target: runtime\n    container_name: claude-proxy\n    ports:\n      # 统一端口：前端界面 + API + 管理接口\n      - '3000:3000'\n    volumes:\n      # 配置目录持久化\n      - ./.config:/app/.config\n      # 可选：日志持久化\n      - ./logs:/app/logs\n    environment:\n      # 基础配置\n      - ENV=production\n      # 是否启用Web管理界面 (true=一体化模式, false=纯API模式)\n      - ENABLE_WEB_UI=true\n      # 代理访问密钥 - 用于验证客户端对代理服务器的访问权限\n      - PROXY_ACCESS_KEY=your-super-strong-secret-key\n      # 日志级别 (error, warn, info, debug)\n      - LOG_LEVEL=warn\n      # 是否启用请求日志\n      - ENABLE_REQUEST_LOGS=false\n      # 是否启用响应日志\n      - ENABLE_RESPONSE_LOGS=false\n    # 健康检查配置\n    healthcheck:\n      test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:3000/health']\n      interval: 30s\n      timeout: 10s\n      retries: 3\n      start_period: 40s\n\n    # 资源限制 (Go 应用内存占用更低)\n    deploy:\n      resources:\n        limits:\n          memory: 256M\n          cpus: '0.5'\n        reservations:\n          memory: 64M\n          cpus: '0.25'\n\n    # 重启策略\n    restart: unless-stopped\n# 网络配置（可选，使用默认bridge网络）\n# networks:\n#   default:\n#     driver: bridge\n"
  },
  {
    "path": "frontend/.env.example",
    "content": "# 开发环境配置示例\n# 复制此文件为 .env 并根据需要修改\n\n# 后端API服务器地址（需与 backend-go/.env 中的 PORT 一致）\nVITE_BACKEND_URL=http://localhost:3000\n\n# 前端开发服务器端口\nVITE_FRONTEND_PORT=5173\n\n# API路径配置\nVITE_API_BASE_PATH=/api\nVITE_PROXY_API_PATH=/v1\n\n# 开发模式标识\nVITE_APP_ENV=development\n"
  },
  {
    "path": "frontend/CLAUDE.md",
    "content": "# frontend 模块文档\n\n[← 根目录](../CLAUDE.md)\n\n## 模块职责\n\nVue 3 + Vuetify 3 Web 管理界面：渠道配置、实时监控、拖拽排序、主题切换。\n\n## 启动命令\n\n```bash\nbun run dev       # 开发服务器\nbun run build     # 生产构建\nbun run preview   # 预览构建\n```\n\n## 核心组件\n\n| 组件 | 职责 |\n|------|------|\n| `App.vue` | 根组件，认证和布局 |\n| `ChannelOrchestration.vue` | 渠道编排主界面 |\n| `ChannelCard.vue` | 渠道卡片（状态、密钥、指标） |\n| `AddChannelModal.vue` | 添加/编辑渠道对话框 |\n\n## API 服务\n\n`src/services/api.ts` 封装后端交互：\n\n- `fetchChannels()` / `addChannel()` / `updateChannel()` / `deleteChannel()`\n- `pingChannel()` / `pingAllChannels()`\n- `reorderChannels()` / `setChannelStatus()`\n\n## 主题配置\n\n编辑 `src/plugins/vuetify.ts` 中的 `lightTheme` 和 `darkTheme`。\n\n## 图标系统\n\n项目使用 **SVG 按需导入** 方案，从 `@mdi/js` 导入单个图标 path，而非完整字体文件，显著减小打包体积。\n\n**配置文件**: `src/plugins/vuetify.ts`\n\n**新增图标步骤**:\n1. 从 `@mdi/js` 添加导入（驼峰命名）\n2. 在 `iconMap` 中添加映射（kebab-case）\n\n```typescript\n// 1. 导入\nimport { mdiNewIcon } from '@mdi/js'\n\n// 2. 映射\nconst iconMap = {\n  'new-icon': mdiNewIcon,\n}\n```\n\n**使用方式**: 模板中使用 `mdi-xxx` 格式\n```vue\n<v-icon>mdi-new-icon</v-icon>\n```\n\n**图标查找**: https://pictogrammers.com/library/mdi/\n\n## 构建产物\n\n生产构建输出到 `dist/`，会被嵌入到 Go 后端二进制文件中（`embed.FS`）。\n"
  },
  {
    "path": "frontend/ESLINT.md",
    "content": "# ESLint 配置说明\n\n## 已安装的包\n\n- `eslint` - ESLint 核心\n- `@eslint/js` - ESLint JavaScript 推荐规则\n- `eslint-plugin-vue` - Vue 3 专用规则\n- `vue-eslint-parser` - Vue 文件解析器\n- `@typescript-eslint/parser` - TypeScript 解析器\n- `@typescript-eslint/eslint-plugin` - TypeScript 规则\n- `eslint-config-prettier` - 禁用与 Prettier 冲突的规则\n- `eslint-plugin-prettier` - 将 Prettier 作为 ESLint 规则运行\n\n## 可用命令\n\n```bash\n# 检查代码\nnpm run lint\n\n# 自动修复可修复的问题\nnpm run lint:fix\n\n# 格式化代码（Prettier）\nnpm run format\n```\n\n## 配置特点\n\n### 1. ESLint 9+ Flat Config 格式\n使用最新的 Flat Config 格式（`eslint.config.js`），不再使用旧的 `.eslintrc` 格式。\n\n### 2. Vue 3 支持\n- 使用 `eslint-plugin-vue` 的推荐规则\n- 支持 Vue 3 Composition API\n- 自动检测 Vue 组件问题\n\n### 3. TypeScript 支持\n- 完整的 TypeScript 类型检查\n- 自动检测未使用的变量（以 `_` 开头的变量会被忽略）\n- 警告使用 `any` 类型\n\n### 4. Prettier 集成\n- 自动禁用与 Prettier 冲突的规则\n- 保持代码风格一致性\n- 可以通过 `npm run format` 格式化代码\n\n### 5. 浏览器环境支持\n配置了常用的浏览器全局变量：\n- `window`, `document`, `navigator`\n- `localStorage`, `sessionStorage`\n- `setTimeout`, `setInterval`\n- `fetch`, `URL`, `AbortController`\n- 等等\n\n## 主要规则\n\n### Vue 规则\n- ✅ 允许单词组件名（`vue/multi-word-component-names: off`）\n- ⚠️ 警告使用 `v-html`\n- ⚠️ 建议显式声明 `emits`\n- ❌ 强制自闭合标签规范\n\n### TypeScript 规则\n- ⚠️ 警告使用 `any` 类型\n- ⚠️ 警告未使用的变量（以 `_` 开头除外）\n\n### 通用规则\n- ⚠️ 生产环境警告 `console.log`\n- ❌ 生产环境禁止 `debugger`\n- ⚠️ 建议使用 `const` 而非 `let`\n- ❌ 禁止使用 `var`\n\n## 忽略的文件\n\n以下文件/目录会被自动忽略：\n- `dist/**` - 构建产物\n- `node_modules/**` - 依赖包\n- `*.config.js` / `*.config.ts` - 配置文件\n- `coverage/**` - 测试覆盖率报告\n- `.vite/**` - Vite 缓存\n\n## 与 Prettier 的关系\n\nESLint 负责代码质量检查（逻辑错误、最佳实践等），Prettier 负责代码格式化（缩进、引号、分号等）。两者通过 `eslint-config-prettier` 完美集成，不会产生冲突。\n\n## 建议的工作流\n\n1. **开发时**：编辑器实时显示 ESLint 警告/错误\n2. **提交前**：运行 `npm run lint:fix` 自动修复问题\n3. **CI/CD**：在持续集成中运行 `npm run lint` 确保代码质量\n\n## IDE 集成\n\n### VS Code\n安装 ESLint 扩展：\n```\next install dbaeumer.vscode-eslint\n```\n\n在 `.vscode/settings.json` 中添加：\n```json\n{\n  \"editor.codeActionsOnSave\": {\n    \"source.fixAll.eslint\": true\n  },\n  \"eslint.validate\": [\n    \"javascript\",\n    \"typescript\",\n    \"vue\"\n  ]\n}\n```\n\n### WebStorm / IntelliJ IDEA\nESLint 支持已内置，在设置中启用即可：\n`Settings → Languages & Frameworks → JavaScript → Code Quality Tools → ESLint`\n\n## 自定义规则\n\n如需修改规则，编辑 `eslint.config.js` 文件。例如：\n\n```javascript\n{\n  rules: {\n    // 关闭某个规则\n    'vue/multi-word-component-names': 'off',\n\n    // 修改规则级别（off / warn / error）\n    'no-console': 'warn',\n\n    // 带选项的规则\n    '@typescript-eslint/no-unused-vars': [\n      'warn',\n      { argsIgnorePattern: '^_' }\n    ]\n  }\n}\n```\n\n## 常见问题\n\n### Q: 为什么有些规则显示警告而不是错误？\nA: 警告不会阻止代码运行，但会提醒您注意潜在问题。错误则必须修复。\n\n### Q: 如何临时禁用某个规则？\nA: 使用 ESLint 注释：\n```javascript\n// eslint-disable-next-line no-console\nconsole.log('debug info')\n\n/* eslint-disable vue/multi-word-component-names */\n// 多行代码\n/* eslint-enable vue/multi-word-component-names */\n```\n\n### Q: ESLint 和 Prettier 冲突怎么办？\nA: 已通过 `eslint-config-prettier` 解决冲突，不应该出现此问题。如果遇到，请检查配置顺序。\n\n## 参考资源\n\n- [ESLint 官方文档](https://eslint.org/)\n- [eslint-plugin-vue 文档](https://eslint.vuejs.org/)\n- [TypeScript ESLint 文档](https://typescript-eslint.io/)\n- [Prettier 官方文档](https://prettier.io/)\n"
  },
  {
    "path": "frontend/eslint.config.js",
    "content": "import js from '@eslint/js'\nimport pluginVue from 'eslint-plugin-vue'\nimport vueParser from 'vue-eslint-parser'\nimport tsParser from '@typescript-eslint/parser'\nimport tsPlugin from '@typescript-eslint/eslint-plugin'\nimport prettierConfig from 'eslint-config-prettier'\n\nexport default [\n  // 基础 JavaScript 推荐规则\n  js.configs.recommended,\n\n  // Vue 3 推荐规则\n  ...pluginVue.configs['flat/recommended'],\n\n  // Prettier 配置（禁用与 Prettier 冲突的规则）\n  prettierConfig,\n\n  // 全局配置\n  {\n    files: ['**/*.{js,ts,vue}'],\n    languageOptions: {\n      ecmaVersion: 'latest',\n      sourceType: 'module',\n      globals: {\n        // 浏览器环境\n        window: 'readonly',\n        document: 'readonly',\n        navigator: 'readonly',\n        console: 'readonly',\n        localStorage: 'readonly',\n        sessionStorage: 'readonly',\n        setTimeout: 'readonly',\n        setInterval: 'readonly',\n        clearTimeout: 'readonly',\n        clearInterval: 'readonly',\n        confirm: 'readonly',\n        alert: 'readonly',\n        prompt: 'readonly',\n        fetch: 'readonly',\n        URL: 'readonly',\n        URLSearchParams: 'readonly',\n        AbortController: 'readonly',\n        AbortSignal: 'readonly',\n        RequestInit: 'readonly',\n        Response: 'readonly',\n        Request: 'readonly',\n        Headers: 'readonly',\n        FormData: 'readonly',\n        Blob: 'readonly',\n        File: 'readonly',\n        Event: 'readonly',\n        KeyboardEvent: 'readonly',\n        MouseEvent: 'readonly',\n        // Node.js 环境\n        process: 'readonly',\n        __dirname: 'readonly',\n        __filename: 'readonly',\n        module: 'readonly',\n        require: 'readonly'\n      }\n    }\n  },\n\n  // TypeScript 文件配置\n  {\n    files: ['**/*.ts'],\n    languageOptions: {\n      parser: tsParser,\n      parserOptions: {\n        ecmaVersion: 'latest',\n        sourceType: 'module'\n      }\n    },\n    plugins: {\n      '@typescript-eslint': tsPlugin\n    },\n    rules: {\n      ...tsPlugin.configs.recommended.rules,\n      // TypeScript 特定规则\n      '@typescript-eslint/no-explicit-any': 'warn',\n      '@typescript-eslint/no-unused-vars': [\n        'warn',\n        {\n          argsIgnorePattern: '^_',\n          varsIgnorePattern: '^_'\n        }\n      ]\n    }\n  },\n\n  // Vue 文件配置\n  {\n    files: ['**/*.vue'],\n    languageOptions: {\n      parser: vueParser,\n      parserOptions: {\n        ecmaVersion: 'latest',\n        sourceType: 'module',\n        parser: tsParser,\n        extraFileExtensions: ['.vue']\n      }\n    },\n    rules: {\n      // Vue 3 特定规则\n      'vue/multi-word-component-names': 'off', // 允许单词组件名\n      'vue/no-v-html': 'warn', // 警告使用 v-html\n      'vue/require-default-prop': 'off', // 不强制要求 prop 默认值\n      'vue/require-explicit-emits': 'warn', // 建议显式声明 emits\n      // 代码风格（与 Prettier 不冲突的规则）\n      'vue/html-self-closing': [\n        'error',\n        {\n          html: {\n            void: 'always',\n            normal: 'never',\n            component: 'always'\n          },\n          svg: 'always',\n          math: 'always'\n        }\n      ],\n      'vue/max-attributes-per-line': 'off', // 由 Prettier 处理\n      'vue/singleline-html-element-content-newline': 'off', // 由 Prettier 处理\n      'vue/html-indent': 'off' // 由 Prettier 处理\n    }\n  },\n\n  // 通用规则\n  {\n    rules: {\n      // 代码质量\n      'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',\n      'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',\n      'no-unused-vars': [\n        'warn',\n        {\n          argsIgnorePattern: '^_',\n          varsIgnorePattern: '^_'\n        }\n      ],\n      'no-undef': 'error',\n      'prefer-const': 'warn',\n      'no-var': 'error',\n\n      // 代码风格（不与 Prettier 冲突）\n      'no-multiple-empty-lines': [\n        'error',\n        {\n          max: 1,\n          maxEOF: 0\n        }\n      ],\n      'eol-last': ['error', 'always']\n    }\n  },\n\n  // 忽略文件\n  {\n    ignores: [\n      'dist/**',\n      'node_modules/**',\n      '*.config.js',\n      '*.config.ts',\n      'coverage/**',\n      '.vite/**',\n      '.nuxt/**',\n      '.output/**'\n    ]\n  }\n]\n"
  },
  {
    "path": "frontend/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\" data-theme=\"emerald\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <title>Claude API Proxy 管理面板</title>\n  <link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">\n</head>\n<body>\n  <div id=\"app\"></div>\n  <script type=\"module\" src=\"/src/main.ts\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "frontend/package.json",
    "content": "{\n  \"name\": \"claude-proxy-frontend\",\n  \"version\": \"1.1.1\",\n  \"license\": \"MIT\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview\",\n    \"type-check\": \"vue-tsc --noEmit\",\n    \"lint\": \"eslint . --ext .vue,.js,.ts\",\n    \"lint:fix\": \"eslint . --ext .vue,.js,.ts --fix\",\n    \"format\": \"prettier --write \\\"src/**/*.{js,ts,vue,json,css,scss,md}\\\"\"\n  },\n  \"dependencies\": {\n    \"@mdi/js\": \"^7.4.47\",\n    \"apexcharts\": \"^5.3.6\",\n    \"pinia\": \"^3.0.4\",\n    \"pinia-plugin-persistedstate\": \"^4.7.1\",\n    \"vue\": \"^3.5.26\",\n    \"vue-router\": \"4\",\n    \"vue3-apexcharts\": \"^1.10.0\",\n    \"vuedraggable\": \"^4.1.0\",\n    \"vuetify\": \"^3.11.6\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.39.2\",\n    \"@typescript-eslint/eslint-plugin\": \"^8.53.0\",\n    \"@typescript-eslint/parser\": \"^8.53.0\",\n    \"@vitejs/plugin-vue\": \"^6.0.3\",\n    \"eslint\": \"^9.39.2\",\n    \"eslint-config-prettier\": \"^10.1.8\",\n    \"eslint-plugin-prettier\": \"^5.5.5\",\n    \"eslint-plugin-vue\": \"^10.7.0\",\n    \"sass-embedded\": \"^1.97.2\",\n    \"typescript\": \"^5.9.3\",\n    \"vite\": \"^7.3.1\",\n    \"vite-plugin-vuetify\": \"^2.1.2\",\n    \"vitest\": \"^4.0.17\",\n    \"vue-eslint-parser\": \"^10.2.0\",\n    \"vue-tsc\": \"^3.2.2\"\n  }\n}\n"
  },
  {
    "path": "frontend/src/App.vue",
    "content": "<template>\n  <v-app>\n    <!-- 自动认证加载提示 - 只在真正进行自动认证时显示 -->\n    <v-overlay\n      :model-value=\"authStore.isAutoAuthenticating && !authStore.isInitialized\"\n      persistent\n      class=\"align-center justify-center\"\n      scrim=\"black\"\n    >\n      <v-card class=\"pa-6 text-center\" max-width=\"400\" rounded=\"lg\">\n        <v-progress-circular indeterminate :size=\"64\" :width=\"6\" color=\"primary\" class=\"mb-4\" />\n        <div class=\"text-h6 mb-2\">正在验证访问权限</div>\n        <div class=\"text-body-2 text-medium-emphasis\">使用保存的访问密钥进行身份验证...</div>\n      </v-card>\n    </v-overlay>\n\n    <!-- 认证界面 -->\n    <v-dialog v-model=\"showAuthDialog\" persistent max-width=\"500\">\n      <v-card class=\"pa-4\">\n        <v-card-title class=\"text-h5 text-center mb-4\"> 🔐 Claude Proxy 管理界面 </v-card-title>\n\n        <v-card-text>\n          <v-alert v-if=\"authStore.authError\" type=\"error\" variant=\"tonal\" class=\"mb-4\">\n            {{ authStore.authError }}\n          </v-alert>\n\n          <v-form @submit.prevent=\"handleAuthSubmit\">\n            <v-text-field\n              v-model=\"authStore.authKeyInput\"\n              label=\"访问密钥 (PROXY_ACCESS_KEY)\"\n              type=\"password\"\n              variant=\"outlined\"\n              prepend-inner-icon=\"mdi-key\"\n              :rules=\"[v => !!v || '请输入访问密钥']\"\n              required\n              autofocus\n              @keyup.enter=\"handleAuthSubmit\"\n            />\n\n            <v-btn type=\"submit\" color=\"primary\" block size=\"large\" class=\"mt-4\" :loading=\"authStore.authLoading\">\n              访问管理界面\n            </v-btn>\n          </v-form>\n\n          <v-divider class=\"my-4\" />\n\n          <v-alert type=\"info\" variant=\"tonal\" density=\"compact\" class=\"mb-0\">\n            <div class=\"text-body-2\">\n              <p class=\"mb-2\"><strong>🔒 安全提示：</strong></p>\n              <ul class=\"ml-4 mb-0\">\n                <li>访问密钥在服务器的 <code>PROXY_ACCESS_KEY</code> 环境变量中设置</li>\n                <li>密钥将安全保存在本地，下次访问将自动验证登录</li>\n                <li>请勿与他人分享您的访问密钥</li>\n                <li>如果怀疑密钥泄露，请立即更改服务器配置</li>\n                <li>连续 {{ MAX_AUTH_ATTEMPTS }} 次认证失败将锁定 5 分钟</li>\n              </ul>\n            </div>\n          </v-alert>\n        </v-card-text>\n      </v-card>\n    </v-dialog>\n\n    <!-- 应用栏 - 毛玻璃效果 -->\n    <v-app-bar elevation=\"0\" :height=\"$vuetify.display.mobile ? 56 : 72\" class=\"app-header\">\n      <template #prepend>\n        <div class=\"app-logo\">\n          <v-icon :size=\"$vuetify.display.mobile ? 22 : 32\" color=\"white\"> mdi-rocket-launch </v-icon>\n        </div>\n      </template>\n\n      <!-- 自定义标题容器 - 替代 v-app-bar-title -->\n      <div class=\"header-title\">\n        <div :class=\"$vuetify.display.mobile ? 'text-body-2' : 'text-h6'\" class=\"font-weight-bold d-flex align-center\">\n          <router-link to=\"/channels/messages\" class=\"api-type-text\" :class=\"{ active: channelStore.activeTab === 'messages' }\">\n            Claude\n          </router-link>\n          <span class=\"api-type-text separator\">/</span>\n          <router-link to=\"/channels/responses\" class=\"api-type-text\" :class=\"{ active: channelStore.activeTab === 'responses' }\">\n            Codex\n          </router-link>\n          <span class=\"api-type-text separator\">/</span>\n          <router-link to=\"/channels/gemini\" class=\"api-type-text\" :class=\"{ active: channelStore.activeTab === 'gemini' }\">\n            Gemini\n          </router-link>\n          <span class=\"brand-text d-none d-sm-inline\">API Proxy</span>\n        </div>\n      </div>\n\n      <v-spacer/>\n\n      <!-- 版本信息 -->\n      <div\n        v-if=\"systemStore.versionInfo.currentVersion\"\n        class=\"version-badge\"\n        :class=\"{\n          'version-clickable': systemStore.versionInfo.status === 'update-available' || systemStore.versionInfo.status === 'latest',\n          'version-checking': systemStore.versionInfo.status === 'checking',\n          'version-latest': systemStore.versionInfo.status === 'latest',\n          'version-update': systemStore.versionInfo.status === 'update-available'\n        }\"\n        @click=\"handleVersionClick\"\n      >\n        <v-icon\n          v-if=\"systemStore.versionInfo.status === 'checking'\"\n          size=\"14\"\n          class=\"mr-1\"\n        >mdi-clock-outline</v-icon>\n        <v-icon\n          v-else-if=\"systemStore.versionInfo.status === 'latest'\"\n          size=\"14\"\n          class=\"mr-1\"\n          color=\"success\"\n        >mdi-check-circle</v-icon>\n        <v-icon\n          v-else-if=\"systemStore.versionInfo.status === 'update-available'\"\n          size=\"14\"\n          class=\"mr-1\"\n          color=\"warning\"\n        >mdi-alert</v-icon>\n        <span class=\"version-text\">{{ systemStore.versionInfo.currentVersion }}</span>\n        <template v-if=\"systemStore.versionInfo.status === 'update-available' && systemStore.versionInfo.latestVersion\">\n          <span class=\"version-arrow mx-1\">→</span>\n          <span class=\"version-latest-text\">{{ systemStore.versionInfo.latestVersion }}</span>\n        </template>\n      </div>\n\n      <!-- 暗色模式切换 -->\n      <v-btn icon variant=\"text\" size=\"small\" class=\"header-btn\" @click=\"toggleDarkMode\">\n        <v-icon size=\"20\">{{\n          theme.global.current.value.dark ? 'mdi-weather-night' : 'mdi-white-balance-sunny'\n        }}</v-icon>\n      </v-btn>\n\n      <!-- 注销按钮 -->\n      <v-btn\n        v-if=\"isAuthenticated\"\n        icon\n        variant=\"text\"\n        size=\"small\"\n        class=\"header-btn\"\n        title=\"注销\"\n        @click=\"handleLogout\"\n      >\n        <v-icon size=\"20\">mdi-logout</v-icon>\n      </v-btn>\n    </v-app-bar>\n\n    <!-- 主要内容 -->\n    <v-main>\n      <v-container fluid class=\"pa-4 pa-md-6\">\n        <!-- 全局统计顶部可折叠卡片（根据当前 Tab 显示对应统计） -->\n        <v-card v-if=\"isAuthenticated\" class=\"mb-4 global-stats-panel\">\n          <div\n            class=\"global-stats-header d-flex align-center justify-space-between px-4 py-2\"\n            style=\"cursor: pointer;\"\n            @click=\"preferencesStore.toggleGlobalStats()\"\n          >\n            <div class=\"d-flex align-center\">\n              <v-icon size=\"20\" class=\"mr-2\">mdi-chart-areaspline</v-icon>\n              <span class=\"text-subtitle-1 font-weight-bold\">\n                {{ channelStore.activeTab === 'messages' ? 'Claude Messages' : (channelStore.activeTab === 'responses' ? 'Codex Responses' : 'Gemini') }} 流量统计\n              </span>\n            </div>\n            <v-btn icon size=\"small\" variant=\"text\">\n              <v-icon>{{ preferencesStore.showGlobalStats ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>\n            </v-btn>\n          </div>\n          <v-expand-transition>\n            <div v-if=\"preferencesStore.showGlobalStats\">\n              <v-divider />\n              <GlobalStatsChart :api-type=\"channelStore.activeTab\" />\n            </div>\n          </v-expand-transition>\n        </v-card>\n\n        <!-- 统计卡片 - 玻璃拟态风格 -->\n        <v-row class=\"mb-6 stat-cards-row\">\n          <v-col cols=\"6\" sm=\"4\">\n            <div class=\"stat-card stat-card-info\">\n              <div class=\"stat-card-icon\">\n                <v-icon size=\"28\">mdi-server-network</v-icon>\n              </div>\n              <div class=\"stat-card-content\">\n                <div class=\"stat-card-value\">{{ channelStore.currentChannelsData.channels?.length || 0 }}</div>\n                <div class=\"stat-card-label\">总渠道数</div>\n                <div class=\"stat-card-desc\">已配置的API渠道</div>\n              </div>\n              <div class=\"stat-card-glow\"></div>\n            </div>\n          </v-col>\n\n          <v-col cols=\"6\" sm=\"4\">\n            <div class=\"stat-card stat-card-success\">\n              <div class=\"stat-card-icon\">\n                <v-icon size=\"28\">mdi-check-circle</v-icon>\n              </div>\n              <div class=\"stat-card-content\">\n                <div class=\"stat-card-value\">\n                  {{ channelStore.activeChannelCount }}<span class=\"stat-card-total\">/{{ channelStore.failoverChannelCount }}</span>\n                </div>\n                <div class=\"stat-card-label\">活跃渠道</div>\n                <div class=\"stat-card-desc\">参与故障转移调度</div>\n              </div>\n              <div class=\"stat-card-glow\"></div>\n            </div>\n          </v-col>\n\n          <v-col cols=\"6\" sm=\"4\">\n            <div class=\"stat-card\" :class=\"systemStore.systemStatus === 'running' ? 'stat-card-emerald' : 'stat-card-error'\">\n              <div class=\"stat-card-icon\" :class=\"{ 'pulse-animation': systemStore.systemStatus === 'running' }\">\n                <v-icon size=\"28\">{{ systemStore.systemStatus === 'running' ? 'mdi-heart-pulse' : 'mdi-alert-circle' }}</v-icon>\n              </div>\n              <div class=\"stat-card-content\">\n                <div class=\"stat-card-value\">{{ systemStore.systemStatusText }}</div>\n                <div class=\"stat-card-label\">系统状态</div>\n                <div class=\"stat-card-desc\">{{ systemStore.systemStatusDesc }}</div>\n              </div>\n              <div class=\"stat-card-glow\"></div>\n            </div>\n          </v-col>\n        </v-row>\n\n        <!-- 操作按钮区域 - 现代化设计 -->\n        <div class=\"action-bar mb-6\">\n          <div class=\"action-bar-left\">\n            <v-btn\n              color=\"primary\"\n              size=\"large\"\n              prepend-icon=\"mdi-plus\"\n              class=\"action-btn action-btn-primary\"\n              @click=\"openAddChannelModal\"\n            >\n              添加渠道\n            </v-btn>\n\n            <v-btn\n              color=\"info\"\n              size=\"large\"\n              prepend-icon=\"mdi-speedometer\"\n              variant=\"tonal\"\n              :loading=\"channelStore.isPingingAll\"\n              class=\"action-btn\"\n              @click=\"pingAllChannels\"\n            >\n              测试延迟\n            </v-btn>\n\n            <v-btn size=\"large\" prepend-icon=\"mdi-refresh\" variant=\"text\" class=\"action-btn\" @click=\"refreshChannels\">\n              刷新\n            </v-btn>\n          </div>\n\n          <div class=\"action-bar-right\">\n            <!-- Fuzzy 模式切换按钮 -->\n            <v-tooltip location=\"bottom\" content-class=\"fuzzy-tooltip\">\n              <template #activator=\"{ props }\">\n                <v-btn\n                  v-bind=\"props\"\n                  variant=\"tonal\"\n                  size=\"large\"\n                  :loading=\"systemStore.fuzzyModeLoading\"\n                  :disabled=\"systemStore.fuzzyModeLoadError\"\n                  :color=\"systemStore.fuzzyModeLoadError ? 'error' : (preferencesStore.fuzzyModeEnabled ? 'warning' : 'default')\"\n                  class=\"action-btn\"\n                  @click=\"toggleFuzzyMode\"\n                >\n                  <v-icon start size=\"20\">\n                    {{ systemStore.fuzzyModeLoadError ? 'mdi-alert-circle-outline' : (preferencesStore.fuzzyModeEnabled ? 'mdi-shield-refresh' : 'mdi-shield-off-outline') }}\n                  </v-icon>\n                  Fuzzy\n                </v-btn>\n              </template>\n              <span>{{ systemStore.fuzzyModeLoadError ? '加载失败，请刷新页面' : (preferencesStore.fuzzyModeEnabled ? 'Fuzzy 模式已启用：模糊处理错误，自动尝试所有渠道' : 'Fuzzy 模式已关闭：精确处理错误，透传上游响应') }}</span>\n            </v-tooltip>\n          </div>\n        </div>\n\n        <!-- 渠道编排（高密度列表模式） -->\n        <router-view\n          @edit=\"editChannel\"\n          @delete=\"deleteChannel\"\n          @ping=\"pingChannel\"\n          @refresh=\"refreshChannels\"\n          @error=\"showErrorToast\"\n          @success=\"showSuccessToast\"\n        />\n      </v-container>\n    </v-main>\n\n    <!-- 添加渠道模态框 -->\n    <AddChannelModal\n      v-model:show=\"dialogStore.showAddChannelModal\"\n      :channel=\"dialogStore.editingChannel\"\n      :channel-type=\"channelStore.activeTab\"\n      @save=\"saveChannel\"\n    />\n\n    <!-- 添加API密钥对话框 -->\n    <v-dialog v-model=\"dialogStore.showAddKeyModal\" max-width=\"500\">\n      <v-card rounded=\"lg\">\n        <v-card-title class=\"d-flex align-center\">\n          <v-icon class=\"mr-3\">mdi-key-plus</v-icon>\n          添加API密钥\n        </v-card-title>\n        <v-card-text>\n          <v-text-field\n            v-model=\"dialogStore.newApiKey\"\n            label=\"API密钥\"\n            type=\"password\"\n            variant=\"outlined\"\n            density=\"comfortable\"\n            placeholder=\"输入API密钥\"\n            @keyup.enter=\"addApiKey\"\n          />\n        </v-card-text>\n        <v-card-actions>\n          <v-spacer/>\n          <v-btn variant=\"text\" @click=\"dialogStore.closeAddKeyModal()\">取消</v-btn>\n          <v-btn :disabled=\"!dialogStore.newApiKey.trim()\" color=\"primary\" variant=\"elevated\" @click=\"addApiKey\">添加</v-btn>\n        </v-card-actions>\n      </v-card>\n    </v-dialog>\n\n    <!-- Toast通知 -->\n    <v-snackbar\n      v-for=\"toast in toasts\"\n      :key=\"toast.id\"\n      v-model=\"toast.show\"\n      :color=\"getToastColor(toast.type)\"\n      :timeout=\"3000\"\n      location=\"top right\"\n      variant=\"elevated\"\n    >\n      <div class=\"d-flex align-center\">\n        <v-icon class=\"mr-3\">{{ getToastIcon(toast.type) }}</v-icon>\n        {{ toast.message }}\n      </div>\n    </v-snackbar>\n  </v-app>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, onMounted, onUnmounted, computed, watch } from 'vue'\nimport { useTheme } from 'vuetify'\nimport { api, fetchHealth, ApiError, type Channel } from './services/api'\nimport { versionService } from './services/version'\nimport { useAuthStore } from './stores/auth'\nimport { useChannelStore } from './stores/channel'\nimport { usePreferencesStore } from './stores/preferences'\nimport { useDialogStore } from './stores/dialog'\nimport { useSystemStore } from './stores/system'\nimport AddChannelModal from './components/AddChannelModal.vue'\nimport GlobalStatsChart from './components/GlobalStatsChart.vue'\nimport { useAppTheme } from './composables/useTheme'\n\n// Vuetify主题\nconst theme = useTheme()\n\n// 应用主题系统\nconst { init: initTheme } = useAppTheme()\n\n// 认证 Store\nconst authStore = useAuthStore()\n\n// 渠道 Store\nconst channelStore = useChannelStore()\n\n// 偏好设置 Store\nconst preferencesStore = usePreferencesStore()\n\n// 对话框 Store\nconst dialogStore = useDialogStore()\n\n// 系统状态 Store\nconst systemStore = useSystemStore()\n\n// 对话框状态已迁移到 DialogStore\n\n// 主题和偏好设置已迁移到 PreferencesStore\n\n// 系统状态已迁移到 SystemStore\n\n// Toast通知系统\ninterface Toast {\n  id: number\n  message: string\n  type: 'success' | 'error' | 'warning' | 'info'\n  show?: boolean\n}\nconst toasts = ref<Toast[]>([])\nlet toastId = 0\n\n// Toast工具函数\nconst getToastColor = (type: string) => {\n  const colorMap: Record<string, string> = {\n    success: 'success',\n    error: 'error',\n    warning: 'warning',\n    info: 'info'\n  }\n  return colorMap[type] || 'info'\n}\n\nconst getToastIcon = (type: string) => {\n  const iconMap: Record<string, string> = {\n    success: 'mdi-check-circle',\n    error: 'mdi-alert-circle',\n    warning: 'mdi-alert',\n    info: 'mdi-information'\n  }\n  return iconMap[type] || 'mdi-information'\n}\n\n// 工具函数\nconst showToast = (message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info') => {\n  const toast: Toast = { id: ++toastId, message, type, show: true }\n  toasts.value.push(toast)\n  setTimeout(() => {\n    const index = toasts.value.findIndex(t => t.id === toast.id)\n    if (index > -1) toasts.value.splice(index, 1)\n  }, 3000)\n}\n\nconst _handleError = (error: unknown, defaultMessage: string) => {\n  const message = error instanceof Error ? error.message : defaultMessage\n  showToast(message, 'error')\n  console.error(error)\n}\n\n// 直接显示错误消息（供子组件事件使用）\nconst showErrorToast = (message: string) => {\n  showToast(message, 'error')\n}\n\n// 直接显示成功消息（供子组件事件使用）\nconst showSuccessToast = (message: string) => {\n  showToast(message, 'info')\n}\n\n// 主要功能函数 - 使用 ChannelStore\nconst refreshChannels = async () => {\n  try {\n    await channelStore.refreshChannels()\n  } catch (error) {\n    handleAuthError(error)\n  }\n}\n\nconst saveChannel = async (channel: Omit<Channel, 'index' | 'latency' | 'status'>, options?: { isQuickAdd?: boolean }) => {\n  try {\n    const result = await channelStore.saveChannel(channel, dialogStore.editingChannel?.index ?? null, options)\n    showToast(result.message, 'success')\n    if (result.quickAddMessage) {\n      showToast(result.quickAddMessage, 'info')\n    }\n    dialogStore.closeAddChannelModal()\n    await refreshChannels()\n  } catch (error) {\n    handleAuthError(error)\n  }\n}\n\nconst editChannel = (channel: Channel) => {\n  dialogStore.openEditChannelModal(channel)\n}\n\nconst deleteChannel = async (channelId: number) => {\n  if (!confirm('确定要删除这个渠道吗？')) return\n\n  try {\n    const result = await channelStore.deleteChannel(channelId)\n    showToast(result.message, 'success')\n  } catch (error) {\n    handleAuthError(error)\n  }\n}\n\nconst openAddChannelModal = () => {\n  dialogStore.openAddChannelModal()\n}\n\nconst _openAddKeyModal = (channelId: number) => {\n  dialogStore.openAddKeyModal(channelId)\n}\n\nconst addApiKey = async () => {\n  if (!dialogStore.newApiKey.trim()) return\n\n  try {\n    if (channelStore.activeTab === 'gemini') {\n      await api.addGeminiApiKey(dialogStore.selectedChannelForKey, dialogStore.newApiKey.trim())\n    } else if (channelStore.activeTab === 'responses') {\n      await api.addResponsesApiKey(dialogStore.selectedChannelForKey, dialogStore.newApiKey.trim())\n    } else {\n      await api.addApiKey(dialogStore.selectedChannelForKey, dialogStore.newApiKey.trim())\n    }\n    showToast('API密钥添加成功', 'success')\n    dialogStore.closeAddKeyModal()\n    await refreshChannels()\n  } catch (error) {\n    showToast(`添加API密钥失败: ${error instanceof Error ? error.message : '未知错误'}`, 'error')\n  }\n}\n\nconst _removeApiKey = async (channelId: number, apiKey: string) => {\n  if (!confirm('确定要删除这个API密钥吗？')) return\n\n  try {\n    if (channelStore.activeTab === 'gemini') {\n      await api.removeGeminiApiKey(channelId, apiKey)\n    } else if (channelStore.activeTab === 'responses') {\n      await api.removeResponsesApiKey(channelId, apiKey)\n    } else {\n      await api.removeApiKey(channelId, apiKey)\n    }\n    showToast('API密钥删除成功', 'success')\n    await refreshChannels()\n  } catch (error) {\n    showToast(`删除API密钥失败: ${error instanceof Error ? error.message : '未知错误'}`, 'error')\n  }\n}\n\nconst pingChannel = async (channelId: number) => {\n  try {\n    await channelStore.pingChannel(channelId)\n    // 不再使用 Toast，延迟结果直接显示在渠道列表中\n  } catch (error) {\n    showToast(`延迟测试失败: ${error instanceof Error ? error.message : '未知错误'}`, 'error')\n  }\n}\n\nconst pingAllChannels = async () => {\n  try {\n    await channelStore.pingAllChannels()\n    // 不再使用 Toast，延迟结果直接显示在渠道列表中\n  } catch (error) {\n    showToast(`批量延迟测试失败: ${error instanceof Error ? error.message : '未知错误'}`, 'error')\n  }\n}\n\nconst _updateLoadBalance = async (strategy: string) => {\n  try {\n    const result = await channelStore.updateLoadBalance(strategy)\n    showToast(result.message, 'success')\n  } catch (error) {\n    showToast(`更新负载均衡策略失败: ${error instanceof Error ? error.message : '未知错误'}`, 'error')\n  }\n}\n\n// Fuzzy 模式管理\nconst loadFuzzyModeStatus = async () => {\n  systemStore.setFuzzyModeLoadError(false)\n  try {\n    const { fuzzyModeEnabled: enabled } = await api.getFuzzyMode()\n    preferencesStore.setFuzzyMode(enabled)\n  } catch (e) {\n    console.error('Failed to load fuzzy mode status:', e)\n    systemStore.setFuzzyModeLoadError(true)\n    // 加载失败时不使用默认值，保持 UI 显示未知状态\n    showToast('加载 Fuzzy 模式状态失败，请刷新页面重试', 'warning')\n  }\n}\n\nconst toggleFuzzyMode = async () => {\n  if (systemStore.fuzzyModeLoadError) {\n    showToast('Fuzzy 模式状态未知，请先刷新页面', 'warning')\n    return\n  }\n  systemStore.setFuzzyModeLoading(true)\n  try {\n    await api.setFuzzyMode(!preferencesStore.fuzzyModeEnabled)\n    preferencesStore.toggleFuzzyMode()\n    showToast(`Fuzzy 模式已${preferencesStore.fuzzyModeEnabled ? '启用' : '关闭'}`, 'success')\n  } catch (e) {\n    showToast(`切换 Fuzzy 模式失败: ${e instanceof Error ? e.message : '未知错误'}`, 'error')\n  } finally {\n    systemStore.setFuzzyModeLoading(false)\n  }\n}\n\n// 主题管理\nconst toggleDarkMode = () => {\n  const newMode = preferencesStore.darkModePreference === 'dark' ? 'light' : 'dark'\n  setDarkMode(newMode)\n}\n\nconst setDarkMode = (themeName: 'light' | 'dark' | 'auto') => {\n  preferencesStore.setDarkMode(themeName)\n  const apply = (isDark: boolean) => {\n    // 使用 Vuetify 3.9+ 推荐的 theme.change() API\n    theme.change(isDark ? 'dark' : 'light')\n  }\n\n  if (themeName === 'auto') {\n    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches\n    apply(prefersDark)\n  } else {\n    apply(themeName === 'dark')\n  }\n  // PreferencesStore 已通过 pinia-plugin-persistedstate 自动持久化，无需手动写入 localStorage\n}\n\n// 认证状态管理（使用 AuthStore）\nconst isAuthenticated = computed(() => authStore.isAuthenticated)\n// 认证相关状态已迁移到 AuthStore\n\n// 认证尝试限制\nconst MAX_AUTH_ATTEMPTS = 5\n\n// 控制认证对话框显示\nconst showAuthDialog = computed({\n  get: () => {\n    // 只有在初始化完成后，且未认证，且不在自动认证中时，才显示对话框\n    return authStore.isInitialized && !isAuthenticated.value && !authStore.isAutoAuthenticating\n  },\n  set: () => {} // 防止外部修改，认证状态只能通过内部逻辑控制\n})\n\n// 自动验证保存的密钥\nconst autoAuthenticate = async () => {\n  // 检查 AuthStore 中是否有保存的密钥\n  if (!authStore.apiKey) {\n    // 没有保存的密钥，显示登录对话框\n    authStore.setAuthError('请输入访问密钥以继续')\n    authStore.setAutoAuthenticating(false)\n    authStore.setInitialized(true)\n    return false\n  }\n\n  // 有保存的密钥，尝试自动认证\n  try {\n    // 尝试调用API验证密钥是否有效\n    await api.getChannels()\n\n    // 密钥有效，认证成功\n    authStore.setAuthError('')\n    return true\n  } catch (error) {\n    // 仅在明确 401 时视为密钥无效；其他错误（网络/5xx）不应清除密钥\n    if (error instanceof ApiError && error.status === 401) {\n      console.warn('自动认证失败: 认证失败(401)')\n      authStore.clearAuth()\n      authStore.setAuthError('保存的访问密钥已失效，请重新输入')\n      return false\n    }\n\n    console.warn('自动认证暂时失败:', error)\n    showToast(`无法验证访问密钥: ${error instanceof Error ? error.message : '未知错误'}`, 'warning')\n    // 非 401：保留密钥，继续尝试连接后端（后续刷新会更新系统状态）\n    return true\n  } finally {\n    authStore.setAutoAuthenticating(false)\n    authStore.setInitialized(true)\n  }\n}\n\n// 手动设置密钥（用于重新认证）\nconst setAuthKey = (key: string) => {\n  authStore.setApiKey(key)\n  authStore.setAuthError('')\n}\n\n// 处理认证提交\nconst handleAuthSubmit = async () => {\n  if (!authStore.authKeyInput.trim()) {\n    authStore.setAuthError('请输入访问密钥')\n    return\n  }\n\n  // 检查是否被锁定\n  if (authStore.isAuthLocked) {\n    const remainingSeconds = Math.ceil((authStore.authLockoutTime! - Date.now()) / 1000)\n    authStore.setAuthError(`认证尝试次数过多，请在 ${remainingSeconds} 秒后重试`)\n    return\n  }\n\n  authStore.setAuthLoading(true)\n  authStore.setAuthError('')\n\n  try {\n    // 设置密钥\n    setAuthKey(authStore.authKeyInput.trim())\n\n    // 测试API调用以验证密钥\n    await api.getChannels()\n\n    // 认证成功，重置计数器\n    authStore.resetAuthAttempts()\n    authStore.setAuthLockout(null)\n\n    // 如果成功，加载数据\n    await refreshChannels()\n\n    authStore.setAuthKeyInput('')\n\n    // 记录认证成功(前端日志)\n    if (import.meta.env.DEV) {\n      console.info('✅ 认证成功 - 时间:', new Date().toISOString())\n    }\n  } catch (error) {\n    // 仅在明确 401 时计入认证失败；网络/5xx 不计入失败次数，也不清除已保存密钥\n    if (error instanceof ApiError && error.status === 401) {\n      authStore.incrementAuthAttempts()\n\n      // 记录认证失败(前端日志)\n      console.warn('🔒 认证失败 - 尝试次数:', authStore.authAttempts, '时间:', new Date().toISOString())\n\n      // 如果尝试次数过多，锁定5分钟\n      if (authStore.authAttempts >= MAX_AUTH_ATTEMPTS) {\n        authStore.setAuthLockout(new Date(Date.now() + 5 * 60 * 1000))\n        authStore.setAuthError('认证尝试次数过多，请在5分钟后重试')\n      } else {\n        authStore.setAuthError(`访问密钥验证失败 (剩余尝试次数: ${MAX_AUTH_ATTEMPTS - authStore.authAttempts})`)\n      }\n\n      authStore.clearAuth()\n      return\n    }\n\n    showToast(`无法验证访问密钥: ${error instanceof Error ? error.message : '未知错误'}`, 'error')\n  } finally {\n    authStore.setAuthLoading(false)\n  }\n}\n\n// 处理注销\nconst handleLogout = () => {\n  authStore.clearAuth()\n  channelStore.clearChannels()\n  authStore.setAuthError('请输入访问密钥以继续')\n  showToast('已安全注销', 'info')\n}\n\n// 处理认证失败\nconst handleAuthError = (error: any) => {\n  if (error.message && error.message.includes('认证失败')) {\n    authStore.setAuthError('访问密钥无效或已过期，请重新输入')\n  } else {\n    showToast(`操作失败: ${error instanceof Error ? error.message : '未知错误'}`, 'error')\n  }\n}\n\n// 版本检查\nconst checkVersion = async () => {\n  if (systemStore.isCheckingVersion) return\n\n  systemStore.setCheckingVersion(true)\n  try {\n    // 先获取当前版本\n    const health = await fetchHealth()\n    const currentVersion = health.version?.version || ''\n\n    if (currentVersion) {\n      versionService.setCurrentVersion(currentVersion)\n      systemStore.setCurrentVersion(currentVersion)\n\n      // 检查 GitHub 最新版本\n      const result = await versionService.checkForUpdates()\n      systemStore.setVersionInfo(result)\n    } else {\n      systemStore.setVersionInfo({\n        ...systemStore.versionInfo,\n        status: 'error',\n      })\n    }\n  } catch (error) {\n    console.warn('Version check failed:', error)\n    systemStore.setVersionInfo({\n      ...systemStore.versionInfo,\n      status: 'error',\n    })\n  } finally {\n    systemStore.setCheckingVersion(false)\n  }\n}\n\n// 版本点击处理\nconst handleVersionClick = () => {\n  if (\n    (systemStore.versionInfo.status === 'update-available' || systemStore.versionInfo.status === 'latest') &&\n    systemStore.versionInfo.releaseUrl\n  ) {\n    window.open(systemStore.versionInfo.releaseUrl, '_blank', 'noopener,noreferrer')\n  }\n}\n\n// 初始化\nonMounted(async () => {\n  // 初始化复古像素主题\n  document.documentElement.dataset.theme = 'retro'\n  initTheme()\n\n  // 加载保存的暗色模式偏好（从 PreferencesStore 读取，已自动从 localStorage 恢复）\n  setDarkMode(preferencesStore.darkModePreference)\n\n  // 监听系统主题变化\n  const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')\n  const handlePref = () => {\n    if (preferencesStore.darkModePreference === 'auto') setDarkMode('auto')\n  }\n  mediaQuery.addEventListener('change', handlePref)\n\n  // 版本检查（独立于认证，静默执行）\n  checkVersion()\n\n  // 检查 AuthStore 中是否有保存的密钥\n  if (authStore.apiKey) {\n    // 有保存的密钥，开始自动认证\n    authStore.setAutoAuthenticating(true)\n    authStore.setInitialized(false)\n  } else {\n    // 没有保存的密钥，直接显示登录对话框\n    authStore.setAutoAuthenticating(false)\n    authStore.setInitialized(true)\n  }\n\n  // 尝试自动认证\n  const authenticated = await autoAuthenticate()\n\n  if (authenticated) {\n    // 加载渠道数据\n    await refreshChannels()\n    // 加载 Fuzzy 模式状态\n    await loadFuzzyModeStatus()\n    // 启动自动刷新\n    startAutoRefresh()\n    // 初始化完成后根据最新刷新结果设置系统状态\n    systemStore.setSystemStatus(channelStore.lastRefreshSuccess ? 'running' : 'error')\n  }\n})\n\n// 启动自动刷新定时器\nconst startAutoRefresh = () => {\n  channelStore.startAutoRefresh()\n}\n\n// 停止自动刷新定时器\nconst stopAutoRefresh = () => {\n  channelStore.stopAutoRefresh()\n}\n\n// 监听 Tab 切换，刷新对应数据\nwatch(() => channelStore.activeTab, async () => {\n  if (isAuthenticated.value) {\n    try {\n      await channelStore.refreshChannels()\n    } catch (error) {\n      console.error('切换 Tab 刷新失败:', error)\n    }\n  }\n})\n\n// 监听认证状态变化\nwatch(isAuthenticated, newValue => {\n  if (newValue) {\n    startAutoRefresh()\n  } else {\n    stopAutoRefresh()\n  }\n})\n\n// 监听自动刷新状态，更新 systemStatus\nwatch(() => channelStore.lastRefreshSuccess, (success) => {\n  if (isAuthenticated.value) {\n    systemStore.setSystemStatus(success ? 'running' : 'error')\n  }\n})\n\n// 在组件卸载时清除定时器\nonUnmounted(() => {\n  channelStore.stopAutoRefresh()\n})\n</script>\n\n<style scoped>\n/* =====================================================\n   🎮 复古像素 (Retro Pixel) 主题样式系统\n   Neo-Brutalism: 直角、粗黑边框、硬阴影、等宽字体\n   ===================================================== */\n\n/* ----- 应用栏 - 复古像素风格 ----- */\n.app-header {\n  background: rgb(var(--v-theme-surface)) !important;\n  border-bottom: 2px solid rgb(var(--v-theme-on-surface));\n  transition: none;\n  padding: 0 16px !important;\n}\n\n.v-theme--dark .app-header {\n  background: rgb(var(--v-theme-surface)) !important;\n  border-bottom: 2px solid rgba(255, 255, 255, 0.8);\n}\n\n/* 修复 Header 布局 */\n.app-header :deep(.v-toolbar__prepend) {\n  margin-inline-end: 4px !important;\n}\n\n.app-header .v-toolbar-title {\n  overflow: hidden !important;\n  min-width: 0 !important;\n  flex: 1 !important;\n}\n\n.app-header :deep(.v-toolbar__content) {\n  overflow: visible !important;\n}\n\n.app-header :deep(.v-toolbar__content > .v-toolbar-title) {\n  min-width: 0 !important;\n  margin-inline-start: 0 !important;\n  margin-inline-end: auto !important;\n}\n\n.app-header :deep(.v-toolbar-title__placeholder) {\n  width: 100%;\n  overflow: hidden;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n}\n\n.app-logo {\n  width: 42px;\n  height: 42px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: rgb(var(--v-theme-primary));\n  border: 2px solid rgb(var(--v-theme-on-surface));\n  box-shadow: 3px 3px 0 0 rgb(var(--v-theme-on-surface));\n  margin-right: 8px;\n}\n\n.v-theme--dark .app-logo {\n  border-color: rgba(255, 255, 255, 0.8);\n  box-shadow: 3px 3px 0 0 rgba(255, 255, 255, 0.8);\n}\n\n/* 自定义标题容器 */\n.header-title {\n  display: flex;\n  align-items: center;\n  flex-shrink: 0;\n}\n\n.api-type-text {\n  cursor: pointer;\n  opacity: 0.5;\n  transition: all 0.1s ease;\n  padding: 4px 8px;\n  position: relative;\n  text-decoration: none;\n  color: inherit;\n}\n\na.api-type-text {\n  display: inline-block;\n}\n\n.api-type-text:not(.separator):hover {\n  opacity: 0.8;\n  background: rgba(var(--v-theme-primary), 0.15);\n}\n\n.api-type-text.active {\n  opacity: 1;\n  font-weight: 700;\n  color: rgb(var(--v-theme-primary));\n  background: rgba(var(--v-theme-primary), 0.1);\n  border: 1px solid rgb(var(--v-theme-on-surface));\n}\n\n.v-theme--dark .api-type-text.active {\n  border-color: rgba(255, 255, 255, 0.6);\n}\n\n.separator {\n  opacity: 0.25;\n  margin: 0 2px;\n  cursor: default;\n  padding: 0;\n}\n\n.brand-text {\n  margin-left: 10px;\n  color: rgb(var(--v-theme-primary));\n  font-weight: 700;\n}\n\n.header-btn {\n  border: 2px solid rgb(var(--v-theme-on-surface)) !important;\n  box-shadow: 2px 2px 0 0 rgb(var(--v-theme-on-surface)) !important;\n  margin-left: 4px;\n  transition: all 0.1s ease !important;\n}\n\n.v-theme--dark .header-btn {\n  border-color: rgba(255, 255, 255, 0.6) !important;\n  box-shadow: 2px 2px 0 0 rgba(255, 255, 255, 0.6) !important;\n}\n\n.header-btn:hover {\n  background: rgba(var(--v-theme-primary), 0.1);\n  transform: translate(-1px, -1px);\n  box-shadow: 3px 3px 0 0 rgb(var(--v-theme-on-surface)) !important;\n}\n\n.header-btn:active {\n  transform: translate(2px, 2px) !important;\n  box-shadow: none !important;\n}\n\n/* ----- 版本信息徽章 ----- */\n.version-badge {\n  display: flex;\n  align-items: center;\n  padding: 4px 10px;\n  margin-right: 8px;\n  font-family: 'JetBrains Mono', 'Fira Code', monospace;\n  font-size: 12px;\n  border: 2px solid rgb(var(--v-theme-on-surface));\n  background: rgb(var(--v-theme-surface));\n  transition: all 0.15s ease;\n}\n\n.version-badge.version-clickable {\n  cursor: pointer;\n}\n\n.version-badge.version-clickable:hover {\n  transform: translateY(-1px);\n  box-shadow: 3px 3px 0 0 rgb(var(--v-theme-on-surface));\n}\n\n.version-badge.version-checking {\n  opacity: 0.7;\n}\n\n.version-badge.version-latest {\n  border-color: rgb(var(--v-theme-success));\n}\n\n.version-badge.version-update {\n  border-color: rgb(var(--v-theme-warning));\n  background: rgba(var(--v-theme-warning), 0.1);\n}\n\n.version-text {\n  color: rgb(var(--v-theme-on-surface));\n}\n\n.version-arrow {\n  color: rgb(var(--v-theme-warning));\n  font-weight: bold;\n}\n\n.version-latest-text {\n  color: rgb(var(--v-theme-warning));\n  font-weight: bold;\n}\n\n.v-theme--dark .version-badge {\n  border-color: rgba(255, 255, 255, 0.6);\n}\n\n.v-theme--dark .version-badge.version-latest {\n  border-color: rgb(var(--v-theme-success));\n}\n\n.v-theme--dark .version-badge.version-update {\n  border-color: rgb(var(--v-theme-warning));\n}\n\n/* ----- 统计卡片 - 复古像素风格 ----- */\n.stat-cards-row {\n  margin-top: -8px;\n}\n\n.stat-card {\n  position: relative;\n  display: flex;\n  align-items: center;\n  gap: 16px;\n  padding: 20px;\n  margin: 2px;\n  background: rgb(var(--v-theme-surface));\n  border: 2px solid rgb(var(--v-theme-on-surface));\n  box-shadow: 6px 6px 0 0 rgb(var(--v-theme-on-surface));\n  transition: all 0.1s ease;\n  overflow: hidden;\n  min-height: 100px;\n}\n.stat-card:hover {\n  transform: translate(-2px, -2px);\n  box-shadow: 8px 8px 0 0 rgb(var(--v-theme-on-surface));\n  border: 2px solid rgb(var(--v-theme-on-surface));\n}\n\n.stat-card:active {\n  transform: translate(2px, 2px);\n  box-shadow: 2px 2px 0 0 rgb(var(--v-theme-on-surface));\n}\n\n.v-theme--dark .stat-card {\n  background: rgb(var(--v-theme-surface));\n  border-color: rgba(255, 255, 255, 0.8);\n  box-shadow: 6px 6px 0 0 rgba(255, 255, 255, 0.8);\n}\n.v-theme--dark .stat-card:hover {\n  box-shadow: 8px 8px 0 0 rgba(255, 255, 255, 0.8);\n  border-color: rgba(255, 255, 255, 0.8);\n}\n\n.v-theme--dark .stat-card:active {\n  box-shadow: 2px 2px 0 0 rgba(255, 255, 255, 0.8);\n}\n\n.stat-card-icon {\n  width: 56px;\n  height: 56px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  flex-shrink: 0;\n  border: 2px solid rgb(var(--v-theme-on-surface));\n  background: rgba(var(--v-theme-primary), 0.15);\n  transition: transform 0.1s ease;\n}\n\n.v-theme--dark .stat-card-icon {\n  border-color: rgba(255, 255, 255, 0.6);\n}\n\n.stat-card:hover .stat-card-icon {\n  transform: scale(1.05);\n}\n\n.stat-card-content {\n  flex: 1;\n  min-width: 0;\n}\n\n.stat-card-value {\n  font-size: 1.75rem;\n  font-weight: 700;\n  line-height: 1.2;\n  letter-spacing: -0.5px;\n}\n\n.stat-card-total {\n  font-size: 1rem;\n  font-weight: 500;\n  opacity: 0.6;\n}\n\n.stat-card-label {\n  font-size: 0.875rem;\n  font-weight: 600;\n  margin-top: 2px;\n  opacity: 0.85;\n  text-transform: uppercase;\n}\n\n.stat-card-desc {\n  font-size: 0.75rem;\n  opacity: 0.6;\n  margin-top: 2px;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n/* 隐藏光晕效果 */\n.stat-card-glow {\n  display: none;\n}\n\n/* 统计卡片颜色变体 */\n.stat-card-info .stat-card-icon {\n  background: #3b82f6;\n  color: white;\n}\n.stat-card-info .stat-card-value {\n  color: #3b82f6;\n}\n.v-theme--dark .stat-card-info .stat-card-value {\n  color: #60a5fa;\n}\n\n.stat-card-success .stat-card-icon {\n  background: #10b981;\n  color: white;\n}\n.stat-card-success .stat-card-value {\n  color: #10b981;\n}\n.v-theme--dark .stat-card-success .stat-card-value {\n  color: #34d399;\n}\n\n.stat-card-primary .stat-card-icon {\n  background: #6366f1;\n  color: white;\n}\n.stat-card-primary .stat-card-value {\n  color: #6366f1;\n}\n.v-theme--dark .stat-card-primary .stat-card-value {\n  color: #818cf8;\n}\n\n.stat-card-emerald .stat-card-icon {\n  background: #059669;\n  color: white;\n}\n.stat-card-emerald .stat-card-value {\n  color: #059669;\n}\n.v-theme--dark .stat-card-emerald .stat-card-value {\n  color: #34d399;\n}\n\n.stat-card-error .stat-card-icon {\n  background: #dc2626;\n  color: white;\n}\n.stat-card-error .stat-card-value {\n  color: #dc2626;\n}\n.v-theme--dark .stat-card-error .stat-card-value {\n  color: #f87171;\n}\n\n/* =========================================\n   复古像素主题 - 全局样式覆盖\n   ========================================= */\n\n/* 全局背景 */\n.v-application {\n  background-color: #fffbeb !important;\n  font-family: 'Courier New', Consolas, monospace !important;\n}\n\n.v-theme--dark .v-application,\n.v-theme--dark.v-application {\n  background-color: rgb(var(--v-theme-background)) !important;\n}\n\n.v-main {\n  background-color: #fffbeb !important;\n}\n\n.v-theme--dark .v-main {\n  background-color: rgb(var(--v-theme-background)) !important;\n}\n\n/* 统计卡片图标配色 */\n.stat-card-icon .v-icon {\n  color: white !important;\n}\n\n.stat-card-emerald .stat-card-icon .v-icon {\n  color: white !important;\n}\n\n/* 主按钮 - 复古像素风格 */\n.action-btn-primary {\n  background: rgb(var(--v-theme-primary)) !important;\n  border: 2px solid rgb(var(--v-theme-on-surface)) !important;\n  box-shadow: 4px 4px 0 0 rgb(var(--v-theme-on-surface)) !important;\n  color: white !important;\n}\n\n.action-btn-primary:hover {\n  transform: translate(-1px, -1px);\n  box-shadow: 5px 5px 0 0 rgb(var(--v-theme-on-surface)) !important;\n}\n\n.action-btn-primary:active {\n  transform: translate(2px, 2px) !important;\n  box-shadow: none !important;\n}\n\n.v-theme--dark .action-btn-primary {\n  border-color: rgba(255, 255, 255, 0.8) !important;\n  box-shadow: 4px 4px 0 0 rgba(255, 255, 255, 0.8) !important;\n}\n\n/* 渠道编排容器 */\n.channel-orchestration {\n  background: transparent !important;\n  box-shadow: none !important;\n  border: none !important;\n}\n\n/* 渠道列表卡片样式 */\n.channel-list .channel-row {\n  background: rgb(var(--v-theme-surface)) !important;\n  margin-bottom: 0;\n  padding: 14px 12px 14px 28px !important;\n  border: 2px solid rgb(var(--v-theme-on-surface)) !important;\n  box-shadow: 4px 4px 0 0 rgb(var(--v-theme-on-surface)) !important;\n  min-height: 48px !important;\n  position: relative;\n}\n\n.v-theme--dark .channel-list .channel-row {\n  border-color: rgba(255, 255, 255, 0.7) !important;\n  box-shadow: 4px 4px 0 0 rgba(255, 255, 255, 0.7) !important;\n}\n\n.channel-list .channel-row:active {\n  transform: translate(2px, 2px);\n  box-shadow: none !important;\n  transition: transform 0.1s;\n}\n\n/* 序号角标 */\n.channel-row .priority-number {\n  position: absolute !important;\n  top: -1px !important;\n  left: -1px !important;\n  background: rgb(var(--v-theme-surface)) !important;\n  color: rgb(var(--v-theme-on-surface)) !important;\n  font-size: 10px !important;\n  font-weight: 700 !important;\n  padding: 2px 8px !important;\n  border: 1px solid rgb(var(--v-theme-on-surface)) !important;\n  border-top: none !important;\n  border-left: none !important;\n  width: auto !important;\n  height: auto !important;\n  margin: 0 !important;\n  box-shadow: none !important;\n  text-transform: uppercase;\n}\n\n.v-theme--dark .channel-row .priority-number {\n  border-color: rgba(255, 255, 255, 0.5) !important;\n}\n\n/* 拖拽手柄 */\n.drag-handle {\n  opacity: 0.3;\n  padding: 8px;\n  margin-left: -8px;\n}\n\n/* 渠道名称 */\n.channel-name {\n  font-size: 14px !important;\n  font-weight: 700 !important;\n  color: rgb(var(--v-theme-on-surface));\n}\n\n.channel-name .text-caption.text-medium-emphasis {\n  background: rgb(var(--v-theme-surface-variant));\n  padding: 2px 6px;\n  font-size: 10px !important;\n  font-weight: 600;\n  color: rgb(var(--v-theme-on-surface)) !important;\n  border: 1px solid rgb(var(--v-theme-on-surface));\n  text-transform: uppercase;\n}\n\n.v-theme--dark .channel-name .text-caption.text-medium-emphasis {\n  border-color: rgba(255, 255, 255, 0.5);\n}\n\n/* 隐藏描述文字 */\n.channel-name .text-disabled {\n  display: none !important;\n}\n\n/* 隐藏指标和密钥数 */\n.channel-metrics,\n.channel-keys {\n  display: none !important;\n}\n\n/* --- 备用资源池 --- */\n.inactive-pool {\n  background: rgb(var(--v-theme-surface)) !important;\n  border: 2px dashed rgb(var(--v-theme-on-surface)) !important;\n  padding: 8px !important;\n  margin-top: 12px;\n}\n\n.v-theme--dark .inactive-pool {\n  border-color: rgba(255, 255, 255, 0.5) !important;\n}\n\n.inactive-channel-row {\n  background: rgb(var(--v-theme-surface)) !important;\n  margin: 6px !important;\n  padding: 12px !important;\n  border: 2px solid rgb(var(--v-theme-on-surface)) !important;\n  box-shadow: 3px 3px 0 0 rgb(var(--v-theme-on-surface)) !important;\n}\n\n.v-theme--dark .inactive-channel-row {\n  border-color: rgba(255, 255, 255, 0.6) !important;\n  box-shadow: 3px 3px 0 0 rgba(255, 255, 255, 0.6) !important;\n}\n\n.inactive-channel-row .channel-info-main {\n  color: rgb(var(--v-theme-on-surface)) !important;\n  font-weight: 600;\n}\n\n/* ----- 操作按钮区域 ----- */\n.action-bar {\n  display: flex;\n  flex-wrap: wrap;\n  align-items: center;\n  justify-content: space-between;\n  gap: 12px;\n  padding: 16px 20px;\n  background: rgb(var(--v-theme-surface));\n  border: 2px solid rgb(var(--v-theme-on-surface));\n  box-shadow: 6px 6px 0 0 rgb(var(--v-theme-on-surface));\n}\n\n.v-theme--dark .action-bar {\n  background: rgb(var(--v-theme-surface));\n  border-color: rgba(255, 255, 255, 0.8);\n  box-shadow: 6px 6px 0 0 rgba(255, 255, 255, 0.8);\n}\n\n.action-bar-left {\n  display: flex;\n  flex-wrap: wrap;\n  align-items: center;\n  gap: 12px;\n}\n\n.action-bar-right {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n}\n\n.action-btn {\n  font-weight: 600;\n  letter-spacing: 0.3px;\n  text-transform: uppercase;\n  transition: all 0.1s ease;\n  border: 2px solid rgb(var(--v-theme-on-surface)) !important;\n  box-shadow: 4px 4px 0 0 rgb(var(--v-theme-on-surface)) !important;\n}\n\n.v-theme--dark .action-btn {\n  border-color: rgba(255, 255, 255, 0.7) !important;\n  box-shadow: 4px 4px 0 0 rgba(255, 255, 255, 0.7) !important;\n}\n\n.action-btn:hover {\n  transform: translate(-1px, -1px);\n  box-shadow: 5px 5px 0 0 rgb(var(--v-theme-on-surface)) !important;\n}\n\n.action-btn:active {\n  transform: translate(2px, 2px) !important;\n  box-shadow: none !important;\n}\n\n.load-balance-btn {\n  text-transform: uppercase;\n}\n\n.load-balance-menu {\n  min-width: 300px;\n  padding: 8px;\n  border: 2px solid rgb(var(--v-theme-on-surface)) !important;\n  box-shadow: 4px 4px 0 0 rgb(var(--v-theme-on-surface)) !important;\n}\n\n.v-theme--dark .load-balance-menu {\n  border-color: rgba(255, 255, 255, 0.7) !important;\n  box-shadow: 4px 4px 0 0 rgba(255, 255, 255, 0.7) !important;\n}\n\n.load-balance-menu .v-list-item {\n  margin-bottom: 4px;\n  padding: 12px 16px;\n}\n\n.load-balance-menu .v-list-item:last-child {\n  margin-bottom: 0;\n}\n\n/* =========================================\n   手机端专属样式 (≤600px)\n   ========================================= */\n@media (max-width: 600px) {\n  /* --- 主容器内边距缩小 --- */\n  .v-main .v-container {\n    padding-left: 8px !important;\n    padding-right: 8px !important;\n  }\n\n  /* --- 顶部导航栏 --- */\n  .app-header {\n    padding: 0 12px !important;\n    background: rgb(var(--v-theme-surface)) !important;\n    border-bottom: 2px solid rgb(var(--v-theme-on-surface)) !important;\n    box-shadow: none !important;\n  }\n\n  .v-theme--dark .app-header {\n    border-bottom-color: rgba(255, 255, 255, 0.7) !important;\n  }\n\n  .app-logo {\n    width: 32px;\n    height: 32px;\n    margin-right: 8px;\n    box-shadow: 2px 2px 0 0 rgb(var(--v-theme-on-surface));\n  }\n\n  .v-theme--dark .app-logo {\n    box-shadow: 2px 2px 0 0 rgba(255, 255, 255, 0.7);\n  }\n\n  .api-type-text {\n    padding: 2px 6px;\n  }\n\n  .api-type-text.active {\n    color: rgb(var(--v-theme-primary)) !important;\n    font-weight: 800 !important;\n  }\n\n  .brand-text {\n    display: none;\n  }\n\n  /* --- 统计卡片优化 --- */\n  .stat-card {\n    padding: 14px 12px;\n    gap: 10px;\n    min-height: auto;\n    background: rgb(var(--v-theme-surface)) !important;\n    box-shadow: 4px 4px 0 0 rgb(var(--v-theme-on-surface)) !important;\n    border: 2px solid rgb(var(--v-theme-on-surface)) !important;\n  }\n\n  .v-theme--dark .stat-card {\n    box-shadow: 4px 4px 0 0 rgba(255, 255, 255, 0.7) !important;\n    border-color: rgba(255, 255, 255, 0.7) !important;\n  }\n\n  .stat-card-icon {\n    width: 36px;\n    height: 36px;\n  }\n\n  .stat-card-icon .v-icon {\n    font-size: 18px !important;\n  }\n\n  .stat-card-value {\n    font-size: 1.35rem;\n    font-weight: 800 !important;\n    line-height: 1.2;\n    color: rgb(var(--v-theme-on-surface));\n    letter-spacing: -0.5px;\n  }\n\n  .stat-card-label {\n    font-size: 0.7rem;\n    color: rgba(var(--v-theme-on-surface), 0.6);\n    font-weight: 500;\n    text-transform: uppercase;\n  }\n\n  .stat-card-desc {\n    display: none;\n  }\n\n  .stat-cards-row {\n    margin-bottom: 12px !important;\n    margin-left: -4px !important;\n    margin-right: -4px !important;\n  }\n\n  .stat-cards-row .v-col {\n    padding: 4px !important;\n  }\n\n  /* --- 操作按钮区域 --- */\n  .action-bar {\n    flex-direction: column;\n    gap: 10px;\n    padding: 12px !important;\n    box-shadow: 4px 4px 0 0 rgb(var(--v-theme-on-surface)) !important;\n  }\n\n  .v-theme--dark .action-bar {\n    box-shadow: 4px 4px 0 0 rgba(255, 255, 255, 0.7) !important;\n  }\n\n  .action-bar-left {\n    width: 100%;\n    display: grid;\n    grid-template-columns: 1fr 1fr;\n    gap: 8px;\n  }\n\n  .action-bar-left .action-btn {\n    width: 100%;\n    justify-content: center;\n  }\n\n  /* 刷新按钮独占一行 */\n  .action-bar-left .action-btn:nth-child(3) {\n    grid-column: 1 / -1;\n  }\n\n  .action-bar-right {\n    width: 100%;\n    display: grid;\n    grid-template-columns: auto 1fr;\n    gap: 8px;\n  }\n\n  .action-bar-right .action-btn {\n    min-width: 0;\n    flex-shrink: 1;\n  }\n\n  .action-bar-right .load-balance-btn {\n    width: 100%;\n    justify-content: center;\n    min-width: 0;\n    overflow: hidden;\n  }\n\n  .action-bar-right .load-balance-btn :deep(.v-btn__content) {\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n\n  /* --- 渠道编排容器 --- */\n  .channel-orchestration .v-card-title {\n    display: none !important;\n  }\n\n  .channel-orchestration > .v-divider {\n    display: none !important;\n  }\n\n  /* 隐藏\"故障转移序列\"标题区域 */\n  .channel-orchestration .px-4.pt-3.pb-2 > .d-flex.mb-2 {\n    display: none !important;\n  }\n\n  /* --- 渠道列表卡片化 --- */\n  .channel-list .channel-row:active {\n    transform: translate(2px, 2px);\n    box-shadow: none !important;\n    transition: transform 0.1s;\n  }\n\n  /* --- 通用优化 --- */\n  .v-chip {\n    font-weight: 600;\n    border: 1px solid rgb(var(--v-theme-on-surface));\n    text-transform: uppercase;\n  }\n\n  .v-theme--dark .v-chip {\n    border-color: rgba(255, 255, 255, 0.5);\n  }\n\n  /* 隐藏分割线 */\n  .channel-orchestration .v-divider {\n    display: none !important;\n  }\n}\n\n/* 心跳动画 - 简化为简单闪烁 */\n.pulse-animation {\n  animation: pixel-blink 1s step-end infinite;\n}\n\n@keyframes pixel-blink {\n  0%,\n  100% {\n    opacity: 1;\n  }\n  50% {\n    opacity: 0.7;\n  }\n}\n\n/* ----- 响应式调整 ----- */\n@media (min-width: 768px) {\n  .app-header {\n    padding: 0 24px !important;\n  }\n}\n\n@media (min-width: 1024px) {\n  .app-header {\n    padding: 0 32px !important;\n  }\n}\n\n/* ----- 渠道列表动画 ----- */\n.d-contents {\n  display: contents;\n}\n\n.channel-col {\n  transition: all 0.2s ease;\n  max-width: 640px;\n}\n\n.channel-list-enter-active,\n.channel-list-leave-active {\n  transition: all 0.2s ease;\n}\n\n.channel-list-enter-from {\n  opacity: 0;\n  transform: translateY(10px);\n}\n\n.channel-list-leave-to {\n  opacity: 0;\n  transform: translateY(-10px);\n}\n\n.channel-list-move {\n  transition: transform 0.2s ease;\n}\n\n/* ----- 全局统计面板样式 ----- */\n\n/* 方案 B: 顶部可折叠卡片 */\n.global-stats-panel {\n  background: rgb(var(--v-theme-surface)) !important;\n  border: 2px solid rgb(var(--v-theme-on-surface)) !important;\n  box-shadow: 4px 4px 0 0 rgb(var(--v-theme-on-surface)) !important;\n}\n\n.v-theme--dark .global-stats-panel {\n  border-color: rgba(255, 255, 255, 0.7) !important;\n  box-shadow: 4px 4px 0 0 rgba(255, 255, 255, 0.7) !important;\n}\n\n.global-stats-header {\n  transition: background 0.15s ease;\n}\n\n.global-stats-header:hover {\n  background: rgba(var(--v-theme-primary), 0.05);\n}\n</style>\n\n<!-- 全局样式 - 复古像素主题 -->\n<style>\n/* 复古像素主题 - 全局样式 */\n.v-application {\n  font-family: 'Courier New', Consolas, 'Liberation Mono', monospace !important;\n}\n\n/* 所有按钮复古像素风格 */\n.v-btn:not(.v-btn--icon) {\n  border-radius: 0 !important;\n  text-transform: uppercase !important;\n  font-weight: 600 !important;\n}\n\n/* 所有卡片复古像素风格 */\n.v-card {\n  border-radius: 0 !important;\n}\n\n/* 所有 Chip 复古像素风格 */\n.v-chip {\n  border-radius: 0 !important;\n  font-weight: 600;\n  text-transform: uppercase;\n}\n\n/* 输入框复古像素风格 */\n.v-text-field .v-field {\n  border-radius: 0 !important;\n}\n\n/* 对话框复古像素风格 */\n.v-dialog .v-card {\n  border: 2px solid currentColor !important;\n  box-shadow: 6px 6px 0 0 currentColor !important;\n}\n\n/* 菜单复古像素风格 */\n.v-menu > .v-overlay__content > .v-list {\n  border-radius: 0 !important;\n  border: 2px solid rgb(var(--v-theme-on-surface)) !important;\n  box-shadow: 4px 4px 0 0 rgb(var(--v-theme-on-surface)) !important;\n}\n\n.v-theme--dark .v-menu > .v-overlay__content > .v-list {\n  border-color: rgba(255, 255, 255, 0.7) !important;\n  box-shadow: 4px 4px 0 0 rgba(255, 255, 255, 0.7) !important;\n}\n\n/* Snackbar 复古像素风格 */\n.v-snackbar__wrapper {\n  border-radius: 0 !important;\n  border: 2px solid currentColor !important;\n  box-shadow: 4px 4px 0 0 currentColor !important;\n}\n\n/* 状态徽章复古像素风格 */\n.status-badge .badge-content {\n  border-radius: 0 !important;\n  border: 1px solid rgb(var(--v-theme-on-surface));\n}\n\n.v-theme--dark .status-badge .badge-content {\n  border-color: rgba(255, 255, 255, 0.6);\n}\n\n/* Fuzzy tooltip 样式 - 复古像素主题 */\n.fuzzy-tooltip {\n  background: #1a1a1a !important;\n  color: #f5f5f5 !important;\n  border: 1px solid #333 !important;\n  border-radius: 0 !important;\n  box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.2) !important;\n  padding: 8px 12px !important;\n}\n\n.v-theme--dark .fuzzy-tooltip {\n  background: #2d2d2d !important;\n  color: #f5f5f5 !important;\n  border-color: #555 !important;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/assets/style.css",
    "content": "/* 全局基础样式 */\nhtml {\n  font-family: 'Courier New', Consolas, 'Liberation Mono', monospace;\n}\n\n/* 过渡动画 */\n.fade-enter-active,\n.fade-leave-active {\n  transition: opacity 0.2s ease;\n}\n\n.fade-enter-from,\n.fade-leave-to {\n  opacity: 0;\n}\n"
  },
  {
    "path": "frontend/src/components/AddChannelModal.vue",
    "content": "<template>\n  <v-dialog :model-value=\"show\" max-width=\"800\" persistent @update:model-value=\"$emit('update:show', $event)\">\n    <v-card rounded=\"lg\">\n      <v-card-title class=\"d-flex align-center ga-3 pa-6\" :class=\"headerClasses\">\n        <v-avatar :color=\"avatarColor\" variant=\"flat\" size=\"40\">\n          <v-icon :style=\"headerIconStyle\" size=\"20\">{{ isEditing ? 'mdi-pencil' : 'mdi-plus' }}</v-icon>\n        </v-avatar>\n        <div class=\"flex-grow-1\">\n          <div class=\"text-h5 font-weight-bold\">\n            {{ isEditing ? '编辑渠道' : '添加新渠道' }}\n          </div>\n          <div class=\"text-body-2\" :class=\"subtitleClasses\">\n            {{ isEditing ? '修改渠道配置信息' : isQuickMode ? '快速批量添加 API 密钥' : '配置API渠道信息和密钥' }}\n          </div>\n        </div>\n        <!-- 模式切换按钮（仅在添加模式显示） -->\n        <v-btn v-if=\"!isEditing\" variant=\"outlined\" size=\"small\" class=\"mode-toggle-btn\" @click=\"toggleMode\">\n          <v-icon start size=\"16\">{{ isQuickMode ? 'mdi-form-textbox' : 'mdi-lightning-bolt' }}</v-icon>\n          {{ isQuickMode ? '详细配置' : '快速添加' }}\n        </v-btn>\n      </v-card-title>\n\n      <v-card-text class=\"pa-6\">\n        <!-- 快速添加模式 -->\n        <div v-if=\"!isEditing && isQuickMode\">\n          <v-textarea\n            v-model=\"quickInput\"\n            label=\"输入内容\"\n            placeholder=\"每行输入一个 API Key 或 Base URL&#10;&#10;示例:&#10;sk-xxx-your-api-key&#10;sk-yyy-another-key&#10;https://api.example.com/v1\"\n            variant=\"outlined\"\n            rows=\"10\"\n            no-resize\n            autofocus\n            class=\"quick-input-textarea\"\n            @input=\"parseQuickInput\"\n          />\n\n          <!-- 检测状态提示 -->\n          <v-card variant=\"outlined\" class=\"mt-4 detection-status-card\" rounded=\"lg\">\n            <v-card-text class=\"pa-4\">\n              <div class=\"d-flex flex-column ga-3\">\n                <!-- Base URL 检测 -->\n                <div class=\"d-flex align-start ga-3\">\n                  <v-icon :color=\"detectedBaseUrls.length > 0 ? 'success' : 'error'\" size=\"20\" class=\"mt-1\">\n                    {{ detectedBaseUrls.length > 0 ? 'mdi-check-circle' : 'mdi-alert-circle' }}\n                  </v-icon>\n                  <div class=\"flex-grow-1\">\n                    <div class=\"text-body-2 font-weight-medium\">Base URL</div>\n                    <div v-if=\"detectedBaseUrls.length === 0\" class=\"text-caption text-error\">\n                      请输入一个有效的 URL (https://...)\n                    </div>\n                    <div v-else class=\"d-flex flex-column ga-2 mt-1\">\n                      <div v-for=\"url in detectedBaseUrls\" :key=\"url\" class=\"base-url-item\">\n                        <div class=\"text-caption text-success\">{{ url }}</div>\n                        <div class=\"text-caption text-medium-emphasis\">预期请求: {{ getExpectedRequestUrl(url) }}</div>\n                      </div>\n                    </div>\n                  </div>\n                  <v-chip v-if=\"detectedBaseUrls.length > 0\" size=\"x-small\" color=\"success\" variant=\"tonal\">\n                    {{ detectedBaseUrls.length }} 个\n                  </v-chip>\n                </div>\n\n                <!-- API Keys 检测 -->\n                <div class=\"d-flex align-center ga-3\">\n                  <v-icon :color=\"detectedApiKeys.length > 0 ? 'success' : 'error'\" size=\"20\">\n                    {{ detectedApiKeys.length > 0 ? 'mdi-check-circle' : 'mdi-alert-circle' }}\n                  </v-icon>\n                  <div class=\"flex-grow-1\">\n                    <div class=\"text-body-2 font-weight-medium\">API 密钥</div>\n                    <div class=\"text-caption\" :class=\"detectedApiKeys.length > 0 ? 'text-success' : 'text-error'\">\n                      {{\n                        detectedApiKeys.length > 0\n                          ? `已检测到 ${detectedApiKeys.length} 个密钥`\n                          : '请至少输入一个 API Key'\n                      }}\n                    </div>\n                  </div>\n                  <v-chip v-if=\"detectedApiKeys.length > 0\" size=\"x-small\" color=\"success\" variant=\"tonal\">\n                    {{ detectedApiKeys.length }} 个\n                  </v-chip>\n                </div>\n\n                <!-- 渠道名称预览 -->\n                <div class=\"d-flex align-center ga-3\">\n                  <v-icon color=\"primary\" size=\"20\">mdi-tag</v-icon>\n                  <div class=\"flex-grow-1\">\n                    <div class=\"text-body-2 font-weight-medium\">渠道名称</div>\n                    <div class=\"text-caption text-primary font-weight-medium\">\n                      {{ generatedChannelName }}\n                    </div>\n                  </div>\n                  <v-chip size=\"x-small\" color=\"primary\" variant=\"tonal\"> 自动生成 </v-chip>\n                </div>\n\n                <!-- 渠道类型提示 -->\n                <div class=\"d-flex align-center ga-3\">\n                  <v-icon color=\"info\" size=\"20\">mdi-information</v-icon>\n                  <div class=\"flex-grow-1\">\n                    <div class=\"text-body-2 font-weight-medium\">渠道类型</div>\n                    <div class=\"text-caption text-medium-emphasis\">\n                      {{ props.channelType === 'gemini' ? 'Gemini' : props.channelType === 'responses' ? 'Responses (Codex)' : 'Claude (Messages)' }} -\n                      {{ getDefaultServiceType() }}\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </v-card-text>\n          </v-card>\n        </div>\n\n        <!-- 详细表单模式（原有表单） -->\n        <v-form v-else ref=\"formRef\" @submit.prevent=\"handleSubmit\">\n          <v-row>\n            <!-- 基本信息 -->\n            <v-col cols=\"12\" md=\"6\">\n              <v-text-field\n                v-model=\"form.name\"\n                label=\"渠道名称 *\"\n                placeholder=\"例如：GPT-4 渠道\"\n                prepend-inner-icon=\"mdi-tag\"\n                variant=\"outlined\"\n                density=\"comfortable\"\n                :rules=\"[rules.required]\"\n                required\n                :error-messages=\"errors.name\"\n              />\n            </v-col>\n\n            <v-col cols=\"12\" md=\"6\">\n              <v-select\n                v-model=\"form.serviceType\"\n                label=\"服务类型 *\"\n                :items=\"serviceTypeOptions\"\n                prepend-inner-icon=\"mdi-cog\"\n                variant=\"outlined\"\n                density=\"comfortable\"\n                :rules=\"[rules.required]\"\n                required\n                :error-messages=\"errors.serviceType\"\n              />\n            </v-col>\n\n            <!-- 基础URL -->\n            <v-col cols=\"12\">\n              <v-textarea\n                v-model=\"baseUrlsText\"\n                label=\"基础URL *\"\n                placeholder=\"每行一个 URL，支持多个 BaseURL&#10;例如：&#10;https://api.openai.com/v1&#10;https://api2.openai.com/v1\"\n                prepend-inner-icon=\"mdi-web\"\n                variant=\"outlined\"\n                density=\"comfortable\"\n                rows=\"3\"\n                no-resize\n                :rules=\"[rules.required, rules.baseUrls]\"\n                required\n                :error-messages=\"errors.baseUrl\"\n                hide-details=\"auto\"\n              />\n              <!-- 固定高度的提示区域，防止布局跳动；有错误时不显示 -->\n              <div v-show=\"formExpectedRequestUrls.length > 0 && !baseUrlHasError\" class=\"base-url-hint\">\n                <div v-for=\"(item, index) in formExpectedRequestUrls\" :key=\"index\" class=\"expected-request-item\">\n                  <span class=\"text-caption text-medium-emphasis\"> 预期请求: {{ item.expectedUrl }} </span>\n                </div>\n              </div>\n            </v-col>\n\n            <!-- 官网/控制台（可选） -->\n            <v-col cols=\"12\">\n              <v-text-field\n                v-model=\"form.website\"\n                label=\"官网/控制台 (可选)\"\n                placeholder=\"例如：https://platform.openai.com\"\n                prepend-inner-icon=\"mdi-open-in-new\"\n                variant=\"outlined\"\n                density=\"comfortable\"\n                type=\"url\"\n                :rules=\"[rules.urlOptional]\"\n                :error-messages=\"errors.website\"\n              />\n            </v-col>\n\n            <!-- 模型重定向配置 -->\n            <v-col v-if=\"form.serviceType\" cols=\"12\">\n              <v-card variant=\"outlined\" rounded=\"lg\">\n                <v-card-title class=\"d-flex align-center justify-space-between pa-4 pb-2\">\n                  <div class=\"d-flex align-center ga-2\">\n                    <v-icon color=\"primary\">mdi-swap-horizontal</v-icon>\n                    <span class=\"text-body-1 font-weight-bold\">模型重定向 (可选)</span>\n                  </div>\n                  <v-chip size=\"small\" color=\"secondary\" variant=\"tonal\"> 自动转换模型名称 </v-chip>\n                </v-card-title>\n\n                <v-card-text class=\"pt-2\">\n                  <div class=\"text-body-2 text-medium-emphasis mb-4\">\n                    {{ modelMappingHint }}\n                    <br/>\n                    <span class=\"text-caption text-primary\">💡 点击目标模型输入框会自动获取上游支持的模型列表,每个 API Key 的检测状态会显示在密钥列表中</span>\n                  </div>\n\n                  <!-- 现有映射列表 -->\n                  <div v-if=\"Object.keys(form.modelMapping).length\" class=\"mb-4\">\n                    <v-list density=\"compact\" class=\"bg-transparent\">\n                      <v-list-item\n                        v-for=\"[source, target] in Object.entries(form.modelMapping)\"\n                        :key=\"source\"\n                        class=\"mb-2\"\n                        rounded=\"lg\"\n                        variant=\"tonal\"\n                        color=\"surface-variant\"\n                      >\n                        <template #prepend>\n                          <v-icon size=\"small\" color=\"primary\">mdi-arrow-right</v-icon>\n                        </template>\n\n                        <v-list-item-title>\n                          <div class=\"d-flex align-center ga-2\">\n                            <code class=\"text-caption\">{{ source }}</code>\n                            <v-icon size=\"small\" color=\"primary\">mdi-arrow-right</v-icon>\n                            <code class=\"text-caption\">{{ target }}</code>\n                          </div>\n                        </v-list-item-title>\n\n                        <template #append>\n                          <v-btn size=\"small\" color=\"error\" icon variant=\"text\" @click=\"removeModelMapping(source)\">\n                            <v-icon size=\"small\" color=\"error\">mdi-close</v-icon>\n                          </v-btn>\n                        </template>\n                      </v-list-item>\n                    </v-list>\n                  </div>\n\n                  <!-- 添加新映射 -->\n                  <div class=\"d-flex align-center ga-2\">\n                    <v-select\n                      v-model=\"newMapping.source\"\n                      label=\"源模型名\"\n                      :items=\"sourceModelOptions\"\n                      variant=\"outlined\"\n                      density=\"comfortable\"\n                      hide-details\n                      class=\"flex-1-1\"\n                      placeholder=\"选择源模型名\"\n                    />\n                    <v-icon color=\"primary\">mdi-arrow-right</v-icon>\n                    <v-combobox\n                      v-model=\"newMapping.target\"\n                      label=\"目标模型名\"\n                      :placeholder=\"targetModelPlaceholder\"\n                      :items=\"targetModelOptions\"\n                      :loading=\"fetchingModels\"\n                      variant=\"outlined\"\n                      density=\"comfortable\"\n                      hide-details\n                      class=\"flex-1-1\"\n                      clearable\n                      @focus=\"handleTargetModelClick\"\n                      @keyup.enter=\"addModelMapping\"\n                    />\n                    <v-btn\n                      color=\"secondary\"\n                      variant=\"elevated\"\n                      :disabled=\"!isMappingInputValid\"\n                      @click=\"addModelMapping\"\n                    >\n                      添加\n                    </v-btn>\n                  </div>\n                  <!-- 错误提示 -->\n                  <div v-if=\"fetchModelsError\" class=\"text-error text-caption mt-2\">\n                    {{ fetchModelsError }}\n                  </div>\n                </v-card-text>\n              </v-card>\n            </v-col>\n\n            <!-- API密钥管理 -->\n            <v-col cols=\"12\">\n              <v-card variant=\"outlined\" rounded=\"lg\" :color=\"form.apiKeys.length === 0 ? 'error' : undefined\">\n                <v-card-title class=\"d-flex align-center justify-space-between pa-4 pb-2\">\n                  <div class=\"d-flex align-center ga-2\">\n                    <v-icon :color=\"form.apiKeys.length > 0 ? 'primary' : 'error'\">mdi-key</v-icon>\n                    <span class=\"text-body-1 font-weight-bold\">API密钥管理 *</span>\n                    <v-chip v-if=\"form.apiKeys.length === 0\" size=\"x-small\" color=\"error\" variant=\"tonal\">\n                      至少需要一个密钥\n                    </v-chip>\n                  </div>\n                  <v-chip size=\"small\" color=\"info\" variant=\"tonal\"> 可添加多个密钥用于负载均衡 </v-chip>\n                </v-card-title>\n\n                <v-card-text class=\"pt-2\">\n                  <!-- 现有密钥列表 -->\n                  <div v-if=\"form.apiKeys.length\" class=\"mb-4\">\n                    <v-list density=\"compact\" class=\"bg-transparent\">\n                      <v-list-item\n                        v-for=\"(key, index) in form.apiKeys\"\n                        :key=\"index\"\n                        class=\"mb-2\"\n                        rounded=\"lg\"\n                        variant=\"tonal\"\n                        :color=\"duplicateKeyIndex === index ? 'error' : 'surface-variant'\"\n                        :class=\"{ 'animate-pulse': duplicateKeyIndex === index }\"\n                      >\n                        <template #prepend>\n                          <v-icon size=\"small\" :color=\"duplicateKeyIndex === index ? 'error' : 'primary'\">\n                            {{ duplicateKeyIndex === index ? 'mdi-alert' : 'mdi-key' }}\n                          </v-icon>\n                        </template>\n\n                        <v-list-item-title>\n                          <div class=\"d-flex align-center justify-space-between\">\n                            <code class=\"text-caption\">{{ maskApiKey(key) }}</code>\n                            <div class=\"d-flex align-center ga-1\">\n                              <!-- Models 状态标签 -->\n                              <v-chip\n                                v-if=\"keyModelsStatus.get(key)?.loading\"\n                                size=\"x-small\"\n                                color=\"info\"\n                                variant=\"tonal\"\n                              >\n                                <v-icon start size=\"12\">mdi-loading</v-icon>\n                                检测中...\n                              </v-chip>\n                              <v-chip\n                                v-else-if=\"keyModelsStatus.get(key)?.success\"\n                                size=\"x-small\"\n                                color=\"success\"\n                                variant=\"tonal\"\n                              >\n                                models {{ keyModelsStatus.get(key)?.statusCode }} ({{ keyModelsStatus.get(key)?.modelCount }} 个)\n                              </v-chip>\n                              <v-tooltip\n                                v-else-if=\"keyModelsStatus.get(key)?.error\"\n                                :text=\"keyModelsStatus.get(key)?.error\"\n                                location=\"top\"\n                                max-width=\"300\"\n                              >\n                                <template #activator=\"{ props: tooltipProps }\">\n                                  <v-chip\n                                    v-bind=\"tooltipProps\"\n                                    size=\"x-small\"\n                                    color=\"error\"\n                                    variant=\"tonal\"\n                                  >\n                                    models {{ keyModelsStatus.get(key)?.statusCode || 'ERR' }}\n                                  </v-chip>\n                                </template>\n                              </v-tooltip>\n                              <!-- 重复密钥标签 -->\n                              <v-chip v-if=\"duplicateKeyIndex === index\" size=\"x-small\" color=\"error\" variant=\"text\">\n                                重复密钥\n                              </v-chip>\n                            </div>\n                          </div>\n                        </v-list-item-title>\n\n                        <template #append>\n                          <div class=\"d-flex align-center ga-1\">\n                            <!-- 置顶/置底：仅首尾密钥显示 -->\n                            <v-tooltip\n                              v-if=\"index === form.apiKeys.length - 1 && form.apiKeys.length > 1\"\n                              text=\"置顶\"\n                              location=\"top\"\n                              :open-delay=\"150\"\n                              content-class=\"key-tooltip\"\n                            >\n                              <template #activator=\"{ props: tooltipProps }\">\n                                <v-btn\n                                  v-bind=\"tooltipProps\"\n                                  size=\"small\"\n                                  color=\"warning\"\n                                  icon\n                                  variant=\"text\"\n                                  rounded=\"md\"\n                                  @click=\"moveApiKeyToTop(index)\"\n                                >\n                                  <v-icon size=\"small\">mdi-arrow-up-bold</v-icon>\n                                </v-btn>\n                              </template>\n                            </v-tooltip>\n                            <v-tooltip\n                              v-if=\"index === 0 && form.apiKeys.length > 1\"\n                              text=\"置底\"\n                              location=\"top\"\n                              :open-delay=\"150\"\n                              content-class=\"key-tooltip\"\n                            >\n                              <template #activator=\"{ props: tooltipProps }\">\n                                <v-btn\n                                  v-bind=\"tooltipProps\"\n                                  size=\"small\"\n                                  color=\"warning\"\n                                  icon\n                                  variant=\"text\"\n                                  rounded=\"md\"\n                                  @click=\"moveApiKeyToBottom(index)\"\n                                >\n                                  <v-icon size=\"small\">mdi-arrow-down-bold</v-icon>\n                                </v-btn>\n                              </template>\n                            </v-tooltip>\n                            <v-tooltip\n                              :text=\"copiedKeyIndex === index ? '已复制!' : '复制密钥'\"\n                              location=\"top\"\n                              :open-delay=\"150\"\n                              content-class=\"key-tooltip\"\n                            >\n                              <template #activator=\"{ props: tooltipProps }\">\n                                <v-btn\n                                  v-bind=\"tooltipProps\"\n                                  size=\"small\"\n                                  :color=\"copiedKeyIndex === index ? 'success' : 'primary'\"\n                                  icon\n                                  variant=\"text\"\n                                  @click=\"copyApiKey(key, index)\"\n                                >\n                                  <v-icon size=\"small\">{{\n                                    copiedKeyIndex === index ? 'mdi-check' : 'mdi-content-copy'\n                                  }}</v-icon>\n                                </v-btn>\n                              </template>\n                            </v-tooltip>\n                            <v-tooltip text=\"删除密钥\" location=\"top\" :open-delay=\"150\" content-class=\"key-tooltip\">\n                              <template #activator=\"{ props: tooltipProps }\">\n                                <v-btn\n                                  v-bind=\"tooltipProps\"\n                                  size=\"small\"\n                                  color=\"error\"\n                                  icon\n                                  variant=\"text\"\n                                  @click=\"removeApiKey(index)\"\n                                >\n                                  <v-icon size=\"small\" color=\"error\">mdi-close</v-icon>\n                                </v-btn>\n                              </template>\n                            </v-tooltip>\n                          </div>\n                        </template>\n                      </v-list-item>\n                    </v-list>\n                  </div>\n\n                  <!-- 添加新密钥 -->\n                  <div class=\"d-flex align-start ga-3\">\n                    <v-text-field\n                      v-model=\"newApiKey\"\n                      label=\"添加新的API密钥\"\n                      placeholder=\"输入完整的API密钥\"\n                      prepend-inner-icon=\"mdi-plus\"\n                      variant=\"outlined\"\n                      density=\"comfortable\"\n                      type=\"password\"\n                      :error=\"!!apiKeyError\"\n                      :error-messages=\"apiKeyError\"\n                      class=\"flex-grow-1\"\n                      @keyup.enter=\"addApiKey\"\n                      @input=\"handleApiKeyInput\"\n                    />\n                    <v-btn\n                      color=\"primary\"\n                      variant=\"elevated\"\n                      size=\"large\"\n                      height=\"40\"\n                      :disabled=\"!newApiKey.trim()\"\n                      class=\"mt-1\"\n                      @click=\"addApiKey\"\n                    >\n                      添加\n                    </v-btn>\n                  </div>\n                </v-card-text>\n              </v-card>\n            </v-col>\n\n            <!-- 描述 -->\n            <v-col cols=\"12\">\n              <v-textarea\n                v-model=\"form.description\"\n                label=\"描述 (可选)\"\n                hint=\"可选的渠道描述...\"\n                persistent-hint\n                prepend-inner-icon=\"mdi-text\"\n                variant=\"outlined\"\n                density=\"comfortable\"\n                rows=\"3\"\n                no-resize\n              />\n            </v-col>\n\n            <!-- 跳过 TLS 证书验证 -->\n            <v-col cols=\"12\">\n              <div class=\"d-flex align-center justify-space-between\">\n                <div class=\"d-flex align-center ga-2\">\n                  <v-icon color=\"warning\">mdi-shield-alert</v-icon>\n                  <div>\n                    <div class=\"text-body-1 font-weight-medium\">跳过 TLS 证书验证</div>\n                    <div class=\"text-caption text-medium-emphasis\">\n                      仅在自签名或域名不匹配时临时启用，生产环境请关闭\n                    </div>\n                  </div>\n                </div>\n                <v-switch v-model=\"form.insecureSkipVerify\" inset color=\"warning\" hide-details />\n              </div>\n            </v-col>\n\n            <!-- 低质量渠道标记 -->\n            <v-col cols=\"12\">\n              <div class=\"d-flex align-center justify-space-between\">\n                <div class=\"d-flex align-center ga-2\">\n                  <v-icon color=\"info\">mdi-speedometer-slow</v-icon>\n                  <div>\n                    <div class=\"text-body-1 font-weight-medium\">低质量渠道</div>\n                    <div class=\"text-caption text-medium-emphasis\">\n                      启用后强制本地估算 token 数量，偏差超过 5% 时使用本地值\n                    </div>\n                  </div>\n                </div>\n                <v-switch v-model=\"form.lowQuality\" inset color=\"info\" hide-details />\n              </div>\n            </v-col>\n\n            <!-- 注入 Dummy Thought Signature（仅 Gemini 渠道显示） -->\n            <v-col v-if=\"props.channelType === 'gemini'\" cols=\"12\">\n              <div class=\"d-flex align-center justify-space-between\">\n                <div class=\"d-flex align-center ga-2\">\n                  <v-icon color=\"secondary\">mdi-signature</v-icon>\n                  <div>\n                    <div class=\"text-body-1 font-weight-medium\">注入 Dummy Thought Signature</div>\n                    <div class=\"text-caption text-medium-emphasis\">\n                      为 functionCall 注入 dummy signature，兼容需要该字段的第三方 API（官方 API 请关闭）\n                    </div>\n                  </div>\n                </div>\n                <v-switch v-model=\"form.injectDummyThoughtSignature\" inset color=\"secondary\" hide-details />\n              </div>\n            </v-col>\n\n            <!-- 移除 Thought Signature（仅 Gemini 渠道显示） -->\n            <v-col v-if=\"props.channelType === 'gemini'\" cols=\"12\">\n              <div class=\"d-flex align-center justify-space-between\">\n                <div class=\"d-flex align-center ga-2\">\n                  <v-icon color=\"error\">mdi-close-circle</v-icon>\n                  <div>\n                    <div class=\"text-body-1 font-weight-medium\">移除 Thought Signature</div>\n                    <div class=\"text-caption text-medium-emphasis\">\n                      移除 functionCall 的 thought_signature 字段，兼容不支持该字段的旧版 Gemini API\n                    </div>\n                  </div>\n                </div>\n                <v-switch v-model=\"form.stripThoughtSignature\" inset color=\"error\" hide-details />\n              </div>\n            </v-col>\n          </v-row>\n        </v-form>\n      </v-card-text>\n\n      <v-card-actions class=\"pa-6 pt-0\">\n        <v-spacer />\n        <v-btn variant=\"text\" @click=\"handleCancel\"> 取消 </v-btn>\n        <v-btn\n          v-if=\"!isEditing && isQuickMode\"\n          color=\"primary\"\n          variant=\"elevated\"\n          :disabled=\"!isQuickFormValid\"\n          prepend-icon=\"mdi-check\"\n          @click=\"handleQuickSubmit\"\n        >\n          创建渠道\n        </v-btn>\n        <v-btn\n          v-else\n          color=\"primary\"\n          variant=\"elevated\"\n          :disabled=\"!isFormValid\"\n          prepend-icon=\"mdi-check\"\n          @click=\"handleSubmit\"\n        >\n          {{ isEditing ? '更新渠道' : '创建渠道' }}\n        </v-btn>\n      </v-card-actions>\n    </v-card>\n  </v-dialog>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue'\nimport { useTheme } from 'vuetify'\nimport type { Channel } from '../services/api'\nimport { fetchUpstreamModels, ApiError } from '../services/api'\nimport {\n  isValidApiKey as _isValidApiKey,\n  isValidUrl as _isValidQuickInputUrl,\n  parseQuickInput as parseQuickInputUtil\n} from '../utils/quickInputParser'\n\ninterface Props {\n  show: boolean\n  channel?: Channel | null\n  channelType?: 'messages' | 'responses' | 'gemini'\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n  channelType: 'messages'\n})\n\nconst emit = defineEmits<{\n  'update:show': [value: boolean]\n  save: [channel: Omit<Channel, 'index' | 'latency' | 'status'>, options?: { isQuickAdd?: boolean }]\n}>()\n\n// 主题\nconst theme = useTheme()\n\n// 表单引用\nconst formRef = ref()\n\n// 模式切换: 快速添加 vs 详细表单\nconst isQuickMode = ref(true)\n\n// 快速添加模式的数据\nconst quickInput = ref('')\nconst detectedBaseUrl = ref('')\nconst detectedBaseUrls = ref<string[]>([])\nconst detectedApiKeys = ref<string[]>([])\nconst detectedServiceType = ref<'openai' | 'gemini' | 'claude' | 'responses' | null>(null)\n\n// 详细表单预期请求 URL 预览（防止输入时抖动）\nconst formBaseUrlPreview = ref('')\nlet formBaseUrlPreviewTimer: number | null = null\n\n// 切换模式时，将快速模式检测到的值同步到详细表单，但不清空快速模式输入\nconst toggleMode = () => {\n  if (isQuickMode.value) {\n    // 从快速模式切换到详细模式：始终用检测到的值覆盖表单\n    if (detectedBaseUrls.value.length > 0) {\n      // 多个 BaseURL\n      form.baseUrl = detectedBaseUrls.value[0]\n      form.baseUrls = [...detectedBaseUrls.value]\n      baseUrlsText.value = detectedBaseUrls.value.join('\\n')\n    } else if (detectedBaseUrl.value) {\n      // 单个 BaseURL\n      form.baseUrl = detectedBaseUrl.value\n      form.baseUrls = []\n      baseUrlsText.value = detectedBaseUrl.value\n    }\n    if (detectedApiKeys.value.length > 0) {\n      form.apiKeys = [...detectedApiKeys.value]\n    }\n    if (generatedChannelName.value) {\n      form.name = generatedChannelName.value\n    }\n    form.serviceType = detectedServiceType.value || getDefaultServiceTypeValue()\n  }\n  // 切换回快速模式时不做任何清理，保留 quickInput 原有内容\n  isQuickMode.value = !isQuickMode.value\n}\n\n// 解析快速输入内容\nconst parseQuickInput = () => {\n  const result = parseQuickInputUtil(quickInput.value)\n  detectedBaseUrl.value = result.detectedBaseUrl\n  detectedBaseUrls.value = result.detectedBaseUrls\n  detectedApiKeys.value = result.detectedApiKeys\n  detectedServiceType.value = result.detectedServiceType\n}\n\n// 获取默认服务类型\nconst getDefaultServiceType = (): string => {\n  if (props.channelType === 'gemini') {\n    return 'Gemini'\n  }\n  if (props.channelType === 'responses') {\n    return 'Responses (原生接口)'\n  }\n  return 'Claude'\n}\n\n// 获取默认服务类型值\nconst getDefaultServiceTypeValue = (): 'openai' | 'gemini' | 'claude' | 'responses' => {\n  if (props.channelType === 'gemini') {\n    return 'gemini'\n  }\n  if (props.channelType === 'responses') {\n    return 'responses'\n  }\n  return 'claude'\n}\n\n// 获取默认 Base URL\nconst _getDefaultBaseUrl = (): string => {\n  if (props.channelType === 'gemini') {\n    return 'https://generativelanguage.googleapis.com'\n  }\n  if (props.channelType === 'responses') {\n    return 'https://api.openai.com/v1'\n  }\n  return 'https://api.anthropic.com'\n}\n\n// 快速模式表单验证\nconst isQuickFormValid = computed(() => {\n  return detectedBaseUrls.value.length > 0 && detectedApiKeys.value.length > 0\n})\n\n// 生成随机字符串\nconst generateRandomString = (length: number): string => {\n  const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'\n  let result = ''\n  for (let i = 0; i < length; i++) {\n    result += chars.charAt(Math.floor(Math.random() * chars.length))\n  }\n  return result\n}\n\n// 从 URL 提取二级域名\nconst extractDomain = (url: string): string => {\n  try {\n    const hostname = new URL(url).hostname\n    // 移除 www. 前缀\n    const cleanHost = hostname.replace(/^www\\./, '')\n    const parts = cleanHost.split('.')\n\n    // 处理特殊情况\n    if (parts.length <= 1) {\n      // localhost 等单段域名\n      return cleanHost\n    } else if (parts.length === 2) {\n      // example.com → example\n      return parts[0]\n    } else {\n      // api.openai.com → openai (取倒数第二段)\n      return parts[parts.length - 2]\n    }\n  } catch {\n    return 'channel'\n  }\n}\n\n// 随机后缀和生成的渠道名称\nconst randomSuffix = ref(generateRandomString(6))\n\nconst generatedChannelName = computed(() => {\n  if (!detectedBaseUrl.value) {\n    return `channel-${randomSuffix.value}`\n  }\n  const domain = extractDomain(detectedBaseUrl.value)\n  return `${domain}-${randomSuffix.value}`\n})\n\n// 预期请求 URL（模拟后端逻辑）\nconst _expectedRequestUrl = computed(() => {\n  if (!detectedBaseUrl.value) return ''\n\n  let baseUrl = detectedBaseUrl.value\n  const skipVersion = baseUrl.endsWith('#')\n  if (skipVersion) {\n    baseUrl = baseUrl.slice(0, -1)\n  }\n\n  // 检查是否已包含版本号\n  const hasVersion = /\\/v\\d+[a-z]*$/.test(baseUrl)\n\n  // 根据渠道类型和服务类型确定端点（与后端逻辑一致）\n  const serviceType = detectedServiceType.value || getDefaultServiceTypeValue()\n  let endpoint = ''\n  if (props.channelType === 'responses') {\n    // responses 渠道根据 serviceType 决定端点\n    if (serviceType === 'responses') {\n      endpoint = '/responses'\n    } else if (serviceType === 'claude') {\n      endpoint = '/messages'\n    } else {\n      endpoint = '/chat/completions'\n    }\n  } else {\n    // messages 渠道：根据检测到的服务类型决定端点\n    if (serviceType === 'claude') {\n      endpoint = '/messages'\n    } else if (serviceType === 'gemini') {\n      endpoint = '/models/{model}:generateContent'\n    } else {\n      endpoint = '/chat/completions'\n    }\n  }\n\n  if (hasVersion || skipVersion) {\n    return baseUrl + endpoint\n  }\n  // Gemini 使用 /v1beta，其他使用 /v1\n  const versionPrefix = serviceType === 'gemini' ? '/v1beta' : '/v1'\n  return baseUrl + versionPrefix + endpoint\n})\n\n// 生成单个 URL 的预期请求地址\nconst getExpectedRequestUrl = (inputBaseUrl: string): string => {\n  if (!inputBaseUrl) return ''\n\n  let baseUrl = inputBaseUrl\n  const skipVersion = baseUrl.endsWith('#')\n  if (skipVersion) {\n    baseUrl = baseUrl.slice(0, -1)\n  }\n\n  const hasVersion = /\\/v\\d+[a-z]*$/.test(baseUrl)\n\n  const serviceType = detectedServiceType.value || getDefaultServiceTypeValue()\n  let endpoint = ''\n  if (props.channelType === 'responses') {\n    if (serviceType === 'responses') {\n      endpoint = '/responses'\n    } else if (serviceType === 'claude') {\n      endpoint = '/messages'\n    } else {\n      endpoint = '/chat/completions'\n    }\n  } else {\n    if (serviceType === 'claude') {\n      endpoint = '/messages'\n    } else if (serviceType === 'gemini') {\n      endpoint = '/models/{model}:generateContent'\n    } else {\n      endpoint = '/chat/completions'\n    }\n  }\n\n  if (hasVersion || skipVersion) {\n    return baseUrl + endpoint\n  }\n  // Gemini 使用 /v1beta，其他使用 /v1\n  const versionPrefix = serviceType === 'gemini' ? '/v1beta' : '/v1'\n  return baseUrl + versionPrefix + endpoint\n}\n\n// 检测 baseUrl 是否有验证错误\nconst baseUrlHasError = computed(() => {\n  const value = form.baseUrl\n  if (!value) return true\n  try {\n    new URL(value)\n    return false\n  } catch {\n    return true\n  }\n})\n\n// 详细模式所有 URL 的预期请求（支持多 BaseURL）\nconst formExpectedRequestUrls = computed(() => {\n  if (!form.serviceType) return []\n\n  // 收集所有 URL\n  const urls: string[] = []\n  if (form.baseUrls && form.baseUrls.length > 0) {\n    urls.push(...form.baseUrls)\n  } else if (form.baseUrl) {\n    urls.push(form.baseUrl)\n  }\n\n  if (urls.length === 0) return []\n\n  // 根据 serviceType 确定端点\n  let endpoint = ''\n  if (props.channelType === 'responses') {\n    if (form.serviceType === 'responses') {\n      endpoint = '/responses'\n    } else if (form.serviceType === 'claude') {\n      endpoint = '/messages'\n    } else {\n      endpoint = '/chat/completions'\n    }\n  } else {\n    // messages 渠道\n    if (form.serviceType === 'claude') {\n      endpoint = '/messages'\n    } else if (form.serviceType === 'gemini') {\n      endpoint = '/models/{model}:generateContent'\n    } else {\n      endpoint = '/chat/completions'\n    }\n  }\n\n  // 为每个 URL 生成预期请求\n  return urls\n    .filter(url => url && isValidUrl(url.replace(/#$/, '')))\n    .map(rawUrl => {\n      let baseUrl = rawUrl.trim()\n      const skipVersion = baseUrl.endsWith('#')\n      if (skipVersion) {\n        baseUrl = baseUrl.slice(0, -1)\n      }\n      baseUrl = baseUrl.replace(/\\/$/, '')\n\n      const hasVersion = /\\/v\\d+[a-z]*$/.test(baseUrl)\n\n      // Gemini 使用 /v1beta，其他使用 /v1\n      const versionPrefix = form.serviceType === 'gemini' ? '/v1beta' : '/v1'\n      const expectedUrl = hasVersion || skipVersion ? baseUrl + endpoint : baseUrl + versionPrefix + endpoint\n\n      return { baseUrl: rawUrl, expectedUrl }\n    })\n})\n\n// 处理快速添加提交\nconst handleQuickSubmit = () => {\n  if (!isQuickFormValid.value) return\n\n  const channelData = {\n    name: generatedChannelName.value,\n    serviceType: detectedServiceType.value || getDefaultServiceTypeValue(),\n    baseUrl: detectedBaseUrl.value,\n    baseUrls: detectedBaseUrls.value,\n    apiKeys: detectedApiKeys.value,\n    modelMapping: {}\n  }\n\n  // 传递 isQuickAdd 标志，让 App.vue 知道需要进行后续处理\n  emit('save', channelData, { isQuickAdd: true })\n}\n\n// 服务类型选项 - 根据渠道类型动态显示\nconst serviceTypeOptions = computed(() => {\n  if (props.channelType === 'gemini') {\n    return [\n      { title: 'Gemini', value: 'gemini' },\n      { title: 'OpenAI', value: 'openai' },\n      { title: 'Claude', value: 'claude' }\n    ]\n  }\n  if (props.channelType === 'responses') {\n    return [\n      { title: 'Responses (原生接口)', value: 'responses' },\n      { title: 'OpenAI', value: 'openai' },\n      { title: 'Claude', value: 'claude' }\n    ]\n  } else {\n    return [\n      { title: 'OpenAI', value: 'openai' },\n      { title: 'Claude', value: 'claude' },\n      { title: 'Gemini', value: 'gemini' }\n    ]\n  }\n})\n\n// 全部源模型选项 - 根据渠道类型动态显示\nconst allSourceModelOptions = computed(() => {\n  if (props.channelType === 'gemini') {\n    // Gemini API 常用模型别名\n    return [\n      { title: 'gemini-2', value: 'gemini-2' },\n      { title: 'gemini-2.5-flash', value: 'gemini-2.5-flash' },\n      { title: 'gemini-2.5-flash-lite', value: 'gemini-2.5-flash-lite' },\n      { title: 'gemini-2.5-flash-image', value: 'gemini-2.5-flash-image' },\n      { title: 'gemini-2.5-flash-preview-tts', value: 'gemini-2.5-flash-preview-tts' },\n      { title: 'gemini-2.5-flash-native-audio-preview-12-2025', value: 'gemini-2.5-flash-native-audio-preview-12-2025' },\n      { title: 'gemini-2.5-pro', value: 'gemini-2.5-pro' },\n      { title: 'gemini-2.5-pro-preview-tts', value: 'gemini-2.5-pro-preview-tts' },\n      { title: 'gemini-3-pro-preview', value: 'gemini-3-pro-preview' },\n      { title: 'gemini-3-flash-preview', value: 'gemini-3-flash-preview' },\n      { title: 'gemini-3-pro-image-preview', value: 'gemini-3-pro-image-preview' }\n    ]\n  }\n  if (props.channelType === 'responses') {\n    // Responses API (Codex) 常用模型名称\n    return [\n      { title: 'codex', value: 'codex' },\n      { title: 'gpt-5', value: 'gpt-5' },\n      { title: 'gpt-5.2-codex', value: 'gpt-5.2-codex' },\n      { title: 'gpt-5.2', value: 'gpt-5.2' },\n      { title: 'gpt-5.1-codex-max', value: 'gpt-5.1-codex-max' },\n      { title: 'gpt-5.1-codex', value: 'gpt-5.1-codex' },\n      { title: 'gpt-5.1-codex-mini', value: 'gpt-5.1-codex-mini' },\n      { title: 'gpt-5.1', value: 'gpt-5.1' }\n    ]\n  } else {\n    // Messages API (Claude) 常用模型别名\n    return [\n      { title: 'opus', value: 'opus' },\n      { title: 'sonnet', value: 'sonnet' },\n      { title: 'haiku', value: 'haiku' }\n    ]\n  }\n})\n\n// 可选的源模型选项 - 过滤掉已配置的模型\nconst sourceModelOptions = computed(() => {\n  const configuredModels = Object.keys(form.modelMapping)\n  return allSourceModelOptions.value.filter(opt => !configuredModels.includes(opt.value))\n})\n\n// 模型重定向的示例文本 - 根据渠道类型动态显示\nconst modelMappingHint = computed(() => {\n  if (props.channelType === 'gemini') {\n    return '配置模型名称映射，将请求中的模型名重定向到目标模型。例如：将 \"gemini-pro\" 重定向到 \"gemini-2.0-flash\"'\n  }\n  if (props.channelType === 'responses') {\n    return '配置模型名称映射，将请求中的模型名重定向到目标模型。例如：将 \"o3\" 重定向到 \"gpt-5.1-codex-max\"'\n  } else {\n    return '配置模型名称映射，将请求中的模型名重定向到目标模型。例如：将 \"opus\" 重定向到 \"claude-3-5-sonnet\"'\n  }\n})\n\nconst targetModelPlaceholder = computed(() => {\n  if (props.channelType === 'gemini') {\n    return '例如：gemini-2.0-flash'\n  }\n  if (props.channelType === 'responses') {\n    return '例如：gpt-5.1-codex-max'\n  } else {\n    return '例如：claude-3-5-sonnet'\n  }\n})\n\n// 表单数据\nconst form = reactive({\n  name: '',\n  serviceType: '' as 'openai' | 'gemini' | 'claude' | 'responses' | '',\n  baseUrl: '',\n  baseUrls: [] as string[],\n  website: '',\n  insecureSkipVerify: false,\n  lowQuality: false,\n  injectDummyThoughtSignature: false,\n  stripThoughtSignature: false,\n  description: '',\n  apiKeys: [] as string[],\n  modelMapping: {} as Record<string, string>\n})\n\n// 多 BaseURL 文本输入（独立变量，保留用户输入的换行）\nconst baseUrlsText = ref('')\n\n// 监听 baseUrlsText 变化，同步到 form（仅做基本同步，不修改用户输入）\nwatch(baseUrlsText, val => {\n  const urls = val\n    .split('\\n')\n    .map(s => s.trim())\n    .filter(Boolean)\n  if (urls.length === 0) {\n    form.baseUrl = ''\n    form.baseUrls = []\n  } else if (urls.length === 1) {\n    form.baseUrl = urls[0]\n    form.baseUrls = []\n  } else {\n    form.baseUrl = urls[0]\n    form.baseUrls = urls\n  }\n})\n\n// 原始密钥映射 (掩码密钥 -> 原始密钥)\nconst originalKeyMap = ref<Map<string, string>>(new Map())\n\n// 新API密钥输入\nconst newApiKey = ref('')\n\n// 密钥重复检测状态\nconst apiKeyError = ref('')\nconst duplicateKeyIndex = ref(-1)\n\n// 处理 API 密钥输入事件\nconst handleApiKeyInput = () => {\n  apiKeyError.value = ''\n  duplicateKeyIndex.value = -1\n}\n\n// 复制功能相关状态\nconst copiedKeyIndex = ref<number | null>(null)\n\n// 新模型映射输入\nconst newMapping = reactive({\n  source: '',\n  target: ''\n})\n\n// 安全地获取字符串值（处理 v-select/v-combobox 可能返回对象的情况）\nconst getStringValue = (val: string | { title: string; value: string } | null | undefined): string => {\n  if (!val) return ''\n  if (typeof val === 'string') return val\n  return val.value || ''\n}\n\n// 检查映射输入是否有效\nconst isMappingInputValid = computed(() => {\n  const source = getStringValue(newMapping.source).trim()\n  const target = getStringValue(newMapping.target).trim()\n  return source && target\n})\n\n// 目标模型列表（从上游获取）\nconst targetModelOptions = ref<Array<{ title: string; value: string }>>([])\nconst fetchingModels = ref(false)\nconst fetchModelsError = ref('')\nconst hasTriedFetchModels = ref(false) // 标记是否已尝试获取过模型列表\n\n// API Key 的 models 状态管理\ninterface KeyModelsStatus {\n  loading: boolean\n  success: boolean\n  statusCode?: number\n  error?: string\n  modelCount?: number\n}\nconst keyModelsStatus = ref<Map<string, KeyModelsStatus>>(new Map())\n\n// 表单验证错误\nconst errors = reactive({\n  name: '',\n  serviceType: '',\n  baseUrl: '',\n  website: ''\n})\n\n// 验证规则\nconst rules = {\n  required: (value: string) => !!value || '此字段为必填项',\n  url: (value: string) => {\n    try {\n      new URL(value)\n      return true\n    } catch {\n      return '请输入有效的URL'\n    }\n  },\n  urlOptional: (value: string) => {\n    if (!value) return true\n    try {\n      new URL(value)\n      return true\n    } catch {\n      return '请输入有效的URL'\n    }\n  },\n  baseUrls: (value: string) => {\n    if (!value) return '此字段为必填项'\n    const urls = value\n      .split('\\n')\n      .map(s => s.trim())\n      .filter(Boolean)\n    if (urls.length === 0) return '请至少输入一个 URL'\n    for (const url of urls) {\n      try {\n        new URL(url)\n      } catch {\n        return `无效的 URL: ${url}`\n      }\n    }\n    return true\n  }\n}\n\n// 计算属性\nconst isEditing = computed(() => !!props.channel)\n\n// 动态header样式\nconst headerClasses = computed(() => {\n  const isDark = theme.global.current.value.dark\n  // Dark: keep neutral surface header; Light: use brand primary header\n  return isDark ? 'bg-surface text-high-emphasis' : 'bg-primary text-white'\n})\n\nconst avatarColor = computed(() => 'primary')\n\n// Use Vuetify theme \"on-primary\" token so icon isn't fixed white\nconst headerIconStyle = computed(() => ({\n  color: 'rgb(var(--v-theme-on-primary))'\n}))\n\nconst subtitleClasses = computed(() => {\n  const isDark = theme.global.current.value.dark\n  // Dark mode: use medium emphasis; Light mode: use white with opacity for primary bg\n  return isDark ? 'text-medium-emphasis' : 'text-white-subtitle'\n})\n\nconst isFormValid = computed(() => {\n  return (\n    form.name.trim() && form.serviceType && form.baseUrl.trim() && isValidUrl(form.baseUrl) && form.apiKeys.length > 0\n  )\n})\n\n// 工具函数\nconst isValidUrl = (url: string): boolean => {\n  try {\n    new URL(url)\n    return true\n  } catch {\n    return false\n  }\n}\n\nconst maskApiKey = (key: string): string => {\n  if (key.length <= 10) return key.slice(0, 3) + '***' + key.slice(-2)\n  return key.slice(0, 8) + '***' + key.slice(-5)\n}\n\n// 表单操作\nconst resetForm = () => {\n  form.name = ''\n  form.serviceType = ''\n  form.baseUrl = ''\n  form.baseUrls = []\n  form.website = ''\n  form.insecureSkipVerify = false\n  form.lowQuality = false\n  form.injectDummyThoughtSignature = false\n  form.stripThoughtSignature = false\n  form.description = ''\n  form.apiKeys = []\n  form.modelMapping = {}\n  newApiKey.value = ''\n  newMapping.source = ''\n  newMapping.target = ''\n\n  // 重置 baseUrlsText\n  baseUrlsText.value = ''\n\n  // 清空原始密钥映射\n  originalKeyMap.value.clear()\n\n  // 清空密钥错误状态\n  apiKeyError.value = ''\n  duplicateKeyIndex.value = -1\n\n  // 清空模型缓存和状态\n  targetModelOptions.value = []\n  fetchingModels.value = false\n  fetchModelsError.value = ''\n  keyModelsStatus.value.clear()\n  hasTriedFetchModels.value = false\n\n  // 清除错误信息\n  errors.name = ''\n  errors.serviceType = ''\n  errors.baseUrl = ''\n\n  // 重置快速添加模式数据\n  quickInput.value = ''\n  detectedBaseUrl.value = ''\n  detectedApiKeys.value = []\n  detectedServiceType.value = null\n  randomSuffix.value = generateRandomString(6)\n}\n\nconst loadChannelData = (channel: Channel) => {\n  form.name = channel.name\n  form.serviceType = channel.serviceType\n  form.baseUrl = channel.baseUrl\n  form.baseUrls = channel.baseUrls || []\n  form.website = channel.website || ''\n  form.insecureSkipVerify = !!channel.insecureSkipVerify\n  form.lowQuality = !!channel.lowQuality\n  form.injectDummyThoughtSignature = !!channel.injectDummyThoughtSignature\n  form.stripThoughtSignature = !!channel.stripThoughtSignature\n  form.description = channel.description || ''\n\n  // 同步 baseUrlsText（优先使用 baseUrls，否则使用 baseUrl）\n  if (channel.baseUrls && channel.baseUrls.length > 0) {\n    baseUrlsText.value = channel.baseUrls.join('\\n')\n  } else {\n    baseUrlsText.value = channel.baseUrl || ''\n  }\n\n  // 直接存储原始密钥，不需要映射关系\n  form.apiKeys = [...channel.apiKeys]\n\n  // 清空原始密钥映射（现在不需要了）\n  originalKeyMap.value.clear()\n\n  form.modelMapping = { ...(channel.modelMapping || {}) }\n\n  // 立即同步 baseUrl 到预览变量，避免等待 debounce\n  formBaseUrlPreview.value = channel.baseUrl\n\n  // 清空模型映射输入框\n  newMapping.source = ''\n  newMapping.target = ''\n\n  // 清空模型缓存和状态（切换渠道时重置）\n  targetModelOptions.value = []\n  fetchingModels.value = false\n  fetchModelsError.value = ''\n  keyModelsStatus.value.clear()\n  hasTriedFetchModels.value = false\n}\n\nconst addApiKey = () => {\n  const key = newApiKey.value.trim()\n  if (!key) return\n\n  // 重置错误状态\n  apiKeyError.value = ''\n  duplicateKeyIndex.value = -1\n\n  // 检查是否与现有密钥重复\n  const duplicateIndex = findDuplicateKeyIndex(key)\n  if (duplicateIndex !== -1) {\n    apiKeyError.value = '该密钥已存在'\n    duplicateKeyIndex.value = duplicateIndex\n    // 清除输入框，让用户重新输入\n    newApiKey.value = ''\n    return\n  }\n\n  // 直接存储原始密钥\n  form.apiKeys.push(key)\n  newApiKey.value = ''\n}\n\n// 检查密钥是否重复，返回重复密钥的索引，如果没有重复返回-1\nconst findDuplicateKeyIndex = (newKey: string): number => {\n  return form.apiKeys.findIndex(existingKey => existingKey === newKey)\n}\n\nconst removeApiKey = (index: number) => {\n  form.apiKeys.splice(index, 1)\n\n  // 如果删除的是当前高亮的重复密钥，清除高亮状态\n  if (duplicateKeyIndex.value === index) {\n    duplicateKeyIndex.value = -1\n    apiKeyError.value = ''\n  } else if (duplicateKeyIndex.value > index) {\n    // 如果删除的密钥在高亮密钥之前，调整高亮索引\n    duplicateKeyIndex.value--\n  }\n}\n\n// 将指定密钥移到最上方\nconst moveApiKeyToTop = (index: number) => {\n  if (index <= 0 || index >= form.apiKeys.length) return\n  const [key] = form.apiKeys.splice(index, 1)\n  form.apiKeys.unshift(key)\n  duplicateKeyIndex.value = -1\n  copiedKeyIndex.value = null\n}\n\n// 将指定密钥移到最下方\nconst moveApiKeyToBottom = (index: number) => {\n  if (index < 0 || index >= form.apiKeys.length - 1) return\n  const [key] = form.apiKeys.splice(index, 1)\n  form.apiKeys.push(key)\n  duplicateKeyIndex.value = -1\n  copiedKeyIndex.value = null\n}\n\n// 复制API密钥到剪贴板\nconst copyApiKey = async (key: string, index: number) => {\n  try {\n    await navigator.clipboard.writeText(key)\n    copiedKeyIndex.value = index\n\n    // 2秒后重置复制状态\n    setTimeout(() => {\n      copiedKeyIndex.value = null\n    }, 2000)\n  } catch (err) {\n    console.error('复制密钥失败:', err)\n    // 降级方案：使用传统的复制方法\n    const textArea = document.createElement('textarea')\n    textArea.value = key\n    textArea.style.position = 'fixed'\n    textArea.style.left = '-999999px'\n    textArea.style.top = '-999999px'\n    document.body.appendChild(textArea)\n    textArea.focus()\n    textArea.select()\n\n    try {\n      document.execCommand('copy')\n      copiedKeyIndex.value = index\n\n      setTimeout(() => {\n        copiedKeyIndex.value = null\n      }, 2000)\n    } catch (err) {\n      console.error('降级复制方案也失败:', err)\n    } finally {\n      textArea.remove()\n    }\n  }\n}\n\nconst addModelMapping = () => {\n  // 安全地获取字符串值（处理 v-select/v-combobox 可能返回对象的情况）\n  const getStringValue = (val: string | { title: string; value: string } | null | undefined): string => {\n    if (!val) return ''\n    if (typeof val === 'string') return val\n    return val.value || ''\n  }\n\n  const source = getStringValue(newMapping.source).trim()\n  const target = getStringValue(newMapping.target).trim()\n\n  if (source && target && !form.modelMapping[source]) {\n    form.modelMapping[source] = target\n    newMapping.source = ''\n    newMapping.target = ''\n  }\n}\n\nconst removeModelMapping = (source: string) => {\n  delete form.modelMapping[source]\n}\n\n// 处理目标模型输入框点击事件(仅在首次或有新 key 时触发请求)\nconst handleTargetModelClick = () => {\n  // 如果已经尝试过获取且正在加载中,不重复触发\n  if (hasTriedFetchModels.value || fetchingModels.value) {\n    return\n  }\n\n  // 标记已尝试获取\n  hasTriedFetchModels.value = true\n\n  // 调用获取模型列表(内部有缓存逻辑)\n  fetchTargetModels()\n}\n\nconst fetchTargetModels = async () => {\n  if (!form.baseUrl || form.apiKeys.length === 0) {\n    fetchModelsError.value = '请先填写 Base URL 和至少一个 API Key'\n    return\n  }\n\n  // 如果已经有模型列表且所有 key 都已检测过,直接返回(缓存)\n  if (targetModelOptions.value.length > 0) {\n    const allKeysChecked = form.apiKeys.every(key => keyModelsStatus.value.has(key))\n    if (allKeysChecked) {\n      return\n    }\n  }\n\n  fetchingModels.value = true\n  fetchModelsError.value = ''\n\n  // 仅为未检测过的 API Key 发起请求\n  const uncheckedKeys = form.apiKeys.filter(key => !keyModelsStatus.value.has(key))\n\n  if (uncheckedKeys.length === 0) {\n    fetchingModels.value = false\n    return\n  }\n\n  // 为每个未检测的 API Key 检测 models 状态\n  const keyPromises = uncheckedKeys.map(async (apiKey) => {\n    keyModelsStatus.value.set(apiKey, { loading: true, success: false })\n\n    try {\n      const response = await fetchUpstreamModels(form.baseUrl, apiKey)\n\n      keyModelsStatus.value.set(apiKey, {\n        loading: false,\n        success: true,\n        statusCode: 200,\n        modelCount: response.data.length\n      })\n\n      return response.data\n    } catch (error) {\n      let errorMsg = '未知错误'\n      let statusCode = 0\n\n      if (error instanceof ApiError) {\n        errorMsg = error.message\n        statusCode = error.status\n      } else if (error instanceof Error) {\n        errorMsg = error.message\n      }\n\n      keyModelsStatus.value.set(apiKey, {\n        loading: false,\n        success: false,\n        statusCode,\n        error: errorMsg\n      })\n\n      return []\n    }\n  })\n\n  try {\n    const results = await Promise.all(keyPromises)\n\n    // 合并新获取的模型列表到现有列表\n    const allModels = new Set<string>(targetModelOptions.value.map(opt => opt.value))\n    results.forEach(models => {\n      models.forEach(m => allModels.add(m.id))\n    })\n\n    targetModelOptions.value = Array.from(allModels)\n      .sort()\n      .map(id => ({ title: id, value: id }))\n\n    // 如果所有 key 都失败了,显示错误\n    const allFailed = form.apiKeys.every(key => {\n      const status = keyModelsStatus.value.get(key)\n      return status && !status.success\n    })\n\n    if (allFailed) {\n      fetchModelsError.value = '所有 API Key 都无法获取模型列表,请检查 API 密钥列表中的错误信息'\n    }\n  } finally {\n    fetchingModels.value = false\n  }\n}\n\nconst handleSubmit = async () => {\n  if (!formRef.value) return\n\n  const { valid } = await formRef.value.validate()\n  if (!valid) return\n\n  // 直接使用原始密钥，不需要转换\n  const processedApiKeys = form.apiKeys.filter(key => key.trim())\n\n  // 处理 BaseURL：去重（忽略末尾 / 和 # 差异），并移除 UI 专用的尾部 #\n  const seenUrls = new Set<string>()\n  const deduplicatedUrls =\n    form.baseUrls.length > 0\n      ? form.baseUrls\n          .map(url => url.trim().replace(/[#/]+$/, ''))\n          .filter(Boolean)\n          .filter(url => {\n            const normalized = url.replace(/[#/]+$/, '')\n            if (seenUrls.has(normalized)) return false\n            seenUrls.add(normalized)\n            return true\n          })\n      : [form.baseUrl.trim().replace(/[#/]+$/, '')].filter(Boolean)\n\n  // 构建渠道数据\n  const channelData: Omit<Channel, 'index' | 'latency' | 'status'> = {\n    name: form.name.trim(),\n    serviceType: form.serviceType as 'openai' | 'gemini' | 'claude' | 'responses',\n    baseUrl: deduplicatedUrls[0] || '',\n    website: form.website.trim(), // 空字符串也需要传递，以便清除已有值\n    insecureSkipVerify: form.insecureSkipVerify,\n    lowQuality: form.lowQuality,\n    injectDummyThoughtSignature: form.injectDummyThoughtSignature,\n    stripThoughtSignature: form.stripThoughtSignature,\n    description: form.description.trim(),\n    apiKeys: processedApiKeys,\n    modelMapping: form.modelMapping\n  }\n\n  // 多 BaseURL 支持\n  if (deduplicatedUrls.length > 1) {\n    channelData.baseUrls = deduplicatedUrls\n  }\n\n  emit('save', channelData)\n}\n\nconst handleCancel = () => {\n  emit('update:show', false)\n  resetForm()\n}\n\n// 监听props变化\nwatch(\n  () => props.show,\n  newShow => {\n    if (newShow) {\n      // 无论是编辑还是新增，都先清理密钥错误状态\n      apiKeyError.value = ''\n      duplicateKeyIndex.value = -1\n\n      if (props.channel) {\n        // 编辑模式：使用表单模式\n        isQuickMode.value = false\n        loadChannelData(props.channel)\n      } else {\n        // 添加模式：默认使用快速模式\n        isQuickMode.value = true\n        resetForm()\n      }\n    }\n  }\n)\n\nwatch(\n  () => props.channel,\n  newChannel => {\n    if (newChannel && props.show) {\n      loadChannelData(newChannel)\n    }\n  }\n)\n\nwatch(\n  () => form.baseUrl,\n  value => {\n    if (formBaseUrlPreviewTimer !== null) {\n      window.clearTimeout(formBaseUrlPreviewTimer)\n    }\n    formBaseUrlPreviewTimer = window.setTimeout(() => {\n      formBaseUrlPreview.value = value\n    }, 200)\n  },\n  { immediate: true }\n)\n\n// ESC键监听\nconst handleKeydown = (event: Event) => {\n  const keyboardEvent = event as KeyboardEvent\n  if (keyboardEvent.key === 'Escape' && props.show) {\n    handleCancel()\n  }\n}\n\nonMounted(() => {\n  document.addEventListener('keydown', handleKeydown)\n})\n\nonUnmounted(() => {\n  document.removeEventListener('keydown', handleKeydown)\n  if (formBaseUrlPreviewTimer !== null) {\n    window.clearTimeout(formBaseUrlPreviewTimer)\n  }\n})\n</script>\n\n<style scoped>\n/* 基础URL下方的提示区域 - 固定高度防止布局跳动 */\n.base-url-hint {\n  min-height: 20px;\n  padding: 4px 12px 8px;\n  line-height: 1.25;\n}\n\n/* 多个预期请求项样式 */\n.expected-request-item + .expected-request-item {\n  margin-top: 2px;\n}\n\n/* 浅色模式下副标题使用白色带透明度 */\n.text-white-subtitle {\n  color: rgba(255, 255, 255, 0.85) !important;\n}\n\n.animate-pulse {\n  animation: pulse 1.5s ease-in-out infinite;\n}\n\n@keyframes pulse {\n  0%,\n  100% {\n    opacity: 1;\n  }\n  50% {\n    opacity: 0.7;\n  }\n}\n\n:deep(.key-tooltip) {\n  color: rgba(var(--v-theme-on-surface), 0.92);\n  background-color: rgba(var(--v-theme-surface), 0.98);\n  border: 1px solid rgba(var(--v-theme-primary), 0.45);\n  font-weight: 600;\n  letter-spacing: 0.2px;\n  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);\n}\n\n/* 快速添加模式样式 */\n.quick-input-textarea :deep(textarea) {\n  font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;\n  font-size: 13px;\n  line-height: 1.6;\n}\n\n.detection-status-card {\n  background: rgba(var(--v-theme-surface-variant), 0.3);\n}\n\n/* 多 Base URL 项目样式 */\n.base-url-item {\n  padding: 6px 10px;\n  background: rgba(var(--v-theme-surface-variant), 0.4);\n  border-radius: 6px;\n  border-left: 2px solid rgb(var(--v-theme-success));\n}\n\n.base-url-item + .base-url-item {\n  margin-top: 4px;\n}\n\n.mode-toggle-btn {\n  text-transform: none;\n}\n\n/* 亮色模式下按钮在 primary 背景上显示白色 */\n.bg-primary .mode-toggle-btn {\n  color: white !important;\n  border-color: rgba(255, 255, 255, 0.7) !important;\n}\n\n.bg-primary .mode-toggle-btn:hover {\n  background-color: rgba(255, 255, 255, 0.15) !important;\n  border-color: white !important;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/ChannelCard.vue",
    "content": "<template>\n  <v-card\n    class=\"channel-card h-100\"\n    :style=\"serviceStyle\"\n    :data-pinned=\"channel.pinned\"\n    elevation=\"0\"\n    rounded=\"xl\"\n    hover\n  >\n    <!-- 渐变头部背景 -->\n    <div class=\"card-header-gradient\">\n      <v-card-title class=\"d-flex align-center justify-space-between pa-4 pb-3 position-relative\">\n        <div class=\"d-flex align-center ga-3\">\n          <!-- 服务类型图标 -->\n          <div class=\"service-icon-wrapper\">\n            <v-icon \n              :color=\"getServiceIconColor()\"\n              size=\"24\"\n            >\n              {{ getServiceIcon() }}\n            </v-icon>\n          </div>\n          <div class=\"d-flex align-center ga-2\">\n            <div>\n              <div class=\"text-h6 font-weight-bold channel-title\">\n                {{ channel.name }}\n              </div>\n              <div class=\"text-caption text-high-emphasis opacity-80\">\n                {{ getServiceDisplayName() }}\n              </div>\n            </div>\n            <!-- 官网图标按钮（紧贴标题右侧） -->\n            <v-tooltip v-if=\"channel.website\" text=\"打开官网\" location=\"bottom\" :open-delay=\"150\">\n              <template #activator=\"{ props: tooltipProps }\">\n                <v-btn v-bind=\"tooltipProps\" :href=\"channel.website\" target=\"_blank\" rel=\"noopener\" size=\"small\" variant=\"text\" color=\"primary\" icon>\n                  <v-icon size=\"18\">mdi-open-in-new</v-icon>\n                </v-btn>\n              </template>\n            </v-tooltip>\n          </div>\n        </div>\n        \n        <div class=\"d-flex align-center ga-2\">\n          <!-- Pin 按钮 -->\n          <v-btn\n            size=\"small\"\n            :variant=\"channel.pinned ? 'tonal' : 'text'\"\n            :color=\"channel.pinned ? 'warning' : 'grey'\"\n            class=\"pin-btn\"\n            rounded=\"lg\"\n            @click=\"$emit('togglePin', channel.index)\"\n          >\n            <v-icon size=\"16\">\n              {{ channel.pinned ? 'mdi-pin' : 'mdi-pin-outline' }}\n            </v-icon>\n          </v-btn>\n\n          <v-chip\n            :color=\"getServiceChipColor()\"\n            size=\"small\"\n            variant=\"tonal\"\n            density=\"comfortable\"\n            rounded=\"pill\"\n            class=\"service-chip\"\n          >\n            <span class=\"font-weight-bold\">{{ channel.serviceType.toUpperCase() }}</span>\n          </v-chip>\n          <!-- 渠道状态芯片 -->\n          <v-chip\n            v-if=\"channel.status === 'disabled'\"\n            color=\"grey\"\n            size=\"small\"\n            variant=\"flat\"\n            density=\"comfortable\"\n            rounded=\"lg\"\n          >\n            <v-icon start size=\"small\">mdi-stop-circle</v-icon>\n            停用\n          </v-chip>\n          <v-chip\n            v-else-if=\"channel.status === 'suspended'\"\n            color=\"warning\"\n            size=\"small\"\n            variant=\"flat\"\n            density=\"comfortable\"\n            rounded=\"lg\"\n          >\n            <v-icon start size=\"small\">mdi-pause-circle</v-icon>\n            熔断\n          </v-chip>\n        </div>\n      </v-card-title>\n    </div>\n\n    <v-card-text class=\"px-4 py-2\">\n      <!-- 描述 -->\n      <div v-if=\"channel.description\" class=\"text-body-2 text-medium-emphasis mb-3\">\n        {{ channel.description }}\n      </div>\n\n      <!-- 基本信息 -->\n      <div class=\"mb-4\">\n        <div class=\"d-flex align-center ga-2 mb-2\">\n          <v-icon size=\"16\" color=\"medium-emphasis\">mdi-web</v-icon>\n          <span class=\"text-body-2 font-weight-medium\">Base URL:</span>\n          <div class=\"flex-1-1 text-truncate\">\n            <code class=\"text-caption bg-surface pa-1 rounded\">{{ channel.baseUrl }}</code>\n          </div>\n        </div>\n        \n      </div>\n\n      <!-- 状态和延迟（右对齐、间距更紧凑） -->\n      <div class=\"d-flex align-center justify-end ga-4 mb-4\">\n        <div class=\"status-indicator\">\n          <v-tooltip :text=\"getStatusTooltip()\" location=\"bottom\" :open-delay=\"150\">\n            <template #activator=\"{ props: tooltipProps }\">\n              <div class=\"status-badge cursor-help\" v-bind=\"tooltipProps\" :class=\"`status-${channel.status || 'unknown'}`\">\n                <v-icon \n                  :color=\"getStatusColor()\"\n                  size=\"16\"\n                  class=\"status-icon\"\n                >\n                  {{ getStatusIcon() }}\n                </v-icon>\n                <span class=\"status-text\">{{ getStatusText() }}</span>\n              </div>\n            </template>\n          </v-tooltip>\n        </div>\n        <div v-if=\"channel.latency !== null\" class=\"latency-indicator\">\n          <div class=\"latency-badge\" :class=\"`latency-${getLatencyLevel()}`\">\n            <v-icon size=\"14\" class=\"latency-icon\">mdi-speedometer</v-icon>\n            <span class=\"latency-text\">{{ channel.latency }}ms</span>\n          </div>\n        </div>\n      </div>\n\n      <!-- API密钥管理 -->\n      <v-expansion-panels variant=\"accordion\" rounded=\"lg\" class=\"mb-4\">\n        <v-expansion-panel>\n          <v-expansion-panel-title>\n            <div class=\"d-flex align-center justify-space-between w-100\">\n              <div class=\"d-flex align-center ga-2\">\n                <v-icon size=\"small\">mdi-key-chain</v-icon>\n                <span class=\"text-body-2 font-weight-medium\">API密钥管理</span>\n              </div>\n              <v-chip\n                :color=\"channel.apiKeys.length ? 'secondary' : 'warning'\"\n                size=\"large\"\n                variant=\"tonal\"\n                density=\"comfortable\"\n                rounded=\"lg\"\n                class=\"mr-2 key-count-chip\"\n                :style=\"keyChipStyle\"\n              >\n                <v-icon start size=\"18\">mdi-key</v-icon>\n                {{ channel.apiKeys.length }}\n              </v-chip>\n            </div>\n          </v-expansion-panel-title>\n          <v-expansion-panel-text>\n            <div class=\"d-flex align-center justify-space-between mb-3\">\n              <span class=\"text-body-2 font-weight-medium\">已配置的密钥</span>\n              <v-btn\n                size=\"small\"\n                color=\"primary\"\n                icon\n                variant=\"elevated\"\n                rounded=\"lg\"\n                @click=\"$emit('addKey', channel.index)\"\n              >\n                <v-icon>mdi-plus</v-icon>\n              </v-btn>\n            </div>\n            \n            <div v-if=\"channel.apiKeys.length\" class=\"d-flex flex-column ga-2\" style=\"max-height: 150px; overflow-y: auto;\">\n              <div\n                v-for=\"(key, index) in channel.apiKeys\"\n                :key=\"index\"\n                class=\"d-flex align-center justify-space-between pa-2 bg-surface rounded\"\n              >\n                <code class=\"text-caption flex-1-1 text-truncate mr-2\">{{ maskApiKey(key) }}</code>\n                <div class=\"d-flex align-center ga-1\">\n                  <!-- 置顶按钮：仅最后一个 key 显示 -->\n                  <v-tooltip v-if=\"index === channel.apiKeys.length - 1 && channel.apiKeys.length > 1\" text=\"置顶\" location=\"top\" :open-delay=\"150\">\n                    <template #activator=\"{ props: tooltipProps }\">\n                      <v-btn v-bind=\"tooltipProps\" size=\"x-small\" color=\"warning\" icon variant=\"text\" rounded=\"md\" @click=\"$emit('moveKeyToTop', channel.index, key)\">\n                        <v-icon size=\"small\">mdi-arrow-up-bold</v-icon>\n                      </v-btn>\n                    </template>\n                  </v-tooltip>\n                  <!-- 置底按钮：仅第一个 key 显示 -->\n                  <v-tooltip v-if=\"index === 0 && channel.apiKeys.length > 1\" text=\"置底\" location=\"top\" :open-delay=\"150\">\n                    <template #activator=\"{ props: tooltipProps }\">\n                      <v-btn v-bind=\"tooltipProps\" size=\"x-small\" color=\"warning\" icon variant=\"text\" rounded=\"md\" @click=\"$emit('moveKeyToBottom', channel.index, key)\">\n                        <v-icon size=\"small\">mdi-arrow-down-bold</v-icon>\n                      </v-btn>\n                    </template>\n                  </v-tooltip>\n                  <v-tooltip :text=\"copiedKeyIndex === index ? '已复制!' : '复制密钥'\" location=\"top\" :open-delay=\"150\">\n                    <template #activator=\"{ props: tooltipProps }\">\n                      <v-btn\n                        v-bind=\"tooltipProps\"\n                        size=\"x-small\"\n                        :color=\"copiedKeyIndex === index ? 'success' : 'primary'\"\n                        icon\n                        variant=\"text\"\n                        rounded=\"md\"\n                        @click=\"copyApiKey(key, index)\"\n                      >\n                        <v-icon size=\"small\">{{ copiedKeyIndex === index ? 'mdi-check' : 'mdi-content-copy' }}</v-icon>\n                      </v-btn>\n                    </template>\n                  </v-tooltip>\n                  <v-btn\n                    size=\"x-small\"\n                    color=\"error\"\n                    icon\n                    variant=\"text\"\n                    rounded=\"md\"\n                    @click=\"$emit('removeKey', channel.index, getOriginalKey(key))\"\n                  >\n                    <v-icon size=\"small\">mdi-close</v-icon>\n                  </v-btn>\n                </div>\n              </div>\n            </div>\n            \n            <div v-else class=\"text-center py-4\">\n              <span class=\"text-body-2 text-medium-emphasis\">暂无API密钥</span>\n            </div>\n          </v-expansion-panel-text>\n        </v-expansion-panel>\n      </v-expansion-panels>\n\n      <!-- 操作按钮 -->\n      <div class=\"action-buttons d-flex flex-wrap ga-2 justify-end w-100\">\n        <v-btn\n          size=\"small\"\n          color=\"primary\"\n          variant=\"outlined\"\n          rounded=\"lg\"\n          class=\"action-btn\"\n          prepend-icon=\"mdi-speedometer\"\n          @click=\"$emit('ping', channel.index)\"\n        >\n          测试延迟\n        </v-btn>\n        \n        <v-btn\n          size=\"small\"\n          color=\"info\"\n          variant=\"outlined\"\n          rounded=\"lg\"\n          class=\"action-btn\"\n          prepend-icon=\"mdi-pencil\"\n          @click=\"$emit('edit', channel)\"\n        >\n          编辑\n        </v-btn>\n        \n        <v-btn\n          size=\"small\"\n          color=\"error\"\n          variant=\"text\"\n          rounded=\"lg\"\n          class=\"action-btn danger-action\"\n          prepend-icon=\"mdi-delete\"\n          @click=\"$emit('delete', channel.index)\"\n        >\n          删除\n        </v-btn>\n      </div>\n    </v-card-text>\n  </v-card>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed, ref } from 'vue'\nimport type { Channel } from '../services/api'\n\ninterface Props {\n  channel: Channel\n}\n\nconst props = defineProps<Props>()\n\n// 复制功能相关状态\nconst copiedKeyIndex = ref<number | null>(null)\n\ndefineEmits<{\n  edit: [channel: Channel]\n  delete: [channelId: number]\n  addKey: [channelId: number]\n  removeKey: [channelId: number, apiKey: string]\n  moveKeyToTop: [channelId: number, apiKey: string]\n  moveKeyToBottom: [channelId: number, apiKey: string]\n  ping: [channelId: number]\n  togglePin: [channelId: number]\n}>()\n\n// 获取服务类型对应的芯片颜色\nconst getServiceChipColor = () => {\n  const colorMap: Record<string, string> = {\n    openai: 'info',\n    claude: 'success',\n    gemini: 'accent'\n  }\n  return colorMap[props.channel.serviceType] || 'primary'\n}\n\n// 获取状态对应的颜色\nconst getStatusColor = () => {\n  const colorMap: Record<string, string> = {\n    'healthy': 'success',\n    'error': 'error',\n    'unknown': 'warning'\n  }\n  return colorMap[props.channel.status || 'unknown']\n}\n\n// 获取状态图标\nconst getStatusIcon = () => {\n  const iconMap: Record<string, string> = {\n    'healthy': 'mdi-check-circle',\n    'error': 'mdi-alert-circle',\n    'unknown': 'mdi-help-circle'\n  }\n  return iconMap[props.channel.status || 'unknown']\n}\n\n// 获取状态文本\nconst getStatusText = () => {\n  const textMap: Record<string, string> = {\n    'healthy': '健康',\n    'error': '错误',\n    'unknown': '未检测'\n  }\n  return textMap[props.channel.status || 'unknown']\n}\n\n// 状态解释文案（悬浮提示）\nconst getStatusTooltip = () => {\n  const status = props.channel.status || 'unknown'\n  if (status === 'healthy') return '连接正常：最近一次检测通过'\n  if (status === 'error') return '连接异常：请检查基础 URL、网络或 API 密钥'\n  return '尚未检测：请点击“测试延迟”进行检测'\n}\n\n// 掩码API密钥用于显示\nconst maskApiKey = (key: string): string => {\n  if (key.length <= 10) return key.slice(0, 3) + '***' + key.slice(-2)\n  return key.slice(0, 8) + '***' + key.slice(-5)\n}\n\n// 获取原始密钥（用于删除操作），现在直接传递原始密钥\nconst getOriginalKey = (originalKey: string) => {\n  return originalKey\n}\n\n// 复制API密钥到剪贴板\nconst copyApiKey = async (key: string, index: number) => {\n  try {\n    await navigator.clipboard.writeText(key)\n    copiedKeyIndex.value = index\n\n    // 2秒后重置复制状态\n    setTimeout(() => {\n      copiedKeyIndex.value = null\n    }, 2000)\n  } catch (err) {\n    console.error('复制密钥失败:', err)\n    // 降级方案：使用传统的复制方法\n    const textArea = document.createElement('textarea')\n    textArea.value = key\n    textArea.style.position = 'fixed'\n    textArea.style.left = '-999999px'\n    textArea.style.top = '-999999px'\n    document.body.appendChild(textArea)\n    textArea.focus()\n    textArea.select()\n\n    try {\n      document.execCommand('copy')\n      copiedKeyIndex.value = index\n\n      setTimeout(() => {\n        copiedKeyIndex.value = null\n      }, 2000)\n    } catch (err) {\n      console.error('降级复制方案也失败:', err)\n    } finally {\n      textArea.remove()\n    }\n  }\n}\n\n// 获取服务类型图标\nconst getServiceIcon = () => {\n  const iconMap: Record<string, string> = {\n    'openai': 'mdi-robot',\n    'claude': 'mdi-message-processing',\n    'gemini': 'mdi-diamond-stone'\n  }\n  return iconMap[props.channel.serviceType] || 'mdi-api'\n}\n\n// 获取服务类型图标颜色\nconst getServiceIconColor = () => {\n  const colorMap: Record<string, string> = {\n    'openai': 'primary',\n    'claude': 'orange',\n    'gemini': 'purple'\n  }\n  return colorMap[props.channel.serviceType] || 'grey'\n}\n\n// 获取服务类型显示名称\nconst getServiceDisplayName = () => {\n  const nameMap: Record<string, string> = {\n    'openai': 'OpenAI API',\n    'claude': 'Claude API',\n    'gemini': 'Gemini API'\n  }\n  return nameMap[props.channel.serviceType] || 'Custom API'\n}\n\n// 获取延迟等级\nconst getLatencyLevel = () => {\n  if (!props.channel.latency) return 'unknown'\n  \n  if (props.channel.latency < 200) return 'excellent'\n  if (props.channel.latency < 500) return 'good'\n  if (props.channel.latency < 1000) return 'fair'\n  return 'poor'\n}\n\n// Chip 动态文本颜色，避免在浅色背景上出现近黑文本\nconst keyChipStyle = computed(() => {\n  const hasKeys = props.channel.apiKeys.length > 0\n  return {\n    color: hasKeys ? 'rgb(var(--v-theme-on-secondary))' : 'rgb(var(--v-theme-on-warning))',\n    fontSize: '0.95rem'\n  }\n})\n\n// 根据服务类型设置卡片强调色（明暗模式自动随主题变量变更）\nconst serviceStyle = computed(() => {\n  const map: Record<string, string> = {\n    openai: 'var(--v-theme-info)',\n    claude: 'var(--v-theme-success)',\n    gemini: 'var(--v-theme-accent)'\n  }\n  const value = map[props.channel.serviceType] || 'var(--v-theme-primary)'\n  return {\n    '--card-accent-rgb': value\n  } as Record<string, string>\n})\n</script>\n\n<style scoped>\n/* --- BASE STYLES (LIGHT MODE) --- */\n.channel-card {\n  transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n  position: relative;\n  overflow: hidden;\n  /* 类型底色：在 surface 上叠加轻度同色系着色 */\n  background: linear-gradient(\n    0deg,\n    rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.06),\n    rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.06)\n  ), rgb(var(--v-theme-surface));\n  border: 1px solid rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.28);\n  box-shadow: \n    0 4px 16px rgba(0, 0, 0, 0.05),\n    0 1px 4px rgba(0, 0, 0, 0.02);\n  border-radius: 16px;\n}\n\n/* 左侧彩色强调条，突出渠道类型颜色 */\n.channel-card::before {\n  content: '';\n  position: absolute;\n  left: 0;\n  top: 0;\n  bottom: 0;\n  width: 6px;\n  background: linear-gradient(\n    to bottom,\n    rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.9),\n    rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.5)\n  );\n}\n\n.channel-card:not(:hover) {\n  /* default state */\n}\n\n.channel-card:hover {\n  transform: translateY(-4px) scale(1.01);\n  box-shadow: \n    0 16px 32px rgba(0, 0, 0, 0.08),\n    0 6px 18px rgba(0, 0, 0, 0.05);\n  border-color: rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.45);\n}\n\n.card-header-gradient {\n  background: linear-gradient(135deg,\n    rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.20) 0%,\n    rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.10) 50%,\n    rgba(var(--v-theme-accent), 0.12) 100%);\n  position: relative;\n  border-top-left-radius: inherit;\n  border-top-right-radius: inherit;\n}\n\n.service-icon-wrapper {\n  width: 48px;\n  height: 48px;\n  border-radius: 12px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: linear-gradient(135deg,\n    rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.18) 0%,\n    rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.10) 100%);\n  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);\n  border: 1px solid rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.25);\n  transition: all 0.3s ease;\n}\n\n.channel-card:hover .service-icon-wrapper {\n  transform: scale(1.1);\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);\n}\n\n.service-chip {\n  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);\n  border: none;\n}\n\n/* --- INDICATORS (LIGHT) --- */\n.status-badge, .latency-badge {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  padding: 4px 8px;\n  border-radius: 8px;\n  font-size: 0.75rem;\n  font-weight: 500;\n}\n\n.status-badge {\n  background-color: rgba(0, 0, 0, 0.05);\n}\n.status-badge.status-healthy { color: rgb(var(--v-theme-success)); background-color: rgba(var(--v-theme-success), 0.12); }\n.status-badge.status-error { color: rgb(var(--v-theme-error)); background-color: rgba(var(--v-theme-error), 0.12); }\n.status-badge.status-unknown { color: rgb(var(--v-theme-secondary)); background-color: rgba(var(--v-theme-secondary), 0.12); }\n\n.latency-badge {\n  font-weight: 600;\n}\n.latency-badge.latency-excellent { color: #2e7d32; background: rgba(76, 175, 80, 0.1); }\n.latency-badge.latency-good { color: #f57c00; background: rgba(255, 193, 7, 0.1); }\n.latency-badge.latency-fair { color: #e65100; background: rgba(255, 152, 0, 0.1); }\n.latency-badge.latency-poor { color: #c62828; background: rgba(244, 67, 54, 0.1); }\n\n/* --- PIN BUTTON (LIGHT) --- */\n.pin-btn {\n  min-width: 32px !important; width: 32px; height: 32px;\n  border-radius: 12px !important;\n  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.pin-btn:hover {\n  transform: scale(1.1);\n}\n\n.key-count-chip {\n  font-weight: 700;\n}\n\n/* --- KEYFRAMES --- */\n@keyframes shimmer {\n  0% { transform: translateX(-100%); }\n  100% { transform: translateX(100%); }\n}\n\n@keyframes slideInUp {\n  from { opacity: 0; transform: translateY(30px); }\n  to { opacity: 1; transform: translateY(0); }\n}\n\n.channel-card {\n  animation: slideInUp 0.6s ease-out;\n}\n\n/* \n██████╗ ██╗  ██╗██████╗  ██╗  ██╗\n██╔══██╗██║  ██║██╔══██╗██║ ██╔╝\n██║  ██║███████║██████╔╝█████╔╝ \n██║  ██║██╔══██║██╔══██╗██╔═██╗ \n██████╔╝██║  ██║██║  ██║██║  ██╗\n╚═════╝ ╚═╝  ╚═╝╚═╝  ╚═╝╚═╝  ╚═╝\n*/\n/* Prefer Vuetify theme class over media query to honor manual toggles */\n.v-theme--dark .channel-card {\n  /* 暗色下加深类型底色透明度，保证可见 */\n  background: linear-gradient(\n    0deg,\n    rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.12),\n    rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.12)\n  ), rgb(var(--v-theme-surface));\n  border: 1px solid rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.45);\n  box-shadow:\n    0 4px 24px rgba(0, 0, 0, 0.28),\n    0 1px 8px rgba(0, 0, 0, 0.18);\n}\n\n.v-theme--dark .channel-card:not(.current-channel):hover {\n  border-color: rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.65);\n  box-shadow:\n    0 20px 40px rgba(0, 0, 0, 0.36),\n    0 8px 24px rgba(0, 0, 0, 0.24);\n}\n\n.v-theme--dark .card-header-gradient {\n  background: linear-gradient(135deg,\n    rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.28) 0%,\n    rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.16) 50%,\n    rgba(156, 39, 176, 0.18) 100%);\n}\n\n.v-theme--dark .service-icon-wrapper {\n  background: linear-gradient(135deg,\n    rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.25) 0%,\n    rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.15) 100%);\n  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.24);\n  border: 1px solid rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.35);\n}\n\n.v-theme--dark .channel-card::before {\n  background: linear-gradient(\n    to bottom,\n    rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.95),\n    rgba(var(--card-accent-rgb, var(--v-theme-primary)), 0.6)\n  );\n}\n\n.v-theme--dark .channel-card:hover .service-icon-wrapper {\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.32);\n  border-color: rgba(255, 255, 255, 0.2);\n}\n\n.v-theme--dark .service-chip {\n  border: none;\n}\n\n/* --- INDICATORS (DARK) --- */\n.v-theme--dark .status-badge {\n  background-color: rgba(255, 255, 255, 0.1);\n}\n.v-theme--dark .status-badge.status-healthy { color: #b6e3be; background-color: rgba(52, 211, 153, 0.2); }\n.v-theme--dark .status-badge.status-error { color: #f4b4b4; background-color: rgba(248, 113, 113, 0.22); }\n.v-theme--dark .status-badge.status-unknown { color: #cbd5e1; background-color: rgba(148, 163, 184, 0.2); }\n\n.v-theme--dark .latency-badge.latency-excellent { color: #b6e3be; background: rgba(52, 211, 153, 0.25); }\n.v-theme--dark .latency-badge.latency-good { color: #fde68a; background: rgba(251, 191, 36, 0.22); }\n.v-theme--dark .latency-badge.latency-fair { color: #fcd49b; background: rgba(251, 146, 60, 0.25); }\n.v-theme--dark .latency-badge.latency-poor { color: #f4b4b4; background: rgba(248, 113, 113, 0.28); }\n</style>\n"
  },
  {
    "path": "frontend/src/components/ChannelMetricsChart.vue",
    "content": "<template>\n  <div class=\"channel-chart-container\">\n    <!-- Snackbar for error notification -->\n    <v-snackbar v-model=\"showError\" color=\"error\" :timeout=\"3000\" location=\"top\">\n      {{ errorMessage }}\n      <template #actions>\n        <v-btn variant=\"text\" @click=\"showError = false\">关闭</v-btn>\n      </template>\n    </v-snackbar>\n\n    <!-- 时间范围选择器 -->\n    <div class=\"chart-header d-flex align-center justify-space-between mb-3\">\n      <div class=\"d-flex align-center ga-2\">\n        <v-btn-toggle v-model=\"selectedDuration\" mandatory density=\"compact\" variant=\"outlined\" divided :disabled=\"isLoading\">\n          <v-btn value=\"1h\" size=\"x-small\">1小时</v-btn>\n          <v-btn value=\"6h\" size=\"x-small\">6小时</v-btn>\n          <v-btn value=\"24h\" size=\"x-small\">24小时</v-btn>\n        </v-btn-toggle>\n        <v-btn icon size=\"x-small\" variant=\"text\" :loading=\"isLoading\" :disabled=\"isLoading\" @click=\"refreshData\">\n          <v-icon size=\"small\">mdi-refresh</v-icon>\n        </v-btn>\n      </div>\n      <v-btn icon size=\"x-small\" variant=\"text\" title=\"收起\" @click=\"$emit('close')\">\n        <v-icon size=\"small\">mdi-chevron-up</v-icon>\n      </v-btn>\n    </div>\n\n    <!-- Loading state -->\n    <div v-if=\"isLoading\" class=\"d-flex justify-center align-center\" style=\"height: 150px\">\n      <v-progress-circular indeterminate size=\"24\" color=\"primary\" />\n    </div>\n\n    <!-- Empty state -->\n    <div v-else-if=\"!hasData\" class=\"d-flex flex-column justify-center align-center text-medium-emphasis\" style=\"height: 150px\">\n      <v-icon size=\"32\" color=\"grey-lighten-1\">mdi-chart-line-variant</v-icon>\n      <div class=\"text-caption mt-1\">选定时间范围内没有请求记录</div>\n    </div>\n\n    <!-- Charts -->\n    <div v-else class=\"charts-wrapper\">\n      <div class=\"chart-row\">\n        <!-- Request count chart -->\n        <div class=\"chart-item\">\n          <div class=\"text-caption text-medium-emphasis mb-1\">请求数量</div>\n          <apexchart\n            type=\"area\"\n            height=\"120\"\n            :options=\"requestCountOptions\"\n            :series=\"requestCountSeries\"\n          />\n        </div>\n\n        <!-- Success rate chart -->\n        <div class=\"chart-item\">\n          <div class=\"text-caption text-medium-emphasis mb-1\">成功率</div>\n          <apexchart\n            type=\"line\"\n            height=\"120\"\n            :options=\"successRateOptions\"\n            :series=\"successRateSeries\"\n          />\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, watch, onMounted } from 'vue'\nimport { useTheme } from 'vuetify'\nimport VueApexCharts from 'vue3-apexcharts'\nimport type { ApexOptions } from 'apexcharts'\nimport { api, type MetricsHistoryResponse } from '../services/api'\n\n// Register apexchart component\nconst apexchart = VueApexCharts\n\nconst props = defineProps<{\n  channelType: 'messages' | 'responses'\n  channelIndex: number  // 单渠道模式：指定渠道索引\n  channelName: string   // 渠道名称（用于图例）\n}>()\n\nconst _emit = defineEmits<{\n  (_e: 'close'): void\n}>()\n\nconst theme = useTheme()\n\n// State\nconst selectedDuration = ref<'1h' | '6h' | '24h'>('6h')\nconst isLoading = ref(false)\nconst historyData = ref<MetricsHistoryResponse | null>(null)\nconst showError = ref(false)\nconst errorMessage = ref('')\n\n// Computed: check if has data\nconst hasData = computed(() => {\n  if (!historyData.value) return false\n  return historyData.value.dataPoints &&\n    historyData.value.dataPoints.length > 0 &&\n    historyData.value.dataPoints.some(dp => dp.requestCount > 0)\n})\n\n// Computed: is dark mode\nconst isDark = computed(() => theme.global.current.value.dark)\n\n// Chart color - single channel uses primary color\nconst chartColor = '#2196F3'\n\n// Common chart options\nconst baseChartOptions = computed<ApexOptions>(() => ({\n  chart: {\n    toolbar: { show: false },\n    zoom: { enabled: false },\n    background: 'transparent',\n    fontFamily: 'inherit',\n    sparkline: { enabled: false }\n  },\n  theme: {\n    mode: isDark.value ? 'dark' : 'light'\n  },\n  grid: {\n    borderColor: isDark.value ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',\n    strokeDashArray: 3,\n    padding: { left: 10, right: 10 }\n  },\n  xaxis: {\n    type: 'datetime',\n    labels: {\n      datetimeUTC: false,\n      format: selectedDuration.value === '1h' ? 'HH:mm' : 'HH:mm',\n      style: { fontSize: '10px' }\n    },\n    axisBorder: { show: false },\n    axisTicks: { show: false }\n  },\n  yaxis: {\n    labels: {\n      style: { fontSize: '10px' }\n    }\n  },\n  tooltip: {\n    x: {\n      format: 'MM-dd HH:mm'\n    }\n  },\n  legend: {\n    show: false\n  },\n  stroke: {\n    curve: 'smooth' as const,\n    width: 2\n  }\n}))\n\n// Request count chart options\nconst requestCountOptions = computed<ApexOptions>(() => ({\n  ...baseChartOptions.value,\n  colors: [chartColor],\n  fill: {\n    type: 'gradient' as const,\n    gradient: {\n      shadeIntensity: 1,\n      opacityFrom: 0.4,\n      opacityTo: 0.1,\n      stops: [0, 90, 100]\n    }\n  },\n  yaxis: {\n    min: 0,\n    labels: {\n      formatter: (val: number) => Math.round(val).toString(),\n      style: { fontSize: '10px' }\n    }\n  },\n  dataLabels: {\n    enabled: false\n  }\n}))\n\n// Success rate chart options\nconst successRateOptions = computed<ApexOptions>(() => ({\n  ...baseChartOptions.value,\n  colors: ['#4CAF50'],\n  yaxis: {\n    min: 0,\n    max: 100,\n    labels: {\n      formatter: (val: number) => `${val.toFixed(0)}%`,\n      style: { fontSize: '10px' }\n    }\n  },\n  dataLabels: {\n    enabled: false\n  },\n  markers: {\n    size: 2,\n    hover: {\n      size: 4\n    }\n  }\n}))\n\n// Request count series data\nconst requestCountSeries = computed(() => {\n  if (!historyData.value || !historyData.value.dataPoints) return []\n  return [{\n    name: '请求数',\n    data: historyData.value.dataPoints.map(dp => ({\n      x: new Date(dp.timestamp).getTime(),\n      y: dp.requestCount\n    }))\n  }]\n})\n\n// Success rate series data\nconst successRateSeries = computed(() => {\n  if (!historyData.value || !historyData.value.dataPoints) return []\n  return [{\n    name: '成功率',\n    data: historyData.value.dataPoints\n      .filter(dp => dp.requestCount > 0)\n      .map(dp => ({\n        x: new Date(dp.timestamp).getTime(),\n        y: dp.successRate\n      }))\n  }]\n})\n\n// Fetch data for single channel\nconst refreshData = async () => {\n  isLoading.value = true\n  errorMessage.value = ''\n  try {\n    let allData: MetricsHistoryResponse[]\n    if (props.channelType === 'messages') {\n      allData = await api.getChannelMetricsHistory(selectedDuration.value)\n    } else {\n      allData = await api.getResponsesChannelMetricsHistory(selectedDuration.value)\n    }\n    // Find the specific channel data\n    historyData.value = allData.find(ch => ch.channelIndex === props.channelIndex) || null\n  } catch (error) {\n    console.error('Failed to fetch metrics history:', error)\n    errorMessage.value = error instanceof Error ? error.message : '获取历史数据失败'\n    showError.value = true\n    historyData.value = null\n  } finally {\n    isLoading.value = false\n  }\n}\n\n// Watch duration change\nwatch(selectedDuration, () => {\n  refreshData()\n})\n\n// Watch channel change\nwatch(() => props.channelIndex, () => {\n  refreshData()\n})\n\n// Watch channelType change\nwatch(() => props.channelType, () => {\n  refreshData()\n})\n\n// Initial load\nonMounted(() => {\n  refreshData()\n})\n\n// Expose refresh method\ndefineExpose({\n  refreshData\n})\n</script>\n\n<style scoped>\n.channel-chart-container {\n  padding: 12px 16px;\n  background: rgba(var(--v-theme-primary), 0.03);\n  border-top: 1px dashed rgba(var(--v-theme-on-surface), 0.2);\n}\n\n.v-theme--dark .channel-chart-container {\n  background: rgba(var(--v-theme-primary), 0.05);\n  border-top-color: rgba(255, 255, 255, 0.15);\n}\n\n.charts-wrapper {\n  margin-top: 8px;\n}\n\n.chart-row {\n  display: grid;\n  grid-template-columns: 1fr 1fr;\n  gap: 16px;\n}\n\n.chart-item {\n  min-width: 0;\n}\n\n@media (max-width: 800px) {\n  .chart-row {\n    grid-template-columns: 1fr;\n    gap: 12px;\n  }\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/ChannelOrchestration.vue",
    "content": "<template>\n  <v-card elevation=\"0\" rounded=\"lg\" class=\"channel-orchestration\" variant=\"flat\">\n    <!-- 调度器统计信息 -->\n    <v-card-title class=\"d-flex align-center justify-space-between py-3 px-0\">\n      <div class=\"d-flex align-center\">\n        <v-icon class=\"mr-2\" color=\"primary\">mdi-swap-vertical-bold</v-icon>\n        <span class=\"text-h6\">渠道编排</span>\n        <v-chip v-if=\"isMultiChannelMode\" size=\"small\" color=\"success\" variant=\"tonal\" class=\"ml-3\">\n          多渠道模式\n        </v-chip>\n        <v-chip v-else size=\"small\" color=\"warning\" variant=\"tonal\" class=\"ml-3\"> 单渠道模式 </v-chip>\n      </div>\n      <div class=\"d-flex align-center ga-2\">\n        <v-progress-circular v-if=\"isLoadingMetrics\" indeterminate size=\"16\" width=\"2\" color=\"primary\" />\n      </div>\n    </v-card-title>\n\n    <v-divider />\n\n    <!-- 故障转移序列 (active + suspended) -->\n    <div class=\"pt-3 pb-2\">\n      <div class=\"d-flex align-center justify-space-between mb-2\">\n        <div class=\"text-subtitle-2 text-medium-emphasis d-flex align-center\">\n          <v-icon size=\"small\" class=\"mr-1\" color=\"success\">mdi-play-circle</v-icon>\n          故障转移序列\n          <v-chip size=\"x-small\" class=\"ml-2\">{{ activeChannels.length }}</v-chip>\n        </div>\n        <div class=\"d-flex align-center ga-2\">\n          <span class=\"text-caption text-medium-emphasis\">拖拽调整优先级，自动保存</span>\n          <v-progress-circular v-if=\"isSavingOrder\" indeterminate size=\"16\" width=\"2\" color=\"primary\" />\n        </div>\n      </div>\n\n      <!-- 拖拽列表 -->\n      <draggable\n        v-model=\"activeChannels\"\n        item-key=\"index\"\n        handle=\".drag-handle\"\n        ghost-class=\"ghost\"\n        class=\"channel-list\"\n        @change=\"onDragChange\"\n      >\n        <template #item=\"{ element, index }\">\n          <div class=\"channel-item-wrapper\">\n            <div\n              class=\"channel-row\"\n              :class=\"{ 'is-suspended': element.status === 'suspended' }\"\n              @click=\"toggleChannelChart(element.index)\"\n            >\n              <!-- SVG 活跃度波形柱状图背景 -->\n              <svg class=\"activity-chart-bg\" preserveAspectRatio=\"none\" viewBox=\"0 0 150 100\">\n                <!-- 渐变定义（为每个柱子单独定义渐变） -->\n                <defs>\n                  <linearGradient\n                    v-for=\"(bar, i) in getActivityBars(element.index)\"\n                    :id=\"`gradient-${element.index}-${i}`\"\n                    :key=\"`gradient-${element.index}-${i}`\"\n                    x1=\"0%\"\n                    y1=\"0%\"\n                    x2=\"0%\"\n                    y2=\"100%\"\n                  >\n                    <stop offset=\"0%\" :stop-color=\"bar.color\" stop-opacity=\"0.8\" />\n                    <stop offset=\"100%\" :stop-color=\"bar.color\" stop-opacity=\"0.3\" />\n                  </linearGradient>\n                </defs>\n                <!-- 波形柱状图 -->\n                <g v-for=\"(bar, i) in getActivityBars(element.index)\" :key=\"i\">\n                  <rect\n                    :x=\"bar.x\"\n                    :y=\"bar.y\"\n                    :width=\"bar.width\"\n                    :height=\"bar.height\"\n                    :fill=\"`url(#gradient-${element.index}-${i})`\"\n                    :rx=\"bar.radius\"\n                    :ry=\"bar.radius\"\n                    class=\"activity-bar\"\n                  />\n                </g>\n              </svg>\n\n              <!-- Grid 内容容器 -->\n              <div class=\"channel-row-content\">\n                <!-- 拖拽手柄 -->\n                <div class=\"drag-handle\" @click.stop>\n                  <v-icon size=\"small\" color=\"grey\">mdi-drag-vertical</v-icon>\n                </div>\n\n            <!-- 优先级序号 -->\n            <div class=\"priority-number\" @click.stop>\n              <span class=\"text-caption font-weight-bold\">{{ index + 1 }}</span>\n            </div>\n\n            <!-- 状态指示器 -->\n            <div @click.stop>\n              <ChannelStatusBadge :status=\"element.status || 'active'\" :metrics=\"getChannelMetrics(element.index)\" />\n            </div>\n\n            <!-- 渠道名称和描述 -->\n            <div class=\"channel-name\">\n              <span\n                class=\"font-weight-medium channel-name-link\"\n                tabindex=\"0\"\n                role=\"button\"\n                @click.stop=\"$emit('edit', element)\"\n                @keydown.enter.stop=\"$emit('edit', element)\"\n                @keydown.space.stop=\"$emit('edit', element)\"\n              >{{ element.name }}</span>\n              <!-- 促销期标识 -->\n              <v-chip\n                v-if=\"isInPromotion(element)\"\n                size=\"x-small\"\n                color=\"info\"\n                variant=\"flat\"\n                class=\"ml-2\"\n              >\n                <v-icon start size=\"12\">mdi-rocket-launch</v-icon>\n                {{ formatPromotionRemaining(element.promotionUntil) }}\n              </v-chip>\n              <!-- 官网链接按钮 -->\n              <v-btn\n                :href=\"getWebsiteUrl(element)\"\n                target=\"_blank\"\n                rel=\"noopener\"\n                icon\n                size=\"x-small\"\n                variant=\"text\"\n                color=\"primary\"\n                class=\"ml-1\"\n                title=\"打开官网\"\n                @click.stop\n              >\n                <v-icon size=\"14\">mdi-open-in-new</v-icon>\n              </v-btn>\n              <span class=\"text-caption text-medium-emphasis ml-2\">{{ element.serviceType }}</span>\n              <span v-if=\"element.description\" class=\"text-caption text-disabled ml-3 channel-description\">{{ element.description }}</span>\n              <!-- 展开图标 -->\n              <v-icon\n                size=\"x-small\"\n                class=\"ml-auto expand-icon\"\n                :color=\"expandedChannelIndex === element.index ? 'primary' : 'grey-lighten-1'\"\n              >{{ expandedChannelIndex === element.index ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>\n            </div>\n\n            <!-- 指标显示 -->\n            <div class=\"channel-metrics\" @click.stop>\n              <template v-if=\"getChannelMetrics(element.index)\">\n                <v-tooltip location=\"top\" :open-delay=\"200\">\n                  <template #activator=\"{ props: tooltipProps }\">\n                    <div v-bind=\"tooltipProps\" class=\"d-flex align-center metrics-display\">\n                      <!-- 15分钟有请求时显示成功率，否则显示 -- -->\n                      <template v-if=\"get15mStats(element.index)?.requestCount\">\n                        <v-chip\n                          size=\"x-small\"\n                          :color=\"getSuccessRateColor(get15mStats(element.index)?.successRate)\"\n                          variant=\"tonal\"\n                        >\n                          {{ get15mStats(element.index)?.successRate?.toFixed(0) }}%\n                        </v-chip>\n                        <span class=\"text-caption text-medium-emphasis ml-2 mr-1\">\n                          {{ get15mStats(element.index)?.requestCount }} 请求\n                        </span>\n                        <v-chip\n                          v-if=\"shouldShowCacheHitRate(get15mStats(element.index))\"\n                          size=\"x-small\"\n                          :color=\"getCacheHitRateColor(get15mStats(element.index)?.cacheHitRate)\"\n                          variant=\"tonal\"\n                          class=\"ml-1\"\n                        >\n                          缓存 {{ get15mStats(element.index)?.cacheHitRate?.toFixed(0) }}%\n                        </v-chip>\n                      </template>\n                      <span v-else class=\"text-caption text-medium-emphasis\">--</span>\n                    </div>\n                  </template>\n                  <div class=\"metrics-tooltip\">\n                    <div class=\"text-caption font-weight-bold mb-1\">请求统计</div>\n                    <div class=\"metrics-tooltip-row\">\n                      <span>15分钟:</span>\n                      <span>{{ formatStats(get15mStats(element.index)) }}</span>\n                    </div>\n                    <div class=\"metrics-tooltip-row\">\n                      <span>1小时:</span>\n                      <span>{{ formatStats(get1hStats(element.index)) }}</span>\n                    </div>\n                    <div class=\"metrics-tooltip-row\">\n                      <span>6小时:</span>\n                      <span>{{ formatStats(get6hStats(element.index)) }}</span>\n                    </div>\n                    <div class=\"metrics-tooltip-row\">\n                      <span>24小时:</span>\n                      <span>{{ formatStats(get24hStats(element.index)) }}</span>\n                    </div>\n\n                    <div class=\"text-caption font-weight-bold mt-2 mb-1\">缓存统计 (Token)</div>\n                    <div class=\"metrics-tooltip-row\">\n                      <span>15分钟:</span>\n                      <span>{{ formatCacheStats(get15mStats(element.index)) }}</span>\n                    </div>\n                    <div class=\"metrics-tooltip-row\">\n                      <span>1小时:</span>\n                      <span>{{ formatCacheStats(get1hStats(element.index)) }}</span>\n                    </div>\n                    <div class=\"metrics-tooltip-row\">\n                      <span>6小时:</span>\n                      <span>{{ formatCacheStats(get6hStats(element.index)) }}</span>\n                    </div>\n                    <div class=\"metrics-tooltip-row\">\n                      <span>24小时:</span>\n                      <span>{{ formatCacheStats(get24hStats(element.index)) }}</span>\n                    </div>\n                  </div>\n                </v-tooltip>\n              </template>\n              <span v-else class=\"text-caption text-medium-emphasis\">--</span>\n            </div>\n\n            <!-- RPM/TPM 显示 -->\n            <div class=\"channel-rpm-tpm\" @click.stop>\n              <div class=\"rpm-tpm-values\">\n                <span class=\"rpm-value\" :class=\"{ 'has-data': hasActivityData(element.index) }\">{{ formatRPM(element.index) }}</span>\n                <span class=\"rpm-tpm-separator\">/</span>\n                <span class=\"tpm-value\" :class=\"{ 'has-data': hasActivityData(element.index) }\">{{ formatTPM(element.index) }}</span>\n              </div>\n              <div class=\"rpm-tpm-labels\">\n                <span>RPM</span>\n                <span>/</span>\n                <span>TPM</span>\n              </div>\n            </div>\n\n            <!-- 延迟显示 -->\n            <div class=\"channel-latency\" @click.stop>\n              <v-chip\n                v-if=\"isLatencyValid(element)\"\n                size=\"x-small\"\n                :color=\"getLatencyColor(element.latency!)\"\n                variant=\"tonal\"\n              >\n                {{ element.latency }}ms\n              </v-chip>\n            </div>\n\n            <!-- API密钥数量 -->\n            <div class=\"channel-keys\" @click.stop>\n              <v-chip size=\"x-small\" variant=\"outlined\" class=\"keys-chip\" @click=\"$emit('edit', element)\">\n                <v-icon start size=\"x-small\">mdi-key</v-icon>\n                {{ element.apiKeys?.length || 0 }}\n              </v-chip>\n            </div>\n\n            <!-- 操作按钮 -->\n            <div class=\"channel-actions\" @click.stop>\n              <!-- suspended 状态显示恢复按钮 -->\n              <v-btn\n                v-if=\"element.status === 'suspended'\"\n                icon\n                size=\"x-small\"\n                variant=\"text\"\n                color=\"warning\"\n                title=\"恢复\"\n                @click=\"resumeChannel(element.index)\"\n              >\n                <v-icon size=\"small\">mdi-refresh</v-icon>\n              </v-btn>\n\n              <v-menu>\n                <template #activator=\"{ props: menuProps }\">\n                  <v-btn icon size=\"x-small\" variant=\"text\" v-bind=\"menuProps\">\n                    <v-icon size=\"small\">mdi-dots-vertical</v-icon>\n                  </v-btn>\n                </template>\n                <v-list density=\"compact\">\n                  <v-list-item @click=\"$emit('edit', element)\">\n                    <template #prepend>\n                      <v-icon size=\"small\">mdi-pencil</v-icon>\n                    </template>\n                    <v-list-item-title>编辑</v-list-item-title>\n                  </v-list-item>\n                  <v-list-item @click=\"$emit('ping', element.index)\">\n                    <template #prepend>\n                      <v-icon size=\"small\">mdi-speedometer</v-icon>\n                    </template>\n                    <v-list-item-title>测试延迟</v-list-item-title>\n                  </v-list-item>\n                  <v-list-item @click=\"setPromotion(element)\">\n                    <template #prepend>\n                      <v-icon size=\"small\" color=\"info\">mdi-rocket-launch</v-icon>\n                    </template>\n                    <v-list-item-title>抢优先级 (5分钟)</v-list-item-title>\n                  </v-list-item>\n                  <v-list-item v-if=\"index > 0\" :disabled=\"isSavingOrder\" @click=\"moveChannelToTop(element.index)\">\n                    <template #prepend>\n                      <v-icon size=\"small\" color=\"primary\">mdi-arrow-collapse-up</v-icon>\n                    </template>\n                    <v-list-item-title>置顶</v-list-item-title>\n                  </v-list-item>\n                  <v-list-item v-if=\"index < activeChannels.length - 1\" :disabled=\"isSavingOrder\" @click=\"moveChannelToBottom(element.index)\">\n                    <template #prepend>\n                      <v-icon size=\"small\" color=\"primary\">mdi-arrow-collapse-down</v-icon>\n                    </template>\n                    <v-list-item-title>置底</v-list-item-title>\n                  </v-list-item>\n                  <v-divider />\n                  <v-list-item v-if=\"element.status === 'suspended'\" @click=\"resumeChannel(element.index)\">\n                    <template #prepend>\n                      <v-icon size=\"small\" color=\"success\">mdi-play-circle</v-icon>\n                    </template>\n                    <v-list-item-title>恢复 (重置指标)</v-list-item-title>\n                  </v-list-item>\n                  <v-list-item\n                    v-if=\"element.status !== 'suspended'\"\n                    @click=\"setChannelStatus(element.index, 'suspended')\"\n                  >\n                    <template #prepend>\n                      <v-icon size=\"small\" color=\"warning\">mdi-pause-circle</v-icon>\n                    </template>\n                    <v-list-item-title>暂停</v-list-item-title>\n                  </v-list-item>\n                  <v-list-item @click=\"setChannelStatus(element.index, 'disabled')\">\n                    <template #prepend>\n                      <v-icon size=\"small\" color=\"error\">mdi-stop-circle</v-icon>\n                    </template>\n                    <v-list-item-title>移至备用池</v-list-item-title>\n                  </v-list-item>\n                  <v-list-item :disabled=\"!canDeleteChannel(element)\" @click=\"handleDeleteChannel(element)\">\n                    <template #prepend>\n                      <v-icon size=\"small\" :color=\"canDeleteChannel(element) ? 'error' : 'grey'\">mdi-delete</v-icon>\n                    </template>\n                    <v-list-item-title>\n                      删除\n                      <span v-if=\"!canDeleteChannel(element)\" class=\"text-caption text-disabled ml-1\">\n                        (至少保留一个)\n                      </span>\n                    </v-list-item-title>\n                  </v-list-item>\n                </v-list>\n              </v-menu>\n            </div>\n              </div><!-- .channel-row-content -->\n          </div><!-- .channel-row -->\n\n          <!-- 展开的图表区域 -->\n          <v-expand-transition>\n            <div v-if=\"expandedChannelIndex === element.index\" class=\"channel-chart-wrapper\">\n              <KeyTrendChart\n                :key=\"`chart-${channelType}-${element.index}`\"\n                :channel-id=\"element.index\"\n                :channel-type=\"channelType\"\n                @close=\"expandedChannelIndex = null\"\n              />\n            </div>\n          </v-expand-transition>\n          </div>\n        </template>\n      </draggable>\n\n      <!-- 空状态 -->\n      <div v-if=\"activeChannels.length === 0\" class=\"text-center py-6 text-medium-emphasis\">\n        <v-icon size=\"48\" color=\"grey-lighten-1\">mdi-playlist-remove</v-icon>\n        <div class=\"mt-2\">暂无活跃渠道</div>\n        <div class=\"text-caption\">从下方备用池启用渠道</div>\n      </div>\n    </div>\n\n    <v-divider class=\"my-2\" />\n\n    <!-- 备用资源池 (disabled only) -->\n    <div class=\"pt-2 pb-3\">\n      <div class=\"inactive-pool-header\">\n        <div class=\"text-subtitle-2 text-medium-emphasis d-flex align-center\">\n          <v-icon size=\"small\" class=\"mr-1\" color=\"grey\">mdi-archive-outline</v-icon>\n          备用资源池\n          <v-chip size=\"x-small\" class=\"ml-2\">{{ inactiveChannels.length }}</v-chip>\n        </div>\n        <span class=\"text-caption text-medium-emphasis\">启用后将追加到活跃序列末尾</span>\n      </div>\n\n      <div v-if=\"inactiveChannels.length > 0\" class=\"inactive-pool\">\n        <div v-for=\"channel in inactiveChannels\" :key=\"channel.index\" class=\"inactive-channel-row\">\n          <!-- 渠道信息 -->\n          <div class=\"channel-info\">\n            <div class=\"channel-info-main\">\n              <span\n                class=\"font-weight-medium channel-name-link\"\n                tabindex=\"0\"\n                role=\"button\"\n                @click=\"$emit('edit', channel)\"\n                @keydown.enter=\"$emit('edit', channel)\"\n                @keydown.space.prevent=\"$emit('edit', channel)\"\n              >{{ channel.name }}</span>\n              <span class=\"text-caption text-disabled ml-2\">{{ channel.serviceType }}</span>\n            </div>\n            <div v-if=\"channel.description\" class=\"channel-info-desc text-caption text-disabled\">\n              {{ channel.description }}\n            </div>\n          </div>\n\n          <!-- API密钥数量 -->\n          <div class=\"channel-keys\">\n            <v-chip size=\"x-small\" variant=\"outlined\" color=\"grey\" class=\"keys-chip\" @click=\"$emit('edit', channel)\">\n              <v-icon start size=\"x-small\">mdi-key</v-icon>\n              {{ channel.apiKeys?.length || 0 }}\n            </v-chip>\n          </div>\n\n          <!-- 操作按钮 -->\n          <div class=\"channel-actions\">\n            <v-btn size=\"small\" color=\"success\" variant=\"tonal\" @click=\"enableChannel(channel.index)\">\n              <v-icon start size=\"small\">mdi-play-circle</v-icon>\n              启用\n            </v-btn>\n\n            <v-menu>\n              <template #activator=\"{ props: menuProps }\">\n                <v-btn icon size=\"x-small\" variant=\"text\" v-bind=\"menuProps\">\n                  <v-icon size=\"small\">mdi-dots-vertical</v-icon>\n                </v-btn>\n              </template>\n              <v-list density=\"compact\">\n                <v-list-item @click=\"$emit('edit', channel)\">\n                  <template #prepend>\n                    <v-icon size=\"small\">mdi-pencil</v-icon>\n                  </template>\n                  <v-list-item-title>编辑</v-list-item-title>\n                </v-list-item>\n                <v-divider />\n                <v-list-item @click=\"enableChannel(channel.index)\">\n                  <template #prepend>\n                    <v-icon size=\"small\" color=\"success\">mdi-play-circle</v-icon>\n                  </template>\n                  <v-list-item-title>启用</v-list-item-title>\n                </v-list-item>\n                <v-list-item @click=\"$emit('delete', channel.index)\">\n                  <template #prepend>\n                    <v-icon size=\"small\" color=\"error\">mdi-delete</v-icon>\n                  </template>\n                  <v-list-item-title>删除</v-list-item-title>\n                </v-list-item>\n              </v-list>\n            </v-menu>\n          </div>\n        </div>\n      </div>\n\n      <div v-else class=\"text-center py-4 text-medium-emphasis text-caption\">所有渠道都处于活跃状态</div>\n    </div>\n  </v-card>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, watch, onMounted, onUnmounted } from 'vue'\nimport draggable from 'vuedraggable'\nimport { api, type Channel, type ChannelMetrics, type ChannelStatus, type TimeWindowStats, type ChannelRecentActivity } from '../services/api'\nimport ChannelStatusBadge from './ChannelStatusBadge.vue'\nimport KeyTrendChart from './KeyTrendChart.vue'\n\nconst props = defineProps<{\n  channels: Channel[]\n  currentChannelIndex: number\n  channelType: 'messages' | 'responses' | 'gemini'\n  // 可选：从父组件传入的 metrics 和 stats（使用 dashboard 接口时）\n  dashboardMetrics?: ChannelMetrics[]\n  dashboardStats?: {\n    multiChannelMode: boolean\n    activeChannelCount: number\n    traceAffinityCount: number\n    traceAffinityTTL: string\n    failureThreshold: number\n    windowSize: number\n    circuitRecoveryTime?: string\n  }\n  // 可选：从父组件传入的实时活跃度数据\n  dashboardRecentActivity?: ChannelRecentActivity[]\n}>()\n\nconst emit = defineEmits<{\n  (_e: 'edit', _channel: Channel): void\n  (_e: 'delete', _channelId: number): void\n  (_e: 'ping', _channelId: number): void\n  (_e: 'refresh'): void\n  (_e: 'error', _message: string): void\n  (_e: 'success', _message: string): void\n}>()\n\n// 状态\nconst metrics = ref<ChannelMetrics[]>([])\nconst recentActivity = ref<ChannelRecentActivity[]>([])\nconst schedulerStats = ref<{\n  multiChannelMode: boolean\n  activeChannelCount: number\n  traceAffinityCount: number\n  traceAffinityTTL: string\n  failureThreshold: number\n  windowSize: number\n} | null>(null)\nconst isLoadingMetrics = ref(false)\nconst isSavingOrder = ref(false)\n\n// 延迟测试结果有效期（5 分钟）\nconst LATENCY_VALID_DURATION = 5 * 60 * 1000\n// 用于触发响应式更新的时间戳\nconst currentTime = ref(Date.now())\nlet latencyCheckTimer: ReturnType<typeof setInterval> | null = null\n\n// 用于触发活跃度视图更新的时间戳（每 2 秒更新）\nconst activityUpdateTick = ref(0)\nlet activityUpdateTimer: ReturnType<typeof setInterval> | null = null\n\n// 图表展开状态\nconst expandedChannelIndex = ref<number | null>(null)\n\n// 切换渠道图表展开/收起\nconst toggleChannelChart = (channelIndex: number) => {\n  expandedChannelIndex.value = expandedChannelIndex.value === channelIndex ? null : channelIndex\n}\n\n// 活跃渠道（可拖拽排序）- 包含 active 和 suspended 状态\nconst activeChannels = ref<Channel[]>([])\n\n// 计算属性：非活跃渠道 - 仅 disabled 状态\nconst inactiveChannels = computed(() => {\n  return props.channels.filter(ch => ch.status === 'disabled')\n})\n\n// 计算属性：是否为多渠道模式\n// 多渠道模式判断逻辑：\n// 1. 只有一个启用的渠道 → 单渠道模式\n// 2. 有一个 active + 几个 suspended → 单渠道模式\n// 3. 有多个 active 渠道 → 多渠道模式\nconst isMultiChannelMode = computed(() => {\n  const activeCount = props.channels.filter(\n    ch => ch.status === 'active' || ch.status === undefined || ch.status === ''\n  ).length\n  return activeCount > 1\n})\n\n// 初始化活跃渠道列表 - active + suspended 都参与故障转移序列\n// 优化：只在结构变化时更新，避免频繁重建导致子组件销毁\nconst initActiveChannels = () => {\n  const newActive = props.channels\n    .filter(ch => ch.status !== 'disabled')\n    .sort((a, b) => (a.priority ?? a.index) - (b.priority ?? b.index))\n\n  // 检查是否需要更新：比较 index 列表是否变化\n  const currentIndexes = activeChannels.value.map(ch => ch.index).join(',')\n  const newIndexes = newActive.map(ch => ch.index).join(',')\n\n  if (currentIndexes !== newIndexes) {\n    // 结构变化（新增/删除/重排），需要重建数组\n    activeChannels.value = [...newActive]\n  } else {\n    // 结构未变，只更新现有对象的属性（保持引用不变）\n    activeChannels.value.forEach((ch, i) => {\n      Object.assign(ch, newActive[i])\n    })\n  }\n}\n\n// 监听 channels 变化\nwatch(() => props.channels, initActiveChannels, { immediate: true, deep: true })\n\n// 监听 dashboard props 变化（从父组件传入的合并数据）\nwatch(() => props.dashboardMetrics, (newMetrics) => {\n  if (newMetrics) {\n    metrics.value = newMetrics\n  }\n}, { immediate: true })\n\nwatch(() => props.dashboardStats, (newStats) => {\n  if (newStats) {\n    schedulerStats.value = newStats\n  }\n}, { immediate: true })\n\n// 监听 recentActivity props 变化\nwatch(() => props.dashboardRecentActivity, (newActivity) => {\n  recentActivity.value = newActivity ?? []\n}, { immediate: true })\n\n// 监听 channelType 变化 - 切换时刷新指标并收起图表\nwatch(() => props.channelType, () => {\n  expandedChannelIndex.value = null // 收起展开的图表\n  // 如果没有使用 dashboard props，则自己刷新\n  if (!props.dashboardMetrics) {\n    refreshMetrics()\n  }\n})\n\n// 获取渠道指标\nconst getChannelMetrics = (channelIndex: number): ChannelMetrics | undefined => {\n  return metrics.value.find(m => m.channelIndex === channelIndex)\n}\n\n// 获取分时段统计的辅助方法\nconst get15mStats = (channelIndex: number) => {\n  return getChannelMetrics(channelIndex)?.timeWindows?.['15m']\n}\n\nconst get1hStats = (channelIndex: number) => {\n  return getChannelMetrics(channelIndex)?.timeWindows?.['1h']\n}\n\nconst get6hStats = (channelIndex: number) => {\n  return getChannelMetrics(channelIndex)?.timeWindows?.['6h']\n}\n\nconst get24hStats = (channelIndex: number) => {\n  return getChannelMetrics(channelIndex)?.timeWindows?.['24h']\n}\n\n// 获取成功率颜色\nconst getSuccessRateColor = (rate?: number): string => {\n  if (rate === undefined) return 'grey'\n  if (rate >= 90) return 'success'\n  if (rate >= 70) return 'warning'\n  return 'error'\n}\n\nconst getCacheHitRateColor = (rate?: number): string => {\n  if (rate === undefined) return 'grey'\n  if (rate >= 50) return 'success'\n  if (rate >= 20) return 'info'\n  if (rate >= 5) return 'warning'\n  return 'orange'\n}\n\nconst shouldShowCacheHitRate = (stats?: TimeWindowStats): boolean => {\n  if (!stats || !stats.requestCount) return false\n  const inputTokens = stats.inputTokens ?? 0\n  const cacheReadTokens = stats.cacheReadTokens ?? 0\n  return (inputTokens + cacheReadTokens) > 0\n}\n\n// 获取延迟颜色\nconst getLatencyColor = (latency: number): string => {\n  if (latency < 500) return 'success'\n  if (latency < 1000) return 'warning'\n  return 'error'\n}\n\n// 判断延迟测试结果是否仍然有效（5 分钟内）\nconst isLatencyValid = (channel: Channel): boolean => {\n  // 没有延迟值，不显示\n  if (channel.latency === undefined || channel.latency === null) return false\n  // 没有测试时间戳（兼容旧数据），不显示\n  if (!channel.latencyTestTime) return false\n  // 检查是否在有效期内（使用 currentTime.value 触发响应式更新）\n  return (currentTime.value - channel.latencyTestTime) < LATENCY_VALID_DURATION\n}\n\n// 判断渠道是否处于促销期\nconst isInPromotion = (channel: Channel): boolean => {\n  if (!channel.promotionUntil) return false\n  return new Date(channel.promotionUntil) > new Date()\n}\n\n// 格式化促销期剩余时间\nconst formatPromotionRemaining = (until?: string): string => {\n  if (!until) return ''\n  const remaining = Math.max(0, new Date(until).getTime() - Date.now())\n  const minutes = Math.ceil(remaining / 60000)\n  if (minutes <= 0) return '即将结束'\n  return `${minutes}分钟`\n}\n\n// 格式化统计数据：有请求显示\"N 请求 (X%)\"，无请求显示\"--\"\nconst formatStats = (stats?: TimeWindowStats): string => {\n  if (!stats || !stats.requestCount) return '--'\n  return `${stats.requestCount} 请求 (${stats.successRate?.toFixed(0)}%)`\n}\n\nconst formatTokens = (num?: number): string => {\n  const value = num ?? 0\n  if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`\n  if (value >= 1000) return `${(value / 1000).toFixed(1)}K`\n  return Math.round(value).toString()\n}\n\nconst formatCacheStats = (stats?: TimeWindowStats): string => {\n  if (!stats || !stats.requestCount) return '--'\n\n  const inputTokens = stats.inputTokens ?? 0\n  const cacheReadTokens = stats.cacheReadTokens ?? 0\n  const cacheCreationTokens = stats.cacheCreationTokens ?? 0\n  const denom = inputTokens + cacheReadTokens\n\n  if (denom <= 0) return '--'\n\n  const hitRate = stats.cacheHitRate ?? (cacheReadTokens / denom * 100)\n  return `命中 ${hitRate.toFixed(0)}% · 读 ${formatTokens(cacheReadTokens)} · 写 ${formatTokens(cacheCreationTokens)}`\n}\n\n// 获取官网 URL（优先使用 website，否则从 baseUrl 提取域名）\nconst getWebsiteUrl = (channel: Channel): string => {\n  if (channel.website) return channel.website\n  try {\n    const url = new URL(channel.baseUrl)\n    return `${url.protocol}//${url.host}`\n  } catch {\n    return channel.baseUrl\n  }\n}\n\n// ============== 渠道实时活跃度相关函数 ==============\n\n// 活跃度数据 Map 缓存（避免线性查找）\nconst activityMap = computed(() => {\n  const map = new Map<number, ChannelRecentActivity>()\n  for (const a of recentActivity.value) {\n    map.set(a.channelIndex, a)\n  }\n  return map\n})\n\n// 每个渠道的历史最大请求数（用于固定柱状图高度比例）\nconst maxRequestsHistory = ref(new Map<number, number>())\n\n// 更新历史最大值\nwatch(activityMap, (newMap) => {\n  for (const [channelIndex, activity] of newMap.entries()) {\n    if (!activity.segments || activity.segments.length === 0) continue\n\n    const currentMax = Math.max(...activity.segments.map(s => s.requestCount), 0)\n    const historicalMax = maxRequestsHistory.value.get(channelIndex) ?? 0\n\n    // 只在当前最大值更大时更新（保持历史峰值）\n    if (currentMax > historicalMax) {\n      maxRequestsHistory.value.set(channelIndex, currentMax)\n    }\n  }\n})\n\n// 获取渠道的活跃度数据\nconst getChannelActivity = (channelIndex: number): ChannelRecentActivity | undefined => {\n  return activityMap.value.get(channelIndex)\n}\n\n// 缓存所有渠道的柱状图数据（避免在模板中重复计算）\nconst activityBarsCache = computed(() => {\n  const cache = new Map<number, Array<{ x: number; y: number; width: number; height: number; radius: number; color: string }>>()\n\n  // 使用 activityUpdateTick 触发响应式更新\n  const _ = activityUpdateTick.value\n\n  for (const [channelIndex, activity] of activityMap.value.entries()) {\n    if (!activity || !activity.segments || activity.segments.length === 0) {\n      cache.set(channelIndex, [])\n      continue\n    }\n\n    const segments = activity.segments\n    const numSegments = segments.length  // 150（后端已聚合为每 6 秒一段）\n\n    // 每个段一个柱子\n    const barWidth = 150 / numSegments\n    const barGap = barWidth * 0.2  // 20% 间隙\n    const actualBarWidth = barWidth - barGap\n\n    // 使用历史最大值作为归一化基准（避免高流量段离开后柱子突然变高）\n    const maxRequests = maxRequestsHistory.value.get(channelIndex) ?? Math.max(...segments.map(s => s.requestCount), 1)\n\n    const bars: Array<{ x: number; y: number; width: number; height: number; radius: number; color: string }> = []\n\n    for (let i = 0; i < numSegments; i++) {\n      const segment = segments[i]\n      const requests = segment.requestCount\n\n      // 计算柱子高度（最小高度 2，避免完全消失）\n      const heightPercent = requests / maxRequests\n      const height = Math.max(heightPercent * 85, requests > 0 ? 2 : 0)\n      const y = 100 - height\n\n      // 根据该 6 秒段的成功率计算颜色（7 档分级：极端档位 + 整数档位）\n      let color = 'rgb(74, 222, 128)'  // 默认绿色（无请求或 100% 成功）\n\n      if (requests > 0) {\n        const successCount = requests - segment.failureCount\n        const successRate = (successCount / requests) * 100\n\n        if (successRate < 5) {\n          color = 'rgb(220, 38, 38)'       // 0-5%：深红色（极端故障）\n        } else if (successRate < 20) {\n          color = 'rgb(239, 68, 68)'       // 5-20%：红色（严重失败）\n        } else if (successRate < 40) {\n          color = 'rgb(249, 115, 22)'      // 20-40%：深橙色（高失败率）\n        } else if (successRate < 60) {\n          color = 'rgb(251, 146, 60)'      // 40-60%：橙色（中等失败率）\n        } else if (successRate < 80) {\n          color = 'rgb(250, 204, 21)'      // 60-80%：黄色（轻微失败）\n        } else if (successRate < 95) {\n          color = 'rgb(132, 204, 22)'      // 80-95%：黄绿色（良好）\n        } else {\n          color = 'rgb(34, 197, 94)'       // 95-100%：绿色（优秀）\n        }\n      }\n\n      bars.push({\n        x: i * barWidth + barGap / 2,\n        y,\n        width: actualBarWidth,\n        height,\n        radius: Math.min(actualBarWidth / 2, 1.5),  // 圆角半径\n        color\n      })\n    }\n\n    cache.set(channelIndex, bars)\n  }\n\n  return cache\n})\n\n// 生成波形柱状图数据（从缓存中读取）\nconst getActivityBars = (channelIndex: number): Array<{ x: number; y: number; width: number; height: number; radius: number; color: string }> => {\n  return activityBarsCache.value.get(channelIndex) ?? []\n}\n\n// 生成平滑曲线路径（使用移动平均 + Catmull-Rom 样条）\nconst getActivityPath = (channelIndex: number): string => {\n  const activity = getChannelActivity(channelIndex)\n  if (!activity || !activity.segments || activity.segments.length === 0) return ''\n\n  // 使用 activityUpdateTick 触发响应式更新\n   \n  const _ = activityUpdateTick.value\n\n  const segments = activity.segments\n  const numSegments = segments.length  // 150（后端已聚合为每 6 秒一段）\n\n  // 找到最大请求数用于归一化\n  const maxRequests = Math.max(...segments.map(s => s.requestCount), 1)\n\n  // 应用移动平均平滑数据（窗口大小 5 = 10 秒）\n  const windowSize = 5\n  const smoothedData: number[] = []\n\n  for (let i = 0; i < numSegments; i++) {\n    const start = Math.max(0, i - Math.floor(windowSize / 2))\n    const end = Math.min(numSegments, i + Math.ceil(windowSize / 2))\n    let sum = 0\n    let count = 0\n\n    for (let j = start; j < end; j++) {\n      sum += segments[j].requestCount\n      count++\n    }\n\n    smoothedData.push(count > 0 ? sum / count : 0)\n  }\n\n  // 生成平滑后的点\n  const points: { x: number; y: number }[] = []\n  for (let i = 0; i < numSegments; i++) {\n    const x = i\n    const y = 100 - (smoothedData[i] / maxRequests * 85)\n    points.push({ x, y })\n  }\n\n  if (points.length < 2) return ''\n\n  // 使用 Catmull-Rom 样条生成平滑曲线\n  return catmullRomToPath(points)\n}\n\n// Catmull-Rom 样条转 SVG 贝塞尔路径\nfunction catmullRomToPath(points: { x: number; y: number }[]): string {\n  if (points.length < 2) return ''\n\n  const path: string[] = []\n  path.push(`M ${points[0].x} ${points[0].y}`)\n\n  // 张力参数（0.3 = 较低张力，曲线更贴近原始点）\n  const tension = 0.3\n\n  for (let i = 0; i < points.length - 1; i++) {\n    const p0 = points[Math.max(0, i - 1)]\n    const p1 = points[i]\n    const p2 = points[i + 1]\n    const p3 = points[Math.min(points.length - 1, i + 2)]\n\n    // 计算控制点\n    const cp1x = p1.x + (p2.x - p0.x) / 6 * tension\n    const cp1y = p1.y + (p2.y - p0.y) / 6 * tension\n    const cp2x = p2.x - (p3.x - p1.x) / 6 * tension\n    const cp2y = p2.y - (p3.y - p1.y) / 6 * tension\n\n    path.push(`C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`)\n  }\n\n  return path.join(' ')\n}\n\n// 生成平滑曲线填充区域路径\nconst _getActivityAreaPath = (channelIndex: number): string => {\n  const linePath = getActivityPath(channelIndex)\n  if (!linePath) return ''\n\n  const activity = getChannelActivity(channelIndex)\n  if (!activity || !activity.segments) return ''\n\n  const numSegments = activity.segments.length\n\n  // 在曲线路径后添加闭合到底部\n  return `${linePath} L ${numSegments - 1} 100 L 0 100 Z`\n}\n\n// 获取渠道的活跃度渐变背景（已废弃，改用 SVG 曲线）\nconst _getActivityGradient = (channelIndex: number): string => {\n  const activity = getChannelActivity(channelIndex)\n  if (!activity || !activity.segments || activity.segments.length === 0) return 'transparent'\n\n  // 检查是否有任何活动\n  const hasActivity = activity.segments.some(seg => seg.requestCount > 0)\n  if (!hasActivity) return 'transparent'\n\n  // 使用 activityUpdateTick 触发响应式更新\n   \n  const _ = activityUpdateTick.value\n\n  // 后端返回 150 段（每段 6 秒）\n  // 直接使用原始数据，不做加权平均，确保用户调用 API 后立即看到反馈\n  const numSegments = activity.segments.length  // 150\n\n  // 生成每个 6 秒段的颜色（基于原始请求数）\n  const segmentColors: string[] = []\n\n  for (let i = 0; i < numSegments; i++) {\n    const seg = activity.segments[i]\n\n    // 无请求则透明\n    if (seg.requestCount === 0) {\n      segmentColors.push('transparent')\n      continue\n    }\n\n    const hasFailure = seg.failureCount > 0\n\n    if (hasFailure) {\n      const failureRatio = seg.failureCount / seg.requestCount\n      if (failureRatio >= 0.5) {\n        // 高失败率：红色\n        const intensity = Math.min(0.5, 0.2 + seg.requestCount * 0.01)\n        segmentColors.push(`rgba(239, 68, 68, ${intensity})`)\n      } else {\n        // 部分失败：橙色\n        const intensity = Math.min(0.4, 0.15 + seg.requestCount * 0.008)\n        segmentColors.push(`rgba(251, 146, 60, ${intensity})`)\n      }\n    } else {\n      // 纯成功：绿色，6 级深浅按请求量\n      if (seg.requestCount >= 20) segmentColors.push('rgba(22, 163, 74, 0.65)')       // 极深绿\n      else if (seg.requestCount >= 15) segmentColors.push('rgba(22, 163, 74, 0.55)')  // 深绿\n      else if (seg.requestCount >= 10) segmentColors.push('rgba(34, 197, 94, 0.50)')  // 中深绿\n      else if (seg.requestCount >= 6) segmentColors.push('rgba(34, 197, 94, 0.42)')   // 中绿\n      else if (seg.requestCount >= 3) segmentColors.push('rgba(74, 222, 128, 0.38)')  // 浅绿\n      else segmentColors.push('rgba(74, 222, 128, 0.30)')                              // 极浅绿\n    }\n  }\n\n  // 生成渐变：每段占 100/150 %\n  const stops = segmentColors.map((color, i) => {\n    const start = (i / numSegments * 100).toFixed(3)\n    const end = ((i + 1) / numSegments * 100).toFixed(3)\n    return `${color} ${start}%, ${color} ${end}%`\n  }).join(', ')\n\n  return `linear-gradient(to right, ${stops})`\n}\n\n// 格式化 RPM 显示\nconst formatRPM = (channelIndex: number): string => {\n  const activity = getChannelActivity(channelIndex)\n  if (!activity || activity.rpm === 0) return '--'\n  if (activity.rpm >= 10) return activity.rpm.toFixed(0)\n  return activity.rpm.toFixed(1)\n}\n\n// 格式化 TPM 显示\nconst formatTPM = (channelIndex: number): string => {\n  const activity = getChannelActivity(channelIndex)\n  if (!activity || activity.tpm === 0) return '--'\n  if (activity.tpm >= 1000000) return `${(activity.tpm / 1000000).toFixed(1)}M`\n  if (activity.tpm >= 1000) return `${(activity.tpm / 1000).toFixed(1)}K`\n  return activity.tpm.toFixed(0)\n}\n\n// 判断渠道是否有活跃度数据\nconst hasActivityData = (channelIndex: number): boolean => {\n  const activity = getChannelActivity(channelIndex)\n  if (!activity) return false\n  return activity.rpm > 0 || activity.tpm > 0\n}\n\n// 刷新指标\nconst refreshMetrics = async () => {\n  isLoadingMetrics.value = true\n  try {\n    const [metricsData, statsData] = await Promise.all([\n      props.channelType === 'gemini'\n        ? api.getGeminiChannelMetrics()\n        : props.channelType === 'responses'\n          ? api.getResponsesChannelMetrics()\n          : api.getChannelMetrics(),\n      api.getSchedulerStats(props.channelType)\n    ])\n    metrics.value = metricsData\n    schedulerStats.value = statsData\n  } catch (error) {\n    console.error('Failed to load metrics:', error)\n  } finally {\n    isLoadingMetrics.value = false\n  }\n}\n\n// 拖拽变更事件 - 自动保存顺序\nconst onDragChange = () => {\n  // 拖拽后自动保存顺序到后端\n  saveOrder()\n}\n\n// 保存顺序\nconst saveOrder = async () => {\n  isSavingOrder.value = true\n  try {\n    const order = activeChannels.value.map(ch => ch.index)\n    if (props.channelType === 'gemini') {\n      await api.reorderGeminiChannels(order)\n    } else if (props.channelType === 'responses') {\n      await api.reorderResponsesChannels(order)\n    } else {\n      await api.reorderChannels(order)\n    }\n    // 不调用 emit('refresh')，避免触发父组件刷新导致列表闪烁\n  } catch (error) {\n    console.error('Failed to save order:', error)\n    const errorMessage = error instanceof Error ? error.message : '未知错误'\n    emit('error', `保存渠道顺序失败: ${errorMessage}`)\n    // 保存失败时重新初始化列表，恢复原始顺序\n    initActiveChannels()\n  } finally {\n    isSavingOrder.value = false\n  }\n}\n\n// 置顶渠道\nconst moveChannelToTop = async (channelIndex: number) => {\n  if (isSavingOrder.value) return\n  const idx = activeChannels.value.findIndex(ch => ch.index === channelIndex)\n  if (idx <= 0) return\n\n  const [channel] = activeChannels.value.splice(idx, 1)\n  activeChannels.value.unshift(channel)\n  await saveOrder()\n}\n\n// 置底渠道\nconst moveChannelToBottom = async (channelIndex: number) => {\n  if (isSavingOrder.value) return\n  const idx = activeChannels.value.findIndex(ch => ch.index === channelIndex)\n  if (idx < 0 || idx >= activeChannels.value.length - 1) return\n\n  const [channel] = activeChannels.value.splice(idx, 1)\n  activeChannels.value.push(channel)\n  await saveOrder()\n}\n\n// 设置渠道状态\nconst setChannelStatus = async (channelId: number, status: ChannelStatus) => {\n  try {\n    if (props.channelType === 'gemini') {\n      await api.setGeminiChannelStatus(channelId, status)\n    } else if (props.channelType === 'responses') {\n      await api.setResponsesChannelStatus(channelId, status)\n    } else {\n      await api.setChannelStatus(channelId, status)\n    }\n    emit('refresh')\n  } catch (error) {\n    console.error('Failed to set channel status:', error)\n    const errorMessage = error instanceof Error ? error.message : '未知错误'\n    emit('error', `设置渠道状态失败: ${errorMessage}`)\n  }\n}\n\n// 启用渠道（从备用池移到活跃序列）\nconst enableChannel = async (channelId: number) => {\n  await setChannelStatus(channelId, 'active')\n}\n\n// 恢复渠道（重置指标并设为 active）\nconst resumeChannel = async (channelId: number) => {\n  try {\n    if (props.channelType === 'gemini') {\n      await api.resumeGeminiChannel(channelId)\n    } else if (props.channelType === 'responses') {\n      await api.resumeResponsesChannel(channelId)\n    } else {\n      await api.resumeChannel(channelId)\n    }\n    await setChannelStatus(channelId, 'active')\n  } catch (error) {\n    console.error('Failed to resume channel:', error)\n  }\n}\n\n// 设置渠道促销期（抢优先级）\nconst setPromotion = async (channel: Channel) => {\n  try {\n    const PROMOTION_DURATION = 300 // 5分钟\n\n    // 如果渠道是熔断状态，先恢复它\n    if (channel.status === 'suspended') {\n      if (props.channelType === 'gemini') {\n        await api.resumeGeminiChannel(channel.index)\n      } else if (props.channelType === 'responses') {\n        await api.resumeResponsesChannel(channel.index)\n      } else {\n        await api.resumeChannel(channel.index)\n      }\n      await setChannelStatus(channel.index, 'active')\n    }\n\n    if (props.channelType === 'gemini') {\n      await api.setGeminiChannelPromotion(channel.index, PROMOTION_DURATION)\n    } else if (props.channelType === 'responses') {\n      await api.setResponsesChannelPromotion(channel.index, PROMOTION_DURATION)\n    } else {\n      await api.setChannelPromotion(channel.index, PROMOTION_DURATION)\n    }\n    emit('refresh')\n    // 通知用户\n    emit('success', `渠道 ${channel.name} 已设为最高优先级，5分钟内优先使用`)\n  } catch (error) {\n    console.error('Failed to set promotion:', error)\n    const errorMessage = error instanceof Error ? error.message : '未知错误'\n    emit('error', `设置优先级失败: ${errorMessage}`)\n  }\n}\n\n// 判断渠道是否可以删除\n// 规则：故障转移序列中至少要保留一个 active 状态的渠道\nconst canDeleteChannel = (channel: Channel): boolean => {\n  // 统计当前 active 状态的渠道数量\n  const activeCount = activeChannels.value.filter(\n    ch => ch.status === 'active' || ch.status === undefined || ch.status === ''\n  ).length\n\n  // 如果要删除的是 active 渠道，且只剩一个 active，则不允许删除\n  const isActive = channel.status === 'active' || channel.status === undefined || channel.status === ''\n  if (isActive && activeCount <= 1) {\n    return false\n  }\n\n  return true\n}\n\n// 处理删除渠道\nconst handleDeleteChannel = (channel: Channel) => {\n  if (!canDeleteChannel(channel)) {\n    emit('error', '无法删除：故障转移序列中至少需要保留一个活跃渠道')\n    return\n  }\n  emit('delete', channel.index)\n}\n\n// 组件挂载时加载指标并启动延迟过期检查定时器\nonMounted(() => {\n  refreshMetrics()\n  // 每 30 秒更新一次 currentTime，触发延迟显示的响应式更新\n  latencyCheckTimer = setInterval(() => {\n    currentTime.value = Date.now()\n  }, 30000)\n  // 每 2 秒更新一次 activityUpdateTick，触发活跃度视图更新\n  activityUpdateTimer = setInterval(() => {\n    activityUpdateTick.value++\n  }, 2000)\n})\n\n// 组件卸载时清理定时器\nonUnmounted(() => {\n  if (latencyCheckTimer) {\n    clearInterval(latencyCheckTimer)\n    latencyCheckTimer = null\n  }\n  if (activityUpdateTimer) {\n    clearInterval(activityUpdateTimer)\n    activityUpdateTimer = null\n  }\n})\n\n// 暴露方法给父组件\ndefineExpose({\n  refreshMetrics\n})\n</script>\n\n<style scoped>\n/* =====================================================\n   🎮 渠道编排 - 复古像素主题样式\n   Neo-Brutalism: 直角、粗黑边框、硬阴影\n   ===================================================== */\n\n.channel-orchestration {\n  overflow: hidden;\n  background: transparent;\n  border: none;\n}\n\n.channel-list {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n.channel-item-wrapper {\n  display: flex;\n  flex-direction: column;\n}\n\n.channel-row {\n  position: relative;\n  padding: 10px 12px;\n  margin: 2px;\n  background: rgb(var(--v-theme-surface));\n  border: 2px solid rgb(var(--v-theme-on-surface));\n  box-shadow: 4px 4px 0 0 rgb(var(--v-theme-on-surface));\n  min-height: 52px;\n  transition: all 0.1s ease;\n  cursor: pointer;\n  overflow: hidden;\n}\n\n/* Grid 内容容器 */\n.channel-row-content {\n  display: grid;\n  grid-template-columns: 28px 28px 90px minmax(120px, 1fr) auto 50px 50px 50px auto;\n  align-items: center;\n  gap: 6px;\n  position: relative;\n  z-index: 1;\n}\n\n/* SVG 活跃度波形柱状图背景 */\n.activity-chart-bg {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  pointer-events: none;\n  z-index: 0;\n}\n\n/* 柱状图无动画：避免数据更新时的缩小-增长抖动效果 */\n.activity-bar {\n  transition: none;\n}\n\n/* 图表展开区域 */\n.channel-chart-wrapper {\n  margin: 0 2px 8px 2px;\n}\n\n.channel-row:hover {\n  background: rgba(var(--v-theme-primary), 0.08);\n  transform: translate(-2px, -2px);\n  box-shadow: 6px 6px 0 0 rgb(var(--v-theme-on-surface));\n  border: 2px solid rgb(var(--v-theme-on-surface));\n}\n\n.channel-row:active {\n  transform: translate(2px, 2px);\n  box-shadow: none;\n}\n\n.v-theme--dark .channel-row {\n  background: rgb(var(--v-theme-surface));\n  border-color: rgba(255, 255, 255, 0.7);\n  box-shadow: 4px 4px 0 0 rgba(255, 255, 255, 0.7);\n}\n.v-theme--dark .channel-row:hover {\n  background: rgba(var(--v-theme-primary), 0.12);\n  box-shadow: 6px 6px 0 0 rgba(255, 255, 255, 0.7);\n  border-color: rgba(255, 255, 255, 0.7);\n}\n\n/* suspended 状态的视觉区分 */\n.channel-row.is-suspended {\n  background: rgba(var(--v-theme-warning), 0.1);\n  border-color: rgb(var(--v-theme-warning));\n  box-shadow: 4px 4px 0 0 rgb(var(--v-theme-on-surface));\n}\n.channel-row.is-suspended:hover {\n  background: rgba(var(--v-theme-warning), 0.15);\n  box-shadow: 6px 6px 0 0 rgb(var(--v-theme-on-surface));\n}\n\n.v-theme--dark .channel-row.is-suspended {\n  box-shadow: 4px 4px 0 0 rgba(255, 255, 255, 0.7);\n}\n\n.v-theme--dark .channel-row.is-suspended:hover {\n  box-shadow: 6px 6px 0 0 rgba(255, 255, 255, 0.7);\n}\n\n.channel-row.ghost {\n  opacity: 0.6;\n  background: rgba(var(--v-theme-primary), 0.15);\n  border: 2px dashed rgb(var(--v-theme-primary));\n  box-shadow: none;\n}\n\n.drag-handle {\n  cursor: grab;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 32px;\n  height: 32px;\n  transition: all 0.1s ease;\n}\n\n.drag-handle:hover {\n  background: rgba(var(--v-theme-on-surface), 0.1);\n}\n\n.drag-handle:active {\n  cursor: grabbing;\n  background: rgba(var(--v-theme-primary), 0.2);\n}\n\n.priority-number {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 28px;\n  height: 28px;\n  background: rgb(var(--v-theme-primary));\n  color: white;\n  font-size: 12px;\n  font-weight: 700;\n  border: 2px solid rgb(var(--v-theme-on-surface));\n  text-transform: uppercase;\n}\n\n.v-theme--dark .priority-number {\n  border-color: rgba(255, 255, 255, 0.6);\n}\n\n.channel-name {\n  display: flex;\n  align-items: center;\n  overflow: hidden;\n}\n\n.channel-name .expand-icon {\n  flex-shrink: 0;\n}\n\n.channel-name .font-weight-medium {\n  font-size: 0.95rem;\n  flex-shrink: 0;\n}\n\n/* 描述文本限制最多两行 */\n.channel-description {\n  display: -webkit-box;\n  -webkit-line-clamp: 2;\n  -webkit-box-orient: vertical;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  line-height: 1.4;\n  max-height: calc(1.4em * 2);\n  word-break: break-word;\n}\n\n.channel-name-link {\n  cursor: pointer;\n  transition: all 0.15s ease;\n}\n\n.channel-name-link:hover,\n.channel-name-link:focus {\n  color: rgb(var(--v-theme-primary));\n  text-decoration: underline;\n  outline: none;\n}\n\n.channel-name-link:focus-visible {\n  outline: 2px solid rgb(var(--v-theme-primary));\n  outline-offset: 2px;\n  border-radius: 2px;\n}\n\n.channel-metrics {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  flex-wrap: nowrap;\n  white-space: nowrap;\n}\n\n.channel-latency {\n  display: flex;\n  align-items: center;\n  min-width: 60px;\n}\n\n/* RPM/TPM 显示样式 */\n.channel-rpm-tpm {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  min-width: 60px;\n  margin-left: 8px;\n}\n\n.rpm-tpm-values {\n  display: flex;\n  align-items: baseline;\n  gap: 2px;\n  font-size: 13px;\n  font-weight: 600;\n  color: rgba(var(--v-theme-on-surface), 0.6);\n}\n\n.rpm-tpm-values .rpm-value.has-data,\n.rpm-tpm-values .tpm-value.has-data {\n  color: rgb(var(--v-theme-primary));\n}\n\n.rpm-tpm-separator {\n  color: rgba(var(--v-theme-on-surface), 0.3);\n  font-weight: 400;\n}\n\n.rpm-tpm-labels {\n  display: flex;\n  align-items: center;\n  gap: 2px;\n  font-size: 9px;\n  color: rgba(var(--v-theme-on-surface), 0.5);\n  text-transform: uppercase;\n  letter-spacing: 0.5px;\n}\n\n.channel-keys {\n  display: flex;\n  align-items: center;\n}\n\n.channel-keys .keys-chip {\n  cursor: pointer;\n  transition: all 0.1s ease;\n}\n\n.channel-keys .keys-chip:hover {\n  background: rgba(var(--v-theme-primary), 0.1);\n  border-color: rgb(var(--v-theme-primary));\n  color: rgb(var(--v-theme-primary));\n}\n\n.channel-actions {\n  display: flex;\n  align-items: center;\n  gap: 2px;\n  justify-content: flex-end;\n  min-width: 50px;\n}\n\n/* 备用资源池样式 */\n.inactive-pool-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  margin-bottom: 8px;\n}\n\n.inactive-pool {\n  display: grid;\n  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));\n  gap: 10px;\n  background: rgb(var(--v-theme-surface));\n  padding: 16px;\n  border: 2px dashed rgb(var(--v-theme-on-surface));\n}\n\n.v-theme--dark .inactive-pool {\n  background: rgb(var(--v-theme-surface));\n  border-color: rgba(255, 255, 255, 0.5);\n}\n\n.inactive-channel-row {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 12px;\n  padding: 10px 14px;\n  background: rgb(var(--v-theme-surface));\n  border: 2px solid rgb(var(--v-theme-on-surface));\n  box-shadow: 3px 3px 0 0 rgb(var(--v-theme-on-surface));\n  transition: all 0.1s ease;\n}\n\n.inactive-channel-row:hover {\n  background: rgba(var(--v-theme-primary), 0.08);\n  transform: translate(-1px, -1px);\n  box-shadow: 4px 4px 0 0 rgb(var(--v-theme-on-surface));\n}\n\n.inactive-channel-row:active {\n  transform: translate(2px, 2px);\n  box-shadow: none;\n}\n\n.v-theme--dark .inactive-channel-row {\n  background: rgb(var(--v-theme-surface));\n  border-color: rgba(255, 255, 255, 0.6);\n  box-shadow: 3px 3px 0 0 rgba(255, 255, 255, 0.6);\n}\n\n.v-theme--dark .inactive-channel-row:hover {\n  background: rgba(var(--v-theme-primary), 0.12);\n  box-shadow: 4px 4px 0 0 rgba(255, 255, 255, 0.6);\n}\n\n.inactive-channel-row .channel-info {\n  flex: 1;\n  min-width: 0;\n  overflow: hidden;\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n}\n\n.inactive-channel-row .channel-info-main {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.inactive-channel-row .channel-info-desc {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  line-height: 1.3;\n  max-width: 100%;\n}\n\n.inactive-channel-row .channel-actions {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n}\n\n/* 响应式调整 */\n@media (max-width: 1400px) {\n  .channel-row-content {\n    grid-template-columns: 28px 28px 85px minmax(100px, 1fr) auto 45px 45px 45px auto;\n    gap: 5px;\n  }\n  .channel-row {\n    padding: 10px 10px;\n  }\n}\n\n@media (max-width: 1200px) {\n  .channel-row-content {\n    grid-template-columns: 26px 26px 80px minmax(80px, 1fr) auto 40px 40px 40px auto;\n    gap: 4px;\n  }\n  .channel-row {\n    padding: 8px 8px;\n  }\n\n  .rpm-tpm-values {\n    font-size: 11px;\n  }\n\n  .rpm-tpm-labels {\n    font-size: 8px;\n  }\n}\n\n@media (max-width: 960px) {\n  .channel-row-content {\n    grid-template-columns: 26px 26px 75px minmax(60px, 1fr) auto 38px 38px 38px auto;\n    gap: 4px;\n  }\n  .channel-row {\n    padding: 8px 6px;\n  }\n}\n\n@media (max-width: 600px) {\n  .channel-row-content {\n    grid-template-columns: 28px 1fr 60px;\n    gap: 8px;\n  }\n  .channel-row {\n    padding: 10px;\n    box-shadow: 3px 3px 0 0 rgb(var(--v-theme-on-surface));\n  }\n\n  .channel-metrics,\n  .channel-latency,\n  .channel-keys,\n  .channel-rpm-tpm {\n    display: none;\n  }\n\n  .v-theme--dark .channel-row {\n    box-shadow: 3px 3px 0 0 rgba(255, 255, 255, 0.6);\n  }\n\n  .priority-number,\n  .drag-handle {\n    display: none;\n  }\n}\n\n/* 指标显示样式 */\n.metrics-display {\n  cursor: help;\n}\n\n/* 指标 tooltip 样式 */\n.metrics-tooltip {\n  font-size: 12px;\n  line-height: 1.5;\n  color: rgb(var(--v-theme-on-surface));\n}\n\n.metrics-tooltip-row {\n  display: flex;\n  justify-content: space-between;\n  gap: 16px;\n  padding: 2px 0;\n}\n\n.metrics-tooltip-row span:first-child {\n  color: rgba(var(--v-theme-on-surface), 0.7);\n}\n\n.metrics-tooltip-row span:last-child {\n  font-weight: 500;\n  color: rgb(var(--v-theme-on-surface));\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/ChannelStatusBadge.vue",
    "content": "<template>\n  <div class=\"status-badge\" :class=\"[statusClass, { 'has-metrics': showMetrics }]\">\n    <v-tooltip location=\"top\" content-class=\"status-tooltip\">\n      <template #activator=\"{ props: tooltipProps }\">\n        <div class=\"badge-content\" v-bind=\"tooltipProps\">\n          <v-icon :size=\"iconSize\" class=\"status-icon\">{{ statusIcon }}</v-icon>\n          <span v-if=\"showLabel\" class=\"status-label\">{{ statusLabel }}</span>\n        </div>\n      </template>\n      <div class=\"tooltip-content\">\n        <div class=\"font-weight-bold mb-1\">{{ statusLabel }}</div>\n        <template v-if=\"metrics\">\n          <div class=\"text-caption\">\n            <div>请求数: {{ metrics.requestCount }}</div>\n            <div>成功率: {{ metrics.successRate?.toFixed(1) || 0 }}%</div>\n            <div>连续失败: {{ metrics.consecutiveFailures }}</div>\n            <div v-if=\"metrics.lastSuccessAt\">最后成功: {{ formatTime(metrics.lastSuccessAt) }}</div>\n            <div v-if=\"metrics.lastFailureAt\">最后失败: {{ formatTime(metrics.lastFailureAt) }}</div>\n          </div>\n        </template>\n        <div v-else class=\"text-caption text-medium-emphasis\">暂无指标数据</div>\n      </div>\n    </v-tooltip>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport type { ChannelStatus, ChannelMetrics } from '../services/api'\n\nconst props = withDefaults(defineProps<{\n  status: ChannelStatus | 'healthy' | 'error' | 'unknown'\n  metrics?: ChannelMetrics\n  showLabel?: boolean\n  size?: 'small' | 'default' | 'large'\n}>(), {\n  showLabel: true,\n  size: 'default'\n})\n\n// 状态配置映射\nconst STATUS_CONFIG: Record<string, { icon: string; color: string; label: string; class: string }> = {\n  active: {\n    icon: 'mdi-check-circle',\n    color: 'success',\n    label: '活跃',\n    class: 'status-active'\n  },\n  healthy: {\n    icon: 'mdi-check-circle',\n    color: 'success',\n    label: '健康',\n    class: 'status-active'\n  },\n  suspended: {\n    icon: 'mdi-pause-circle',\n    color: 'warning',\n    label: '熔断',\n    class: 'status-suspended'\n  },\n  disabled: {\n    icon: 'mdi-close-circle',\n    color: 'error',\n    label: '禁用',\n    class: 'status-disabled'\n  },\n  error: {\n    icon: 'mdi-alert-circle',\n    color: 'error',\n    label: '错误',\n    class: 'status-error'\n  },\n  unknown: {\n    icon: 'mdi-help-circle',\n    color: 'grey',\n    label: '未知',\n    class: 'status-unknown'\n  }\n}\n\n// 计算属性\nconst statusConfig = computed(() => {\n  return STATUS_CONFIG[props.status] || STATUS_CONFIG.unknown\n})\n\nconst statusIcon = computed(() => statusConfig.value.icon)\nconst statusLabel = computed(() => statusConfig.value.label)\nconst statusClass = computed(() => statusConfig.value.class)\n\nconst iconSize = computed(() => {\n  switch (props.size) {\n    case 'small': return 16\n    case 'large': return 24\n    default: return 20\n  }\n})\n\nconst showMetrics = computed(() => !!props.metrics)\n\n// 格式化时间\nconst formatTime = (dateStr: string): string => {\n  const date = new Date(dateStr)\n  const now = new Date()\n  const diff = now.getTime() - date.getTime()\n\n  if (diff < 60000) {\n    return '刚刚'\n  } else if (diff < 3600000) {\n    return `${Math.floor(diff / 60000)} 分钟前`\n  } else if (diff < 86400000) {\n    return `${Math.floor(diff / 3600000)} 小时前`\n  } else {\n    return date.toLocaleDateString()\n  }\n}\n</script>\n\n<style scoped>\n/* =====================================================\n   🎮 状态徽章 - 复古像素主题样式\n   Neo-Brutalism: 直角、实体边框、高对比度\n   ===================================================== */\n\n.status-badge {\n  display: inline-flex;\n  align-items: center;\n  position: relative;\n}\n\n.badge-content {\n  display: inline-flex;\n  align-items: center;\n  gap: 4px;\n  padding: 4px 8px;\n  background: rgb(var(--v-theme-surface));\n  border: 1px solid rgb(var(--v-theme-on-surface));\n  cursor: help;\n  transition: all 0.1s ease;\n}\n\n.v-theme--dark .badge-content {\n  border-color: rgba(255, 255, 255, 0.6);\n}\n\n.badge-content:hover {\n  background: rgba(var(--v-theme-surface-variant), 0.8);\n}\n\n.status-label {\n  font-size: 11px;\n  font-weight: 600;\n  text-transform: uppercase;\n  letter-spacing: 0.5px;\n}\n\n/* 状态样式 - 高对比度实心边框 */\n.status-active .badge-content {\n  background: #bbf7d0;\n  color: #166534;\n  border-color: #166534;\n}\n\n.status-active .badge-content .status-icon {\n  color: #166534 !important;\n}\n\n.v-theme--dark .status-active .badge-content {\n  background: #166534;\n  color: #bbf7d0;\n  border-color: #bbf7d0;\n}\n\n.v-theme--dark .status-active .badge-content .status-icon {\n  color: #bbf7d0 !important;\n}\n\n.status-suspended .badge-content {\n  background: #fef3c7;\n  color: #92400e;\n  border-color: #92400e;\n  animation: pixel-blink 1.5s step-end infinite;\n}\n\n.status-suspended .badge-content .status-icon {\n  color: #92400e !important;\n}\n\n.v-theme--dark .status-suspended .badge-content {\n  background: #92400e;\n  color: #fef3c7;\n  border-color: #fef3c7;\n}\n\n.v-theme--dark .status-suspended .badge-content .status-icon {\n  color: #fef3c7 !important;\n}\n\n.status-disabled .badge-content {\n  background: #e5e7eb;\n  color: #6b7280;\n  border-color: #6b7280;\n}\n\n.status-disabled .badge-content .status-icon {\n  color: #6b7280 !important;\n}\n\n.v-theme--dark .status-disabled .badge-content {\n  background: #374151;\n  color: #9ca3af;\n  border-color: #9ca3af;\n}\n\n.v-theme--dark .status-disabled .badge-content .status-icon {\n  color: #9ca3af !important;\n}\n\n.status-error .badge-content {\n  background: #fecaca;\n  color: #991b1b;\n  border-color: #991b1b;\n}\n\n.status-error .badge-content .status-icon {\n  color: #991b1b !important;\n}\n\n.v-theme--dark .status-error .badge-content {\n  background: #991b1b;\n  color: #fecaca;\n  border-color: #fecaca;\n}\n\n.v-theme--dark .status-error .badge-content .status-icon {\n  color: #fecaca !important;\n}\n\n.status-unknown .badge-content {\n  background: #e5e7eb;\n  color: #6b7280;\n  border-color: #6b7280;\n}\n\n.status-unknown .badge-content .status-icon {\n  color: #6b7280 !important;\n}\n\n.v-theme--dark .status-unknown .badge-content {\n  background: #374151;\n  color: #9ca3af;\n  border-color: #9ca3af;\n}\n\n.v-theme--dark .status-unknown .badge-content .status-icon {\n  color: #9ca3af !important;\n}\n\n/* 手机端隐藏状态文字，改为像素点样式 */\n@media (max-width: 600px) {\n  .status-label {\n    display: none;\n  }\n\n  .badge-content {\n    padding: 0;\n    background: transparent !important;\n    border: none !important;\n  }\n\n  .badge-content .v-icon {\n    font-size: 0 !important;\n    width: 10px;\n    height: 10px;\n    margin-right: 10px;\n    position: relative;\n  }\n\n  /* 活跃状态 - 绿色像素点 */\n  .status-active .badge-content .v-icon {\n    background: #10b981;\n    border: 2px solid #065f46;\n  }\n\n  .status-active .badge-content .v-icon::after {\n    content: '';\n    position: absolute;\n    top: -3px;\n    left: -3px;\n    width: 14px;\n    height: 14px;\n    background: rgba(16, 185, 129, 0.3);\n    animation: pixel-pulse 1s step-end infinite;\n  }\n\n  /* 熔断状态 - 橙色像素点 */\n  .status-suspended .badge-content .v-icon {\n    background: #f59e0b;\n    border: 2px solid #92400e;\n  }\n\n  .status-suspended .badge-content .v-icon::after {\n    content: '';\n    position: absolute;\n    top: -3px;\n    left: -3px;\n    width: 14px;\n    height: 14px;\n    background: rgba(245, 158, 11, 0.3);\n    animation: pixel-pulse 0.75s step-end infinite;\n  }\n\n  /* 禁用状态 - 灰色像素点 */\n  .status-disabled .badge-content .v-icon,\n  .status-unknown .badge-content .v-icon {\n    background: #94a3b8;\n    border: 2px solid #475569;\n  }\n\n  @keyframes pixel-pulse {\n    0%, 100% {\n      opacity: 1;\n    }\n    50% {\n      opacity: 0.4;\n    }\n  }\n}\n\n/* 像素风格闪烁动画 */\n@keyframes pixel-blink {\n  0%, 100% {\n    opacity: 1;\n  }\n  50% {\n    opacity: 0.6;\n  }\n}\n\n.tooltip-content {\n  max-width: 200px;\n}\n</style>\n\n<!-- 非 scoped 样式 - 用于 teleport 到 body 的 tooltip -->\n<style>\n/* Status tooltip 样式 - 复古像素主题 */\n.status-tooltip {\n  background: #f5f5f5 !important;\n  color: #1a1a1a !important;\n  border: 1px solid #333 !important;\n  border-radius: 0 !important;\n  box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.2) !important;\n  padding: 8px 12px !important;\n}\n\n.v-theme--dark .status-tooltip {\n  background: #2d2d2d !important;\n  color: #f5f5f5 !important;\n  border-color: #555 !important;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/GlobalStatsChart.vue",
    "content": "<template>\n  <div class=\"global-stats-chart-container\">\n    <!-- Snackbar for error notification -->\n    <v-snackbar v-model=\"showError\" color=\"error\" :timeout=\"3000\" location=\"top\">\n      {{ errorMessage }}\n      <template #actions>\n        <v-btn variant=\"text\" @click=\"showError = false\">关闭</v-btn>\n      </template>\n    </v-snackbar>\n\n    <!-- Header: Duration selector + View switcher -->\n    <div class=\"chart-header d-flex align-center justify-space-between mb-3 flex-wrap ga-2\">\n      <div class=\"d-flex align-center ga-2\">\n        <!-- Duration selector -->\n        <v-btn-toggle v-model=\"selectedDuration\" mandatory density=\"compact\" variant=\"outlined\" divided :disabled=\"isLoading\">\n          <v-btn value=\"1h\" size=\"x-small\">1小时</v-btn>\n          <v-btn value=\"6h\" size=\"x-small\">6小时</v-btn>\n          <v-btn value=\"24h\" size=\"x-small\">24小时</v-btn>\n          <v-btn value=\"today\" size=\"x-small\">今日</v-btn>\n        </v-btn-toggle>\n\n        <v-btn icon size=\"x-small\" variant=\"text\" :loading=\"isLoading\" :disabled=\"isLoading\" @click=\"refreshData\">\n          <v-icon size=\"small\">mdi-refresh</v-icon>\n        </v-btn>\n      </div>\n\n      <!-- View switcher -->\n      <v-btn-toggle v-model=\"selectedView\" mandatory density=\"compact\" variant=\"outlined\" divided :disabled=\"isLoading\">\n        <v-btn value=\"traffic\" size=\"x-small\">\n          <v-icon size=\"small\" class=\"mr-1\">mdi-chart-line</v-icon>\n          流量\n        </v-btn>\n        <v-btn value=\"tokens\" size=\"x-small\">\n          <v-icon size=\"small\" class=\"mr-1\">mdi-chart-areaspline</v-icon>\n          Token\n        </v-btn>\n      </v-btn-toggle>\n    </div>\n\n    <!-- Summary cards -->\n    <div v-if=\"summary && !compact\" class=\"summary-cards d-flex flex-wrap ga-2 mb-3\">\n      <div class=\"summary-card\">\n        <div class=\"summary-label\">总请求</div>\n        <div class=\"summary-value\">{{ formatNumber(summary.totalRequests) }}</div>\n      </div>\n      <div class=\"summary-card\">\n        <div class=\"summary-label\">成功率</div>\n        <div class=\"summary-value\" :class=\"{ 'text-success': summary.avgSuccessRate >= 95, 'text-warning': summary.avgSuccessRate >= 80 && summary.avgSuccessRate < 95, 'text-error': summary.avgSuccessRate < 80 }\">\n          {{ summary.avgSuccessRate.toFixed(1) }}%\n        </div>\n      </div>\n      <div class=\"summary-card\">\n        <div class=\"summary-label\">输入 Token</div>\n        <div class=\"summary-value\">{{ formatNumber(summary.totalInputTokens) }}</div>\n      </div>\n      <div class=\"summary-card\">\n        <div class=\"summary-label\">输出 Token</div>\n        <div class=\"summary-value\">{{ formatNumber(summary.totalOutputTokens) }}</div>\n      </div>\n    </div>\n\n    <!-- Compact summary (single line) -->\n    <div v-if=\"summary && compact\" class=\"compact-summary d-flex align-center ga-3 mb-2 text-caption\">\n      <span><strong>{{ formatNumber(summary.totalRequests) }}</strong> 请求</span>\n      <span :class=\"{ 'text-success': summary.avgSuccessRate >= 95, 'text-warning': summary.avgSuccessRate >= 80 && summary.avgSuccessRate < 95, 'text-error': summary.avgSuccessRate < 80 }\">\n        <strong>{{ summary.avgSuccessRate.toFixed(1) }}%</strong> 成功\n      </span>\n      <span><strong>{{ formatNumber(summary.totalInputTokens) }}</strong> 输入</span>\n      <span><strong>{{ formatNumber(summary.totalOutputTokens) }}</strong> 输出</span>\n    </div>\n\n    <!-- Loading state -->\n    <div v-if=\"isLoading\" class=\"d-flex justify-center align-center\" :style=\"{ height: chartHeight + 'px' }\">\n      <v-progress-circular indeterminate size=\"32\" color=\"primary\" />\n    </div>\n\n    <!-- Empty state -->\n    <div v-else-if=\"!hasData\" class=\"d-flex flex-column justify-center align-center text-medium-emphasis\" :style=\"{ height: chartHeight + 'px' }\">\n      <v-icon size=\"40\" color=\"grey-lighten-1\">mdi-chart-timeline-variant</v-icon>\n      <div class=\"text-caption mt-2\">选定时间范围内没有请求记录</div>\n    </div>\n\n    <!-- Chart -->\n    <div v-else class=\"chart-area\">\n      <apexchart\n        ref=\"chartRef\"\n        type=\"area\"\n        :height=\"chartHeight\"\n        :options=\"chartOptions\"\n        :series=\"chartSeries\"\n      />\n    </div>\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, watch, onMounted, onUnmounted } from 'vue'\nimport { useTheme } from 'vuetify'\nimport VueApexCharts from 'vue3-apexcharts'\nimport type { ApexOptions } from 'apexcharts'\nimport { api, type GlobalStatsHistoryResponse, type GlobalHistoryDataPoint as _GlobalHistoryDataPoint, type GlobalStatsSummary } from '../services/api'\n\n// Register apexchart component\nconst apexchart = VueApexCharts\n\n// Props\nconst props = withDefaults(defineProps<{\n  apiType: 'messages' | 'responses' | 'gemini'\n  compact?: boolean\n}>(), {\n  compact: false\n})\n\n// Types\ntype ViewMode = 'traffic' | 'tokens'\ntype Duration = '1h' | '6h' | '24h' | 'today'\n\n// LocalStorage keys for preferences (per apiType)\nconst getStorageKey = (apiType: string, key: string) => `globalStats:${apiType}:${key}`\n\n// Load saved preferences from localStorage (per apiType)\nconst loadSavedPreferences = (apiType: string) => {\n  const savedView = localStorage.getItem(getStorageKey(apiType, 'viewMode')) as ViewMode | null\n  const savedDuration = localStorage.getItem(getStorageKey(apiType, 'duration')) as Duration | null\n  return {\n    view: savedView && ['traffic', 'tokens'].includes(savedView) ? savedView : 'traffic',\n    duration: savedDuration && ['1h', '6h', '24h', 'today'].includes(savedDuration) ? savedDuration : '6h'\n  }\n}\n\n// Save preference to localStorage\nconst savePreference = (apiType: string, key: string, value: string) => {\n  localStorage.setItem(getStorageKey(apiType, key), value)\n}\n\n// Theme\nconst theme = useTheme()\nconst isDark = computed(() => theme.global.current.value.dark)\n\n// Load saved preferences for current apiType\nconst savedPrefs = loadSavedPreferences(props.apiType)\n\n// State (initialized from saved preferences)\nconst selectedView = ref<ViewMode>(savedPrefs.view)\nconst selectedDuration = ref<Duration>(savedPrefs.duration)\nconst isLoading = ref(false)\nconst historyData = ref<GlobalStatsHistoryResponse | null>(null)\nconst showError = ref(false)\nconst errorMessage = ref('')\n\n// Chart ref for updateSeries\nconst chartRef = ref<InstanceType<typeof VueApexCharts> | null>(null)\n\n// Auto refresh timer (2 seconds interval, same as KeyTrendChart)\nconst AUTO_REFRESH_INTERVAL = 2000\nlet autoRefreshTimer: ReturnType<typeof setInterval> | null = null\n\nconst startAutoRefresh = () => {\n  stopAutoRefresh()\n  autoRefreshTimer = setInterval(() => {\n    if (!isLoading.value) {\n      refreshData(true)\n    }\n  }, AUTO_REFRESH_INTERVAL)\n}\n\nconst stopAutoRefresh = () => {\n  if (autoRefreshTimer) {\n    clearInterval(autoRefreshTimer)\n    autoRefreshTimer = null\n  }\n}\n\n// Chart height based on compact mode\nconst chartHeight = computed(() => props.compact ? 180 : 260)\n\n// Summary data\nconst summary = computed<GlobalStatsSummary | null>(() => historyData.value?.summary || null)\n\n// Check if has data\nconst hasData = computed(() => {\n  if (!historyData.value?.dataPoints) return false\n  return historyData.value.dataPoints.length > 0 &&\n    historyData.value.dataPoints.some(dp => dp.requestCount > 0)\n})\n\n// Chart colors\nconst chartColors = {\n  traffic: {\n    primary: '#3b82f6',    // Blue for requests\n    success: '#10b981',    // Green for success\n    failure: '#ef4444'     // Red for failure\n  },\n  tokens: {\n    input: '#8b5cf6',      // Purple for input\n    output: '#f97316'      // Orange for output\n  }\n}\n\n// Format number for display\nconst formatNumber = (num: number): string => {\n  if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'\n  if (num >= 1000) return (num / 1000).toFixed(1) + 'K'\n  return num.toFixed(0)\n}\n\n// Chart options\nconst chartOptions = computed<ApexOptions>(() => {\n  const mode = selectedView.value\n\n  return {\n    chart: {\n      toolbar: { show: false },\n      zoom: { enabled: false },\n      background: 'transparent',\n      fontFamily: 'inherit',\n      animations: {\n        enabled: true,\n        speed: 400,\n        animateGradually: { enabled: true, delay: 150 },\n        dynamicAnimation: { enabled: true, speed: 350 }\n      }\n    },\n    theme: {\n      mode: isDark.value ? 'dark' : 'light'\n    },\n    colors: mode === 'traffic'\n      ? [chartColors.traffic.primary, chartColors.traffic.success]\n      : [chartColors.tokens.input, chartColors.tokens.output],\n    fill: {\n      type: 'gradient' as const,\n      gradient: {\n        shadeIntensity: 1,\n        opacityFrom: 0.4,\n        opacityTo: 0.08,\n        stops: [0, 90, 100]\n      }\n    },\n    dataLabels: {\n      enabled: false\n    },\n    stroke: {\n      curve: 'smooth' as const,\n      width: 2,\n      dashArray: mode === 'tokens' ? [0, 5] : [0, 0]\n    },\n    grid: {\n      borderColor: isDark.value ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',\n      padding: { left: 10, right: 10 }\n    },\n    xaxis: {\n      type: 'datetime',\n      labels: {\n        datetimeUTC: false,\n        format: 'HH:mm',\n        style: { fontSize: '10px' }\n      },\n      axisBorder: { show: false },\n      axisTicks: { show: false }\n    },\n    yaxis: mode === 'tokens' ? [\n      {\n        seriesName: '输入 Token',\n        labels: {\n          formatter: (val: number) => formatNumber(val),\n          style: { fontSize: '11px' }\n        },\n        min: 0\n      },\n      {\n        seriesName: '输出 Token',\n        opposite: true,\n        labels: {\n          formatter: (val: number) => formatNumber(val),\n          style: { fontSize: '11px' }\n        },\n        min: 0\n      }\n    ] : {\n      labels: {\n        formatter: (val: number) => Math.round(val).toString(),\n        style: { fontSize: '11px' }\n      },\n      min: 0\n    },\n    tooltip: {\n      x: {\n        format: 'MM-dd HH:mm'\n      },\n      y: {\n        formatter: (val: number) => mode === 'traffic'\n          ? `${Math.round(val)} 请求`\n          : formatNumber(val)\n      }\n    },\n    legend: {\n      show: true,\n      position: 'top' as const,\n      horizontalAlign: 'right' as const,\n      fontSize: '11px',\n      markers: { size: 4 }\n    }\n  }\n})\n\n// Build chart series\nconst chartSeries = computed(() => {\n  if (!historyData.value?.dataPoints) return []\n\n  const dataPoints = historyData.value.dataPoints\n  const mode = selectedView.value\n\n  if (mode === 'traffic') {\n    return [\n      {\n        name: '总请求',\n        data: dataPoints.map(dp => ({\n          x: new Date(dp.timestamp).getTime(),\n          y: dp.requestCount\n        }))\n      },\n      {\n        name: '成功',\n        data: dataPoints.map(dp => ({\n          x: new Date(dp.timestamp).getTime(),\n          y: dp.successCount\n        }))\n      }\n    ]\n  } else {\n    return [\n      {\n        name: '输入 Token',\n        data: dataPoints.map(dp => ({\n          x: new Date(dp.timestamp).getTime(),\n          y: dp.inputTokens\n        }))\n      },\n      {\n        name: '输出 Token',\n        data: dataPoints.map(dp => ({\n          x: new Date(dp.timestamp).getTime(),\n          y: dp.outputTokens\n        }))\n      }\n    ]\n  }\n})\n\n// Fetch data\nconst refreshData = async (isAutoRefresh = false) => {\n  if (!isAutoRefresh) {\n    isLoading.value = true\n  }\n  errorMessage.value = ''\n\n  try {\n    let newData: GlobalStatsHistoryResponse\n    if (props.apiType === 'messages') {\n      newData = await api.getMessagesGlobalStats(selectedDuration.value)\n    } else if (props.apiType === 'gemini') {\n      newData = await api.getGeminiGlobalStats(selectedDuration.value)\n    } else {\n      newData = await api.getResponsesGlobalStats(selectedDuration.value)\n    }\n\n    // Check if we can use updateSeries for smooth update\n    const canUpdateInPlace = isAutoRefresh &&\n      chartRef.value &&\n      historyData.value?.dataPoints?.length === newData.dataPoints?.length\n\n    if (canUpdateInPlace) {\n      historyData.value = newData\n      const series = chartSeries.value\n      chartRef.value?.updateSeries(series, false)\n    } else {\n      historyData.value = newData\n    }\n  } catch (error) {\n    console.error('Failed to fetch global stats:', error)\n    errorMessage.value = error instanceof Error ? error.message : '获取全局统计数据失败'\n    showError.value = true\n    historyData.value = null\n  } finally {\n    if (!isAutoRefresh) {\n      isLoading.value = false\n    }\n  }\n}\n\n// Watchers\nwatch(selectedDuration, (newVal) => {\n  savePreference(props.apiType, 'duration', newVal)\n  refreshData()\n})\n\nwatch(selectedView, (newVal) => {\n  savePreference(props.apiType, 'viewMode', newVal)\n})\n\nwatch(() => props.apiType, (newApiType) => {\n  // Load preferences for the new apiType\n  const prefs = loadSavedPreferences(newApiType)\n  selectedView.value = prefs.view\n  selectedDuration.value = prefs.duration\n  refreshData()\n})\n\n// Initial load and start auto refresh\nonMounted(() => {\n  refreshData()\n  startAutoRefresh()\n})\n\n// Cleanup timer on unmount\nonUnmounted(() => {\n  stopAutoRefresh()\n})\n\n// Expose refresh method\ndefineExpose({\n  refreshData,\n  startAutoRefresh,\n  stopAutoRefresh\n})\n</script>\n\n<style scoped>\n.global-stats-chart-container {\n  padding: 12px 16px;\n}\n\n.summary-cards {\n  display: flex;\n  flex-wrap: wrap;\n}\n\n.summary-card {\n  flex: 1 1 auto;\n  min-width: 80px;\n  padding: 8px 12px;\n  background: rgba(var(--v-theme-surface-variant), 0.3);\n  border-radius: 6px;\n  text-align: center;\n}\n\n.v-theme--dark .summary-card {\n  background: rgba(var(--v-theme-surface-variant), 0.2);\n}\n\n.summary-label {\n  font-size: 11px;\n  color: rgba(var(--v-theme-on-surface), 0.6);\n  margin-bottom: 2px;\n}\n\n.summary-value {\n  font-size: 16px;\n  font-weight: 600;\n}\n\n.compact-summary {\n  padding: 4px 8px;\n  background: rgba(var(--v-theme-surface-variant), 0.2);\n  border-radius: 4px;\n}\n\n.chart-header {\n  flex-wrap: wrap;\n  gap: 8px;\n}\n\n.chart-area {\n  margin-top: 8px;\n}\n\n/* Responsive adjustments */\n@media (max-width: 600px) {\n  .summary-card {\n    min-width: 70px;\n    padding: 6px 8px;\n  }\n\n  .summary-value {\n    font-size: 14px;\n  }\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/KeyTrendChart.vue",
    "content": "<template>\n  <div class=\"key-trend-chart-container\">\n    <!-- Snackbar for error notification -->\n    <v-snackbar v-model=\"showError\" color=\"error\" :timeout=\"3000\" location=\"top\">\n      {{ errorMessage }}\n      <template #actions>\n        <v-btn variant=\"text\" @click=\"showError = false\">关闭</v-btn>\n      </template>\n    </v-snackbar>\n\n    <!-- 头部：时间范围选择（左） + 视图切换（右） -->\n    <div class=\"chart-header d-flex align-center justify-space-between mb-3\">\n      <div class=\"d-flex align-center ga-2\">\n        <!-- 时间范围选择器 -->\n        <v-btn-toggle v-model=\"selectedDuration\" mandatory density=\"compact\" variant=\"outlined\" divided :disabled=\"isLoading\">\n          <v-btn value=\"1h\" size=\"x-small\">1小时</v-btn>\n          <v-btn value=\"6h\" size=\"x-small\">6小时</v-btn>\n          <v-btn value=\"24h\" size=\"x-small\">24小时</v-btn>\n          <v-btn value=\"today\" size=\"x-small\">今日</v-btn>\n        </v-btn-toggle>\n\n        <v-btn icon size=\"x-small\" variant=\"text\" :loading=\"isLoading\" :disabled=\"isLoading\" @click=\"refreshData\">\n          <v-icon size=\"small\">mdi-refresh</v-icon>\n        </v-btn>\n      </div>\n\n      <!-- 视图切换按钮 -->\n      <v-btn-toggle v-model=\"selectedView\" mandatory density=\"compact\" variant=\"outlined\" divided :disabled=\"isLoading\">\n        <v-btn value=\"traffic\" size=\"x-small\">\n          <v-icon size=\"small\" class=\"mr-1\">mdi-chart-line</v-icon>\n          流量\n        </v-btn>\n        <v-btn value=\"tokens\" size=\"x-small\">\n          <v-icon size=\"small\" class=\"mr-1\">mdi-chart-line</v-icon>\n          Token I/O\n        </v-btn>\n        <v-btn value=\"cache\" size=\"x-small\">\n          <v-icon size=\"small\" class=\"mr-1\">mdi-database</v-icon>\n          缓存 R/W\n        </v-btn>\n      </v-btn-toggle>\n    </div>\n\n    <!-- Loading state -->\n    <div v-if=\"isLoading\" class=\"d-flex justify-center align-center\" style=\"height: 200px\">\n      <v-progress-circular indeterminate size=\"32\" color=\"primary\" />\n    </div>\n\n    <!-- Empty state -->\n    <div v-else-if=\"!hasData\" class=\"d-flex flex-column justify-center align-center text-medium-emphasis\" style=\"height: 200px\">\n      <v-icon size=\"40\" color=\"grey-lighten-1\">mdi-chart-timeline-variant</v-icon>\n      <div class=\"text-caption mt-2\">选定时间范围内没有 Key 使用记录</div>\n    </div>\n\n    <!-- 图表区域 -->\n    <div v-else class=\"chart-area\">\n      <apexchart\n        ref=\"chartRef\"\n        type=\"area\"\n        height=\"280\"\n        :options=\"chartOptions\"\n        :series=\"chartSeries\"\n      />\n    </div>\n\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, watch, onMounted, onUnmounted } from 'vue'\nimport { useTheme } from 'vuetify'\nimport VueApexCharts from 'vue3-apexcharts'\nimport type { ApexOptions } from 'apexcharts'\nimport { api, type ChannelKeyMetricsHistoryResponse } from '../services/api'\n\n// Register apexchart component\nconst apexchart = VueApexCharts\n\n// Props\nconst props = defineProps<{\n  channelId: number\n  channelType: 'messages' | 'responses' | 'gemini'\n}>()\n\n// View mode type\ntype ViewMode = 'traffic' | 'tokens' | 'cache'\ntype Duration = '1h' | '6h' | '24h' | 'today'\n\n// LocalStorage keys for preferences (per channelType)\nconst getStorageKey = (channelType: string, key: string) => `keyTrendChart:${channelType}:${key}`\n\n// Check if localStorage is available (SSR-safe)\nconst isLocalStorageAvailable = (): boolean => {\n  try {\n    return typeof window !== 'undefined' && window.localStorage !== undefined\n  } catch {\n    return false\n  }\n}\n\nconst loadSavedPreferences = (channelType: string): { view: ViewMode; duration: Duration } => {\n  if (!isLocalStorageAvailable()) {\n    return { view: 'traffic', duration: '1h' }\n  }\n  try {\n    const savedView = window.localStorage.getItem(getStorageKey(channelType, 'viewMode')) as ViewMode | null\n    const savedDuration = window.localStorage.getItem(getStorageKey(channelType, 'duration')) as Duration | null\n    return {\n      view: savedView && ['traffic', 'tokens', 'cache'].includes(savedView) ? savedView : 'traffic',\n      duration: savedDuration && ['1h', '6h', '24h', 'today'].includes(savedDuration) ? savedDuration : '1h'\n    }\n  } catch {\n    return { view: 'traffic', duration: '1h' }\n  }\n}\n\nconst savePreference = (channelType: string, key: string, value: string) => {\n  if (!isLocalStorageAvailable()) return\n  try {\n    window.localStorage.setItem(getStorageKey(channelType, key), value)\n  } catch {\n    // Ignore storage errors (quota exceeded, private mode, etc.)\n  }\n}\n\n// Theme\nconst theme = useTheme()\nconst isDark = computed(() => theme.global.current.value.dark)\n\n// Load saved preferences for current channelType\nconst savedPrefs = loadSavedPreferences(props.channelType)\n\n// State\nconst selectedView = ref<ViewMode>(savedPrefs.view)\nconst selectedDuration = ref<Duration>(savedPrefs.duration)\nconst isLoading = ref(false)\nconst isRefreshing = ref(false) // includes auto refresh (silent) requests\nconst historyData = ref<ChannelKeyMetricsHistoryResponse | null>(null)\nconst showError = ref(false)\nconst errorMessage = ref('')\n\n// Chart ref for updateSeries\nconst chartRef = ref<InstanceType<typeof VueApexCharts> | null>(null)\n\n// request id for refreshData\nlet refreshRequestId = 0\n\n// Auto refresh timer (2 seconds interval, same as global refresh)\nconst AUTO_REFRESH_INTERVAL = 2000\nlet autoRefreshTimer: ReturnType<typeof setInterval> | null = null\n\nconst startAutoRefresh = () => {\n  stopAutoRefresh()\n  autoRefreshTimer = setInterval(() => {\n    // Skip if already refreshing to prevent concurrent requests / stale overwrites\n    if (!isRefreshing.value) {\n      refreshData(true) // true = auto refresh, use updateSeries\n    }\n  }, AUTO_REFRESH_INTERVAL)\n}\n\nconst stopAutoRefresh = () => {\n  if (autoRefreshTimer) {\n    clearInterval(autoRefreshTimer)\n    autoRefreshTimer = null\n  }\n}\n\n// Key colors - 支持最多 10 个 key\nconst keyColors = [\n  '#3b82f6', // 蓝色\n  '#f97316', // 橙色\n  '#10b981', // 绿色\n  '#8b5cf6', // 紫色\n  '#ec4899', // 粉色\n  '#eab308', // 黄色\n  '#06b6d4', // 青色\n  '#f43f5e', // 玫红\n  '#84cc16', // 酸橙绿\n  '#6366f1', // 靛蓝\n]\n\n// 失败率阈值：超过此值显示红色背景\nconst FAILURE_RATE_THRESHOLD = 0.1 // 10%\n\n// 聚合间隔配置（与后端保持一致）\n// 1h = 1m, 6h = 5m, 24h = 15m, today = 动态计算\nconst AGGREGATION_INTERVALS: Record<Duration, number> = {\n  '1h': 60000,    // 1 分钟\n  '6h': 300000,   // 5 分钟\n  '24h': 900000,  // 15 分钟\n  'today': 300000 // 5 分钟（今日默认使用 5 分钟间隔）\n}\n\n// 根据时间范围获取聚合间隔\nconst getAggregationInterval = (duration: Duration): number => {\n  const interval = AGGREGATION_INTERVALS[duration]\n  if (interval === undefined) {\n    console.warn(`[KeyTrendChart] Unknown duration \"${duration}\", falling back to 1m interval`)\n    return 60000\n  }\n  return interval\n}\n\n// 将时间戳对齐到聚合桶（向下取整）\nconst alignToBucket = (timestamp: number, interval: number): number => {\n  return Math.floor(timestamp / interval) * interval\n}\n\n// Computed: check if has data\nconst hasData = computed(() => {\n  if (!historyData.value) return false\n  return historyData.value.keys &&\n    historyData.value.keys.length > 0 &&\n    historyData.value.keys.some(k => k.dataPoints && k.dataPoints.length > 0)\n})\n\n// Computed: 计算每个时间点的加权平均成功率，用于背景色带\n// 返回格式: { timestamp: number, failureRate: number }[]\nconst timePointFailureRates = computed(() => {\n  if (!historyData.value?.keys?.length) return []\n\n  // 获取当前聚合间隔，与 tooltip 保持一致\n  const interval = getAggregationInterval(selectedDuration.value)\n\n  // 按对齐后的时间戳聚合所有 key 的数据（与 tooltip 逻辑一致）\n  const timeMap = new Map<number, { totalRequests: number; totalFailures: number }>()\n\n  historyData.value.keys.forEach(keyData => {\n    keyData.dataPoints?.forEach(dp => {\n      const rawTs = new Date(dp.timestamp).getTime()\n      // 使用 alignToBucket 对齐时间戳，确保与 tooltip 数据匹配\n      const alignedTs = alignToBucket(rawTs, interval)\n      const existing = timeMap.get(alignedTs) || { totalRequests: 0, totalFailures: 0 }\n      existing.totalRequests += dp.requestCount\n      existing.totalFailures += dp.failureCount\n      timeMap.set(alignedTs, existing)\n    })\n  })\n\n  // 转换为数组并计算失败率\n  return Array.from(timeMap.entries())\n    .map(([timestamp, data]) => ({\n      timestamp,\n      failureRate: data.totalRequests > 0 ? data.totalFailures / data.totalRequests : 0\n    }))\n    .sort((a, b) => a.timestamp - b.timestamp)\n})\n\n// Helper: 根据失败率计算透明度（失败率越高，颜色越深）\n// 10% -> 0.08, 20% -> 0.15, 30% -> 0.22, 50% -> 0.35, 70% -> 0.48, 100% -> 0.65\nconst getFailureOpacity = (failureRate: number): number => {\n  const minOpacity = 0.08\n  const maxOpacity = 0.65\n  // 从阈值开始计算，到 100% 时达到最大透明度\n  const normalizedRate = Math.min((failureRate - FAILURE_RATE_THRESHOLD) / (1 - FAILURE_RATE_THRESHOLD), 1)\n  return minOpacity + normalizedRate * (maxOpacity - minOpacity)\n}\n\n// Computed: 生成 ApexCharts annotations（红色背景色带，深浅随失败率变化）\nconst failureAnnotations = computed(() => {\n  if (selectedView.value !== 'traffic') return [] // 只在流量模式显示\n\n  const rates = timePointFailureRates.value\n  if (rates.length === 0) return []\n\n  const annotations: any[] = []\n\n  // 根据当前时间范围获取聚合间隔（与后端保持一致）\n  const DEFAULT_INTERVAL = getAggregationInterval(selectedDuration.value)\n  // 最大间隔限制：默认间隔的 2 倍，防止数据稀疏时色带过大\n  const MAX_INTERVAL = DEFAULT_INTERVAL * 2\n\n  // 为每个超过阈值的点单独创建一个 annotation\n  rates.forEach((point, index) => {\n    if (point.failureRate >= FAILURE_RATE_THRESHOLD) {\n      // 动态计算该点的时间间隔：优先使用与相邻点的实际间隔\n      let interval = DEFAULT_INTERVAL\n      if (rates.length > 1) {\n        if (index > 0) {\n          // 使用与前一个点的间隔\n          interval = point.timestamp - rates[index - 1].timestamp\n        } else if (index < rates.length - 1) {\n          // 第一个点：使用与后一个点的间隔\n          interval = rates[index + 1].timestamp - point.timestamp\n        }\n      }\n      // 限制最大间隔，避免数据稀疏时色带过大\n      interval = Math.min(interval, MAX_INTERVAL)\n\n      annotations.push({\n        x: point.timestamp - interval / 2,\n        x2: point.timestamp + interval / 2,\n        fillColor: '#ef4444',\n        opacity: getFailureOpacity(point.failureRate),\n        label: {\n          text: ''\n        }\n      })\n    }\n  })\n\n  return annotations\n})\n\n// Computed: get all data points flattened\nconst _allDataPoints = computed(() => {\n  if (!historyData.value?.keys) return []\n  return historyData.value.keys.flatMap(k => k.dataPoints || [])\n})\n\n// Computed: chart options\nconst chartOptions = computed<ApexOptions>(() => {\n  const mode = selectedView.value\n\n  // Token/Cache 模式使用双 Y 轴（左侧 Input/Read，右侧 Output/Write）\n  // 解决数量级差异大（如 Input 几十K，Output 几百）导致小值不可见的问题\n  let yaxisConfig: any\n  if (mode === 'tokens' || mode === 'cache') {\n    const keyCount = historyData.value?.keys?.length || 1\n    yaxisConfig = []\n    // 为每个 key 的 Input 和 Output 分别配置 Y 轴\n    for (let i = 0; i < keyCount; i++) {\n      // Input/Read - 左侧 Y 轴（只第一个显示标签）\n      yaxisConfig.push({\n        seriesName: historyData.value?.keys?.[i]?.keyMask\n          ? `${historyData.value.keys[i].keyMask} ${mode === 'tokens' ? 'Input' : 'Cache Read'}`\n          : undefined,\n        show: i === 0,\n        labels: {\n          formatter: (val: number) => formatAxisValue(val, mode),\n          style: { fontSize: '11px' }\n        },\n        min: 0\n      })\n      // Output/Write - 右侧 Y 轴（只第一个显示标签）\n      yaxisConfig.push({\n        seriesName: historyData.value?.keys?.[i]?.keyMask\n          ? `${historyData.value.keys[i].keyMask} ${mode === 'tokens' ? 'Output' : 'Cache Write'}`\n          : undefined,\n        opposite: true,\n        show: i === 0,\n        labels: {\n          formatter: (val: number) => formatAxisValue(val, mode),\n          style: { fontSize: '11px' }\n        },\n        min: 0\n      })\n    }\n  } else {\n    yaxisConfig = {\n      labels: {\n        formatter: (val: number) => formatAxisValue(val, mode),\n        style: { fontSize: '11px' }\n      },\n      min: 0\n    }\n  }\n\n  return {\n    chart: {\n      toolbar: { show: false },\n      zoom: { enabled: false },\n      background: 'transparent',\n      fontFamily: 'inherit',\n      sparkline: { enabled: false },\n      animations: {\n        enabled: true,\n        speed: 400,\n        animateGradually: { enabled: true, delay: 150 },\n        dynamicAnimation: { enabled: true, speed: 350 }\n      }\n    },\n    theme: {\n      mode: isDark.value ? 'dark' : 'light'\n    },\n    colors: getChartColors(),\n    fill: {\n      type: 'gradient' as const,\n      gradient: {\n        shadeIntensity: 1,\n        opacityFrom: 0.4,\n        opacityTo: 0.08,\n        stops: [0, 90, 100]\n      }\n    },\n    dataLabels: {\n      enabled: false\n    },\n    stroke: {\n      curve: 'smooth' as const,\n      width: 2,\n      // traffic 模式全用实线；tokens/cache 模式：Input/Read 实线，Output/Write 虚线\n      dashArray: getDashArray()\n    },\n    grid: {\n      borderColor: isDark.value ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',\n      padding: { left: 10, right: 10 }\n    },\n    xaxis: {\n      type: 'datetime',\n      labels: {\n        datetimeUTC: false,\n        format: selectedDuration.value === '1h' ? 'HH:mm' : 'HH:mm',\n        style: { fontSize: '10px' }\n      },\n      axisBorder: { show: false },\n      axisTicks: { show: false }\n    },\n    yaxis: yaxisConfig,\n    annotations: {\n      xaxis: failureAnnotations.value\n    },\n    tooltip: {\n      x: {\n        format: 'MM-dd HH:mm'\n      },\n      y: {\n        formatter: (val: number) => formatTooltipValue(val, mode)\n      },\n      custom: mode === 'traffic' ? buildTrafficTooltip : undefined\n    },\n    legend: {\n      show: false\n    }\n  }\n})\n\n// Build chart series from data\nconst buildChartSeries = (data: ChannelKeyMetricsHistoryResponse | null) => {\n  if (!data?.keys) return []\n\n  const mode = selectedView.value\n  const result: { name: string; data: { x: number; y: number }[] }[] = []\n\n  data.keys.forEach((keyData, keyIndex) => {\n    const _color = keyColors[keyIndex % keyColors.length]\n\n    if (mode === 'traffic') {\n      // 单向模式：只显示请求数\n      result.push({\n        name: keyData.keyMask,\n        data: keyData.dataPoints.map(dp => ({\n          x: new Date(dp.timestamp).getTime(),\n          y: dp.requestCount\n        }))\n      })\n    } else {\n      // 双向模式：每个 key 创建两个 series（Input/Output 或 Read/Creation）\n      const inLabel = mode === 'tokens' ? 'Input' : 'Cache Read'\n      const outLabel = mode === 'tokens' ? 'Output' : 'Cache Write'\n\n      // 正向（Input/Read）\n      result.push({\n        name: `${keyData.keyMask} ${inLabel}`,\n        data: keyData.dataPoints.map(dp => {\n          let value = 0\n          if (mode === 'tokens') {\n            value = dp.inputTokens\n          } else {\n            value = dp.cacheReadTokens\n          }\n          return { x: new Date(dp.timestamp).getTime(), y: value }\n        })\n      })\n\n      // Output/Write - 使用虚线区分\n      result.push({\n        name: `${keyData.keyMask} ${outLabel}`,\n        data: keyData.dataPoints.map(dp => {\n          let value = 0\n          if (mode === 'tokens') {\n            value = dp.outputTokens\n          } else {\n            value = dp.cacheCreationTokens\n          }\n          return { x: new Date(dp.timestamp).getTime(), y: value }\n        })\n      })\n    }\n  })\n\n  return result\n}\n\n// Computed: chart series data\nconst chartSeries = computed(() => buildChartSeries(historyData.value))\n\n// Helper: format number for display\nconst formatNumber = (num: number): string => {\n  if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'\n  if (num >= 1000) return (num / 1000).toFixed(1) + 'K'\n  return num.toFixed(0)\n}\n\n// Helper: format axis value based on view mode\nconst formatAxisValue = (val: number, mode: ViewMode): string => {\n  switch (mode) {\n    case 'traffic':\n      return Math.round(val).toString()\n    case 'tokens':\n    case 'cache':\n      return formatNumber(Math.abs(val))\n    default:\n      return val.toString()\n  }\n}\n\n// Helper: format tooltip value\nconst formatTooltipValue = (val: number, mode: ViewMode): string => {\n  switch (mode) {\n    case 'traffic':\n      return `${Math.round(val)} 请求`\n    case 'tokens':\n    case 'cache':\n      return formatNumber(Math.abs(val))\n    default:\n      return val.toString()\n  }\n}\n\n// Helper: build custom tooltip for traffic mode (shows success/failure breakdown)\nconst buildTrafficTooltip = ({ seriesIndex, dataPointIndex, w }: any): string => {\n  if (!historyData.value?.keys) return ''\n\n  const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex]\n  const date = new Date(timestamp)\n  const timeStr = date.toLocaleString('zh-CN', {\n    month: '2-digit',\n    day: '2-digit',\n    hour: '2-digit',\n    minute: '2-digit'\n  })\n\n  // 收集该时间点所有 key 的数据\n  const keyStats: { keyMask: string; success: number; failure: number; total: number; color: string }[] = []\n  let grandTotal = 0\n  let grandFailure = 0\n\n  // HTML 转义函数，防止 XSS\n  const escapeHtml = (str: string): string => {\n    return str\n      .replace(/&/g, '&amp;')\n      .replace(/</g, '&lt;')\n      .replace(/>/g, '&gt;')\n      .replace(/\"/g, '&quot;')\n      .replace(/'/g, '&#39;')\n  }\n\n  // 获取当前聚合间隔，用于时间戳对齐匹配\n  const interval = getAggregationInterval(selectedDuration.value)\n  const alignedTimestamp = alignToBucket(timestamp, interval)\n\n  historyData.value.keys.forEach((keyData, keyIndex) => {\n    // 使用 filter 累加同一时间桶内的所有数据点（防御性编程）\n    const matchingPoints = keyData.dataPoints?.filter(p => {\n      const dpTimestamp = new Date(p.timestamp).getTime()\n      return alignToBucket(dpTimestamp, interval) === alignedTimestamp\n    }) || []\n\n    if (matchingPoints.length > 0) {\n      // 累加所有匹配点的统计数据\n      const aggregated = matchingPoints.reduce(\n        (acc, dp) => ({\n          success: acc.success + dp.successCount,\n          failure: acc.failure + dp.failureCount,\n          total: acc.total + dp.requestCount\n        }),\n        { success: 0, failure: 0, total: 0 }\n      )\n\n      if (aggregated.total > 0) {\n        keyStats.push({\n          keyMask: escapeHtml(keyData.keyMask),\n          success: aggregated.success,\n          failure: aggregated.failure,\n          total: aggregated.total,\n          color: keyColors[keyIndex % keyColors.length]\n        })\n        grandTotal += aggregated.total\n        grandFailure += aggregated.failure\n      }\n    }\n  })\n\n  if (keyStats.length === 0) return ''\n\n  const grandFailureRate = grandTotal > 0 ? (grandFailure / grandTotal * 100).toFixed(1) : '0'\n  const hasFailure = grandFailure > 0\n\n  // 构建 HTML\n  let html = `<div style=\"padding: 8px 12px; font-size: 12px;\">`\n  html += `<div style=\"font-weight: 600; margin-bottom: 6px; color: ${hasFailure ? '#ef4444' : 'inherit'};\">${timeStr}</div>`\n\n  // 每个 key 的详情\n  keyStats.forEach(stat => {\n    const failureRate = stat.total > 0 ? (stat.failure / stat.total * 100).toFixed(0) : '0'\n    const hasKeyFailure = stat.failure > 0\n    html += `<div style=\"display: flex; align-items: center; margin: 4px 0;\">`\n    html += `<span style=\"width: 10px; height: 10px; border-radius: 50%; background: ${stat.color}; margin-right: 6px;\"></span>`\n    html += `<span style=\"flex: 1;\">${stat.keyMask}</span>`\n    html += `<span style=\"margin-left: 12px; font-weight: 500;\">${stat.total}</span>`\n    if (hasKeyFailure) {\n      html += `<span style=\"margin-left: 6px; color: #ef4444; font-size: 11px;\">(${stat.failure}失败, ${failureRate}%)</span>`\n    }\n    html += `</div>`\n  })\n\n  // 汇总行（如果有多个 key）\n  if (keyStats.length > 1) {\n    html += `<div style=\"border-top: 1px solid rgba(128,128,128,0.3); margin-top: 6px; padding-top: 6px; font-weight: 600;\">`\n    html += `<span>合计: ${grandTotal} 请求</span>`\n    if (hasFailure) {\n      html += `<span style=\"color: #ef4444; margin-left: 8px;\">${grandFailure} 失败 (${grandFailureRate}%)</span>`\n    }\n    html += `</div>`\n  }\n\n  html += `</div>`\n  return html\n}\n\n// Helper: get duration in milliseconds\nconst _getDurationMs = (duration: Duration): number => {\n  switch (duration) {\n    case '1h': return 60 * 60 * 1000\n    case '6h': return 6 * 60 * 60 * 1000\n    case '24h': return 24 * 60 * 60 * 1000\n    case 'today': {\n      // 计算从今日 0 点到现在的毫秒数\n      const now = new Date()\n      const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate())\n      return now.getTime() - startOfDay.getTime()\n    }\n    default: return 6 * 60 * 60 * 1000\n  }\n}\n\n// Helper: get dash array for stroke style\n// traffic 模式：全部实线\n// tokens/cache 模式：每个 key 有两个 series（正向实线、负向虚线）\nconst getDashArray = (): number | number[] => {\n  if (selectedView.value === 'traffic') {\n    return 0 // 全部实线\n  }\n  // 双向模式：每个 key 产生 2 个 series [正向实线, 负向虚线]\n  const keyCount = historyData.value?.keys?.length || 0\n  const dashArray: number[] = []\n  for (let i = 0; i < keyCount; i++) {\n    dashArray.push(0)  // 正向（Input/Read）- 实线\n    dashArray.push(5)  // 负向（Output/Write）- 虚线\n  }\n  return dashArray.length > 0 ? dashArray : 0\n}\n\n// Helper: get chart colors aligned with series count\n// traffic 模式：每个 key 一个 series，一种颜色\n// tokens/cache 模式：每个 key 两个 series（Input/Output），使用相同颜色\nconst getChartColors = (): string[] => {\n  const keyCount = historyData.value?.keys?.length || 0\n  if (keyCount === 0) return keyColors\n\n  if (selectedView.value === 'traffic') {\n    // 流量模式：每个 key 一种颜色\n    return historyData.value!.keys.map((_, i) => keyColors[i % keyColors.length])\n  }\n  // 双向模式：每个 key 复制颜色（Input 和 Output 同色）\n  const colors: string[] = []\n  for (let i = 0; i < keyCount; i++) {\n    const color = keyColors[i % keyColors.length]\n    colors.push(color)  // 正向\n    colors.push(color)  // 负向（同色）\n  }\n  return colors\n}\n\n// Fetch data\nconst refreshData = async (isAutoRefresh = false) => {\n  // Prevent out-of-order responses from overwriting newer state\n  const requestId = ++refreshRequestId\n  isRefreshing.value = true\n\n  // Auto refresh uses silent update without loading state\n  if (!isAutoRefresh) {\n    isLoading.value = true\n  }\n  errorMessage.value = ''\n  try {\n    let newData: ChannelKeyMetricsHistoryResponse\n    if (props.channelType === 'responses') {\n      newData = await api.getResponsesChannelKeyMetricsHistory(props.channelId, selectedDuration.value)\n    } else if (props.channelType === 'gemini') {\n      newData = await api.getGeminiChannelKeyMetricsHistory(props.channelId, selectedDuration.value)\n    } else {\n      newData = await api.getChannelKeyMetricsHistory(props.channelId, selectedDuration.value)\n    }\n\n    // Ignore stale response\n    if (requestId !== refreshRequestId) return\n\n    // Check if we can use updateSeries (same keys structure)\n    const canUpdateInPlace = isAutoRefresh &&\n      chartRef.value &&\n      historyData.value?.keys?.length === newData.keys?.length &&\n      historyData.value?.keys?.every((k, i) => k.keyMask === newData.keys[i].keyMask)\n\n    if (canUpdateInPlace) {\n      // Update data in place and use updateSeries for smooth update\n      historyData.value = newData\n      const newSeries = buildChartSeries(newData)\n      chartRef.value?.updateSeries(newSeries, false) // false = no animation reset\n    } else {\n      // Full update (initial load or structure changed)\n      historyData.value = newData\n    }\n  } catch (error) {\n    // Ignore stale error\n    if (requestId !== refreshRequestId) return\n\n    console.error('Failed to fetch key metrics history:', error)\n    errorMessage.value = error instanceof Error ? error.message : '获取 Key 历史数据失败'\n    showError.value = true\n    historyData.value = null\n  } finally {\n    // Only let the latest request update flags\n    if (requestId === refreshRequestId) {\n      isRefreshing.value = false\n      if (!isAutoRefresh) {\n        isLoading.value = false\n      }\n    }\n  }\n}\n\n// Watchers\nwatch(selectedDuration, () => {\n  savePreference(props.channelType, 'duration', selectedDuration.value)\n  refreshData()\n}, { flush: 'sync' })\n\nwatch(selectedView, () => {\n  savePreference(props.channelType, 'viewMode', selectedView.value)\n  // View change doesn't need to refetch, just re-render chart\n}, { flush: 'sync' })\n\n// Watch channelType changes to reload preferences and refresh data\nwatch(() => props.channelType, (newChannelType) => {\n  const prefs = loadSavedPreferences(newChannelType)\n  const oldDuration = selectedDuration.value\n  selectedView.value = prefs.view\n  selectedDuration.value = prefs.duration\n  historyData.value = null\n  // Only explicitly refresh if duration didn't change (otherwise duration watcher handles it)\n  if (oldDuration === prefs.duration) {\n    refreshData()\n  }\n})\n\n// Initial load and start auto refresh\nonMounted(() => {\n  refreshData()\n  startAutoRefresh()\n})\n\n// Cleanup timer on unmount\nonUnmounted(() => {\n  stopAutoRefresh()\n})\n\n// Expose refresh method\ndefineExpose({\n  refreshData\n})\n</script>\n\n<style scoped>\n.key-trend-chart-container {\n  padding: 12px 16px;\n  background: rgba(var(--v-theme-surface-variant), 0.3);\n  border-top: 1px dashed rgba(var(--v-theme-on-surface), 0.2);\n}\n\n.v-theme--dark .key-trend-chart-container {\n  background: rgba(var(--v-theme-surface-variant), 0.2);\n  border-top-color: rgba(255, 255, 255, 0.15);\n}\n\n.chart-header {\n  flex-wrap: wrap;\n  gap: 8px;\n}\n\n.chart-area {\n  margin-top: 8px;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/composables/useTheme.ts",
    "content": "import { useTheme as useVuetifyTheme } from 'vuetify'\n\n// 复古像素主题配置\nexport const RETRO_THEME = {\n  name: '复古像素',\n  font: '\"Courier New\", Consolas, \"Liberation Mono\", monospace'\n}\n\nexport function useAppTheme() {\n  const _vuetifyTheme = useVuetifyTheme()\n\n  // 应用复古像素主题\n  function applyRetroTheme() {\n    document.documentElement.style.setProperty('--app-font', RETRO_THEME.font)\n  }\n\n  // 初始化\n  function init() {\n    applyRetroTheme()\n  }\n\n  return {\n    init\n  }\n}\n"
  },
  {
    "path": "frontend/src/env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n\n// Allow importing .vue files in TS\ndeclare module '*.vue' {\n  import type { DefineComponent } from 'vue'\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, any>\n  export default component\n}\n"
  },
  {
    "path": "frontend/src/main.ts",
    "content": "import { createApp } from 'vue'\nimport { createPinia } from 'pinia'\nimport piniaPluginPersistedstate from 'pinia-plugin-persistedstate'\nimport vuetify from './plugins/vuetify'\nimport router from './router'\nimport App from './App.vue'\nimport './assets/style.css'\nimport { useAuthStore } from './stores/auth'\n\nconst app = createApp(App)\n\nconst pinia = createPinia()\npinia.use(piniaPluginPersistedstate)\n\napp.use(pinia)\napp.use(vuetify)\napp.use(router)\n\n// 初始化 AuthStore（从 localStorage 恢复状态）\nconst authStore = useAuthStore()\nauthStore.initializeAuth()\n\napp.mount('#app')\n"
  },
  {
    "path": "frontend/src/plugins/vuetify.ts",
    "content": "import { createVuetify } from 'vuetify'\nimport { h } from 'vue'\nimport type { IconSet, IconProps, ThemeDefinition } from 'vuetify'\nimport * as components from 'vuetify/components'\nimport * as directives from 'vuetify/directives'\n\n// 引入样式\nimport 'vuetify/styles'\n\n// 从 @mdi/js 按需导入使用的图标 (SVG)\n// 📝 维护说明: 新增图标时需要:\n//    1. 从 @mdi/js 添加导入 (驼峰命名，如 mdiNewIcon)\n//    2. 在 iconMap 中添加映射 (如 'new-icon': mdiNewIcon)\n//    图标查找: https://pictogrammers.com/library/mdi/\nimport {\n  mdiSwapVerticalBold,\n  mdiPlayCircle,\n  mdiDragVertical,\n  mdiOpenInNew,\n  mdiKey,\n  mdiRefresh,\n  mdiDotsVertical,\n  mdiPencil,\n  mdiSpeedometer,\n  mdiSpeedometerSlow,\n  mdiRocketLaunch,\n  mdiPauseCircle,\n  mdiStopCircle,\n  mdiDelete,\n  mdiPlaylistRemove,\n  mdiArchiveOutline,\n  mdiPlus,\n  mdiCheckCircle,\n  mdiAlertCircle,\n  mdiHelpCircle,\n  mdiCloseCircle,\n  mdiTag,\n  mdiInformation,\n  mdiCog,\n  mdiWeb,\n  mdiShieldAlert,\n  mdiText,\n  mdiSwapHorizontal,\n  mdiArrowRight,\n  mdiClose,\n  mdiArrowUpBold,\n  mdiArrowDownBold,\n  mdiCheck,\n  mdiContentCopy,\n  mdiAlert,\n  mdiWeatherNight,\n  mdiWhiteBalanceSunny,\n  mdiLogout,\n  mdiServerNetwork,\n  mdiHeartPulse,\n  mdiChevronDown,\n  mdiChevronUp,\n  mdiChevronLeft,\n  mdiChevronRight,\n  mdiTune,\n  mdiRotateRight,\n  mdiDice6,\n  mdiBackupRestore,\n  mdiKeyPlus,\n  mdiPin,\n  mdiPinOutline,\n  mdiKeyChain,\n  mdiRobot,\n  mdiRobotOutline,\n  mdiMessageProcessing,\n  mdiDiamondStone,\n  mdiApi,\n  mdiLightningBolt,\n  mdiFormTextbox,\n  mdiMenuDown,\n  mdiMenuUp,\n  mdiCheckboxMarked,\n  mdiCheckboxBlankOutline,\n  mdiMinusBox,\n  mdiCircle,\n  mdiRadioboxMarked,\n  mdiRadioboxBlank,\n  mdiStar,\n  mdiStarOutline,\n  mdiStarHalf,\n  mdiPageFirst,\n  mdiPageLast,\n  mdiUnfoldMoreHorizontal,\n  mdiLoading,\n  mdiClockOutline,\n  mdiCalendar,\n  mdiPaperclip,\n  mdiEyedropper,\n  mdiShieldRefresh,\n  mdiShieldOffOutline,\n  mdiAlertCircleOutline,\n  mdiChartTimelineVariant,\n  mdiChartAreaspline,\n  mdiChartLine,\n  mdiCodeBraces,\n  mdiDatabase,\n  mdiSignature,\n  mdiArrowCollapseUp,\n  mdiArrowCollapseDown,\n} from '@mdi/js'\n\n// 图标名称到 SVG path 的映射 (使用 kebab-case)\nconst iconMap: Record<string, string> = {\n  // Vuetify 内部使用的图标别名\n  'complete': mdiCheck,\n  'cancel': mdiCloseCircle,\n  'close': mdiClose,\n  'delete': mdiDelete,\n  'clear': mdiClose,\n  'success': mdiCheckCircle,\n  'info': mdiInformation,\n  'warning': mdiAlert,\n  'error': mdiAlertCircle,\n  'prev': mdiChevronLeft,\n  'next': mdiChevronRight,\n  'checkboxOn': mdiCheckboxMarked,\n  'checkboxOff': mdiCheckboxBlankOutline,\n  'checkboxIndeterminate': mdiMinusBox,\n  'delimiter': mdiCircle,\n  'sortAsc': mdiArrowUpBold,\n  'sortDesc': mdiArrowDownBold,\n  'expand': mdiChevronDown,\n  'menu': mdiMenuDown,\n  'subgroup': mdiMenuDown,\n  'dropdown': mdiMenuDown,\n  'radioOn': mdiRadioboxMarked,\n  'radioOff': mdiRadioboxBlank,\n  'edit': mdiPencil,\n  'ratingEmpty': mdiStarOutline,\n  'ratingFull': mdiStar,\n  'ratingHalf': mdiStarHalf,\n  'loading': mdiLoading,\n  'first': mdiPageFirst,\n  'last': mdiPageLast,\n  'unfold': mdiUnfoldMoreHorizontal,\n  'file': mdiPaperclip,\n  'plus': mdiPlus,\n  'minus': mdiMinusBox,\n  'calendar': mdiCalendar,\n  'treeviewCollapse': mdiMenuDown,\n  'treeviewExpand': mdiMenuUp,\n  'eyeDropper': mdiEyedropper,\n\n  // 布局与导航\n  'swap-vertical-bold': mdiSwapVerticalBold,\n  'drag-vertical': mdiDragVertical,\n  'open-in-new': mdiOpenInNew,\n  'chevron-down': mdiChevronDown,\n  'chevron-up': mdiChevronUp,\n  'chevron-left': mdiChevronLeft,\n  'chevron-right': mdiChevronRight,\n  'dots-vertical': mdiDotsVertical,\n  'logout': mdiLogout,\n  'archive-outline': mdiArchiveOutline,\n  'menu-down': mdiMenuDown,\n  'menu-up': mdiMenuUp,\n\n  // 操作按钮\n  'pencil': mdiPencil,\n  'refresh': mdiRefresh,\n  'check': mdiCheck,\n  'content-copy': mdiContentCopy,\n  'arrow-up-bold': mdiArrowUpBold,\n  'arrow-down-bold': mdiArrowDownBold,\n  'arrow-right': mdiArrowRight,\n  'swap-horizontal': mdiSwapHorizontal,\n  'rotate-right': mdiRotateRight,\n  'backup-restore': mdiBackupRestore,\n\n  // 状态图标\n  'play-circle': mdiPlayCircle,\n  'pause-circle': mdiPauseCircle,\n  'stop-circle': mdiStopCircle,\n  'check-circle': mdiCheckCircle,\n  'alert-circle': mdiAlertCircle,\n  'alert-circle-outline': mdiAlertCircleOutline,\n  'close-circle': mdiCloseCircle,\n  'help-circle': mdiHelpCircle,\n  'alert': mdiAlert,\n\n  // 防护盾牌图标\n  'shield-refresh': mdiShieldRefresh,\n  'shield-off-outline': mdiShieldOffOutline,\n\n  // 功能图标\n  'key': mdiKey,\n  'key-plus': mdiKeyPlus,\n  'key-chain': mdiKeyChain,\n  'speedometer': mdiSpeedometer,\n  'speedometer-slow': mdiSpeedometerSlow,\n  'rocket-launch': mdiRocketLaunch,\n  'playlist-remove': mdiPlaylistRemove,\n  'tag': mdiTag,\n  'information': mdiInformation,\n  'cog': mdiCog,\n  'web': mdiWeb,\n  'shield-alert': mdiShieldAlert,\n  'text': mdiText,\n  'tune': mdiTune,\n  'dice-6': mdiDice6,\n  'heart-pulse': mdiHeartPulse,\n  'server-network': mdiServerNetwork,\n  'pin': mdiPin,\n  'pin-outline': mdiPinOutline,\n  'lightning-bolt': mdiLightningBolt,\n  'form-textbox': mdiFormTextbox,\n  'clock-outline': mdiClockOutline,\n  'paperclip': mdiPaperclip,\n  'eye-dropper': mdiEyedropper,\n\n  // 主题切换\n  'weather-night': mdiWeatherNight,\n  'white-balance-sunny': mdiWhiteBalanceSunny,\n\n  // 服务类型图标\n  'robot': mdiRobot,\n  'robot-outline': mdiRobotOutline,\n  'message-processing': mdiMessageProcessing,\n  'diamond-stone': mdiDiamondStone,\n  'api': mdiApi,\n\n  // 复选框和单选框\n  'checkbox-marked': mdiCheckboxMarked,\n  'checkbox-blank-outline': mdiCheckboxBlankOutline,\n  'minus-box': mdiMinusBox,\n  'radiobox-marked': mdiRadioboxMarked,\n  'radiobox-blank': mdiRadioboxBlank,\n\n  // 评分\n  'star': mdiStar,\n  'star-outline': mdiStarOutline,\n  'star-half': mdiStarHalf,\n\n  // 分页\n  'page-first': mdiPageFirst,\n  'page-last': mdiPageLast,\n\n  // 其他\n  'unfold-more-horizontal': mdiUnfoldMoreHorizontal,\n  'circle': mdiCircle,\n\n  // 图表与数据\n  'chart-timeline-variant': mdiChartTimelineVariant,\n  'chart-areaspline': mdiChartAreaspline,\n  'chart-line': mdiChartLine,\n  'code-braces': mdiCodeBraces,\n  'database': mdiDatabase,\n\n  // 签名图标\n  'signature': mdiSignature,\n\n  // 置顶/置底操作\n  'arrow-collapse-up': mdiArrowCollapseUp,\n  'arrow-collapse-down': mdiArrowCollapseDown,\n}\n\n// 自定义 SVG iconset - 处理 mdi-xxx 字符串格式\nconst customSvgIconSet: IconSet = {\n  component: (props: IconProps) => {\n    // 获取图标名称，去掉 mdi- 前缀\n    let iconName = props.icon as string\n    if (iconName.startsWith('mdi-')) {\n      iconName = iconName.substring(4)\n    }\n\n    // 查找对应的 SVG path\n    const svgPath = iconMap[iconName]\n\n    if (!svgPath) {\n      if (import.meta.env.DEV) {\n        console.warn(`[Vuetify Icon] 未找到图标: ${iconName}，请在 vuetify.ts 的 iconMap 中添加映射`)\n      }\n      return h('span', `[${iconName}]`)\n    }\n\n    return h('svg', {\n      class: 'v-icon__svg',\n      xmlns: 'http://www.w3.org/2000/svg',\n      viewBox: '0 0 24 24',\n      role: 'img',\n      'aria-hidden': 'true',\n      style: {\n        fontSize: 'inherit',\n        width: '1em',\n        height: '1em',\n      },\n    }, [\n      h('path', {\n        d: svgPath,\n        fill: 'currentColor',\n      })\n    ])\n  }\n}\n\n// 🎨 精心设计的现代化配色方案\n// Light Theme - 清新专业，柔和渐变\nconst lightTheme: ThemeDefinition = {\n  dark: false,\n  colors: {\n    // 主色调 - 现代蓝紫渐变感\n    primary: '#6366F1', // Indigo - 沉稳专业\n    secondary: '#8B5CF6', // Violet - 辅助强调\n    accent: '#EC4899', // Pink - 活力点缀\n\n    // 语义色彩 - 清晰易辨\n    info: '#3B82F6', // Blue\n    success: '#10B981', // Emerald\n    warning: '#F59E0B', // Amber\n    error: '#EF4444', // Red\n\n    // 表面色 - 柔和分层\n    background: '#F8FAFC', // Slate-50\n    surface: '#FFFFFF', // Pure white cards\n    'surface-variant': '#F1F5F9', // Slate-100 for secondary surfaces\n    'on-surface': '#1E293B', // Slate-800\n    'on-background': '#334155' // Slate-700\n  }\n}\n\n// Dark Theme - 深邃优雅，护眼舒适\nconst darkTheme: ThemeDefinition = {\n  dark: true,\n  colors: {\n    // 主色调 - 亮度适中，不刺眼\n    primary: '#818CF8', // Indigo-400\n    secondary: '#A78BFA', // Violet-400\n    accent: '#F472B6', // Pink-400\n\n    // 语义色彩 - 暗色适配\n    info: '#60A5FA', // Blue-400\n    success: '#34D399', // Emerald-400\n    warning: '#FBBF24', // Amber-400\n    error: '#F87171', // Red-400\n\n    // 表面色 - 深色层次分明\n    background: '#0F172A', // Slate-900\n    surface: '#1E293B', // Slate-800\n    'surface-variant': '#334155', // Slate-700\n    'on-surface': '#F1F5F9', // Slate-100\n    'on-background': '#E2E8F0' // Slate-200\n  }\n}\n\nexport default createVuetify({\n  components,\n  directives,\n  icons: {\n    defaultSet: 'mdi',\n    sets: {\n      mdi: customSvgIconSet\n    }\n  },\n  theme: {\n    defaultTheme: 'light',\n    themes: {\n      light: lightTheme,\n      dark: darkTheme\n    }\n  }\n})\n"
  },
  {
    "path": "frontend/src/router/index.ts",
    "content": "import { createRouter, createWebHistory } from 'vue-router'\nimport { useAuthStore } from '@/stores/auth'\n\nconst routes = [\n  {\n    path: '/',\n    redirect: '/channels/messages'  // 默认跳转到 Messages\n  },\n  {\n    path: '/channels/:type',  // 动态参数匹配 messages/responses/gemini\n    component: () => import('@/views/ChannelsView.vue'),  // 懒加载\n    props: true,  // 将路由参数作为 props 传递\n    meta: { requiresAuth: true }\n  }\n]\n\nconst router = createRouter({\n  history: createWebHistory(),  // 使用 HTML5 History 模式\n  routes\n})\n\n// 认证守卫（可选，认证逻辑已在 App.vue 中处理）\nrouter.beforeEach((to, from, next) => {\n  const authStore = useAuthStore()\n  if (to.meta.requiresAuth && !authStore.isAuthenticated) {\n    // 认证对话框已在 App.vue 中处理，无需重定向\n    next()\n  } else {\n    next()\n  }\n})\n\nexport default router\n"
  },
  {
    "path": "frontend/src/services/api.ts",
    "content": "// API服务模块\nimport { useAuthStore } from '@/stores/auth'\n\nexport class ApiError extends Error {\n  readonly status: number\n  readonly details?: unknown\n\n  constructor(message: string, status: number, details?: unknown) {\n    super(message)\n    this.name = 'ApiError'\n    this.status = status\n    this.details = details\n  }\n}\n\n// 从环境变量读取配置\nconst getApiBase = () => {\n  // 在生产环境中，API调用会直接请求当前域名\n  if (import.meta.env.PROD) {\n    return '/api'\n  }\n\n  // 在开发环境中，支持从环境变量配置后端地址\n  const backendUrl = import.meta.env.VITE_BACKEND_URL\n  const apiBasePath = import.meta.env.VITE_API_BASE_PATH || '/api'\n\n  if (backendUrl) {\n    return `${backendUrl}${apiBasePath}`\n  }\n\n  // fallback到默认配置\n  return '/api'\n}\n\nconst API_BASE = getApiBase()\n\n// 打印当前API配置（仅开发环境）\nif (import.meta.env.DEV) {\n  console.log('🔗 API Configuration:', {\n    API_BASE,\n    BACKEND_URL: import.meta.env.VITE_BACKEND_URL,\n    IS_DEV: import.meta.env.DEV,\n    IS_PROD: import.meta.env.PROD\n  })\n}\n\n// 渠道状态枚举\nexport type ChannelStatus = 'active' | 'suspended' | 'disabled'\n\n// 渠道指标\n// 分时段统计\nexport interface TimeWindowStats {\n  requestCount: number\n  successCount: number\n  failureCount: number\n  successRate: number\n  inputTokens?: number\n  outputTokens?: number\n  cacheCreationTokens?: number\n  cacheReadTokens?: number\n  cacheHitRate?: number\n}\n\nexport interface ChannelMetrics {\n  channelIndex: number\n  requestCount: number\n  successCount: number\n  failureCount: number\n  successRate: number       // 0-100\n  errorRate: number         // 0-100\n  consecutiveFailures: number\n  latency: number           // ms\n  lastSuccessAt?: string\n  lastFailureAt?: string\n  // 分时段统计 (15m, 1h, 6h, 24h)\n  timeWindows?: {\n    '15m': TimeWindowStats\n    '1h': TimeWindowStats\n    '6h': TimeWindowStats\n    '24h': TimeWindowStats\n  }\n}\n\nexport interface Channel {\n  name: string\n  serviceType: 'openai' | 'gemini' | 'claude' | 'responses'\n  baseUrl: string\n  baseUrls?: string[]                // 多 BaseURL 支持（failover 模式）\n  apiKeys: string[]\n  description?: string\n  website?: string\n  insecureSkipVerify?: boolean\n  modelMapping?: Record<string, string>\n  latency?: number\n  status?: ChannelStatus | 'healthy' | 'error' | 'unknown' | ''\n  index: number\n  pinned?: boolean\n  // 多渠道调度相关字段\n  priority?: number          // 渠道优先级（数字越小优先级越高）\n  metrics?: ChannelMetrics   // 实时指标\n  suspendReason?: string     // 熔断原因\n  promotionUntil?: string    // 促销期截止时间（ISO 格式）\n  latencyTestTime?: number   // 延迟测试时间戳（用于 5 分钟后自动清除显示）\n  lowQuality?: boolean       // 低质量渠道标记：启用后强制本地估算 token，偏差>5%时使用本地值\n  injectDummyThoughtSignature?: boolean  // Gemini 特定：为 functionCall 注入 dummy thought_signature（兼容第三方 API）\n  stripThoughtSignature?: boolean        // Gemini 特定：移除 thought_signature 字段（兼容旧版 Gemini API）\n}\n\nexport interface ChannelsResponse {\n  channels: Channel[]\n  current: number\n  loadBalance: string\n}\n\n// 渠道仪表盘响应（合并 channels + metrics + stats）\nexport interface ChannelDashboardResponse {\n  channels: Channel[]\n  loadBalance: string\n  metrics: ChannelMetrics[]\n  stats: {\n    multiChannelMode: boolean\n    activeChannelCount: number\n    traceAffinityCount: number\n    traceAffinityTTL: string\n    failureThreshold: number\n    windowSize: number\n    circuitRecoveryTime: string\n  }\n  recentActivity?: ChannelRecentActivity[]  // 最近 15 分钟分段活跃度\n}\n\nexport interface PingResult {\n  success: boolean\n  latency: number\n  status: string\n  error?: string\n}\n\n// 历史数据点（用于时间序列图表）\nexport interface HistoryDataPoint {\n  timestamp: string\n  requestCount: number\n  successCount: number\n  failureCount: number\n  successRate: number\n}\n\n// 渠道历史指标响应\nexport interface MetricsHistoryResponse {\n  channelIndex: number\n  channelName: string\n  dataPoints: HistoryDataPoint[]\n}\n\n// Key 级别历史数据点（包含 Token 数据）\nexport interface KeyHistoryDataPoint {\n  timestamp: string\n  requestCount: number\n  successCount: number\n  failureCount: number\n  successRate: number\n  inputTokens: number\n  outputTokens: number\n  cacheCreationTokens: number\n  cacheReadTokens: number\n}\n\n// 单个 Key 的历史数据\nexport interface KeyHistoryData {\n  keyMask: string\n  color: string\n  dataPoints: KeyHistoryDataPoint[]\n}\n\n// 渠道 Key 级别历史指标响应\nexport interface ChannelKeyMetricsHistoryResponse {\n  channelIndex: number\n  channelName: string\n  keys: KeyHistoryData[]\n}\n\n// ============== 全局统计类型 ==============\n\n// 全局历史数据点（包含 Token 数据）\nexport interface GlobalHistoryDataPoint {\n  timestamp: string\n  requestCount: number\n  successCount: number\n  failureCount: number\n  successRate: number\n  inputTokens: number\n  outputTokens: number\n  cacheCreationTokens: number\n  cacheReadTokens: number\n}\n\n// 全局统计汇总\nexport interface GlobalStatsSummary {\n  totalRequests: number\n  totalSuccess: number\n  totalFailure: number\n  totalInputTokens: number\n  totalOutputTokens: number\n  totalCacheCreationTokens: number\n  totalCacheReadTokens: number\n  avgSuccessRate: number\n  duration: string\n}\n\n// 全局统计响应\nexport interface GlobalStatsHistoryResponse {\n  dataPoints: GlobalHistoryDataPoint[]\n  summary: GlobalStatsSummary\n}\n\n// ============== 渠道实时活跃度类型 ==============\n\n// 活跃度分段数据（每 6 秒一段）\nexport interface ActivitySegment {\n  requestCount: number\n  successCount: number\n  failureCount: number\n  inputTokens: number\n  outputTokens: number\n}\n\n// 渠道最近活跃度数据\nexport interface ChannelRecentActivity {\n  channelIndex: number\n  segments: ActivitySegment[]  // 150 段，每段 6 秒，从旧到新（共 15 分钟）\n  rpm: number                  // 15分钟平均 RPM\n  tpm: number                  // 15分钟平均 TPM\n}\n\n// ============== 上游模型列表类型 ==============\n\nexport interface ModelEntry {\n  id: string\n  object: string\n  created: number\n  owned_by: string\n}\n\nexport interface ModelsResponse {\n  object: string\n  data: ModelEntry[]\n}\n\n/**\n * 构建上游的 /v1/models 端点 URL\n * 参考：backend-go/internal/handlers/messages/models.go:240-257\n */\nfunction buildModelsURL(baseURL: string): string {\n  // 处理 # 后缀（跳过版本前缀）\n  const skipVersionPrefix = baseURL.endsWith('#')\n  if (skipVersionPrefix) {\n    baseURL = baseURL.slice(0, -1)\n  }\n  baseURL = baseURL.replace(/\\/$/, '')\n\n  // 检查是否已有版本后缀（如 /v1, /v2）\n  const versionPattern = /\\/v\\d+[a-z]*$/\n  const hasVersionSuffix = versionPattern.test(baseURL)\n\n  // 构建端点\n  let endpoint = '/models'\n  if (!hasVersionSuffix && !skipVersionPrefix) {\n    endpoint = '/v1' + endpoint\n  }\n\n  return baseURL + endpoint\n}\n\n/**\n * 直接从上游获取模型列表（前端直连）\n */\nexport async function fetchUpstreamModels(\n  baseUrl: string,\n  apiKey: string\n): Promise<ModelsResponse> {\n  const url = buildModelsURL(baseUrl)\n\n  const response = await fetch(url, {\n    method: 'GET',\n    headers: {\n      'Authorization': `Bearer ${apiKey}`\n    },\n    signal: AbortSignal.timeout(10000) // 10秒超时\n  })\n\n  if (!response.ok) {\n    let errorMessage = `${response.status} ${response.statusText}`\n    let errorDetails: unknown = null\n\n    try {\n      const errorText = await response.text()\n      if (errorText) {\n        const errorJson = JSON.parse(errorText)\n        // 解析上游错误格式: { \"error\": { \"code\": \"\", \"message\": \"...\", \"type\": \"...\" } }\n        if (errorJson.error && errorJson.error.message) {\n          errorMessage = errorJson.error.message\n          errorDetails = errorJson.error\n        } else if (errorJson.message) {\n          errorMessage = errorJson.message\n          errorDetails = errorJson\n        }\n      }\n    } catch {\n      // 解析失败,使用默认错误消息\n    }\n\n    throw new ApiError(errorMessage, response.status, errorDetails)\n  }\n\n  return await response.json()\n}\n\nclass ApiService {\n  // 获取当前 API Key（从 AuthStore）\n  private getApiKey(): string | null {\n    const authStore = useAuthStore()\n    return authStore.apiKey\n  }\n\n  private async parseResponseBody(response: Response): Promise<unknown> {\n    const text = await response.text()\n    if (!text) return null\n    try {\n      return JSON.parse(text)\n    } catch {\n      return text\n    }\n  }\n\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  private async request(url: string, options: RequestInit = {}): Promise<any> {\n    const headers: Record<string, string> = {\n      'Content-Type': 'application/json',\n      ...(options.headers as Record<string, string>)\n    }\n\n    // 从 AuthStore 获取 API 密钥并添加到请求头\n    const apiKey = this.getApiKey()\n    if (apiKey) {\n      headers['x-api-key'] = apiKey\n    }\n\n    const response = await fetch(`${API_BASE}${url}`, {\n      ...options,\n      headers\n    })\n\n    if (!response.ok) {\n      const errorBody = await this.parseResponseBody(response)\n      const errorMessage =\n        (typeof errorBody === 'object' && errorBody && 'error' in errorBody && typeof (errorBody as { error?: unknown }).error === 'string'\n          ? (errorBody as { error: string }).error\n          : typeof errorBody === 'object' && errorBody && 'message' in errorBody && typeof (errorBody as { message?: unknown }).message === 'string'\n            ? (errorBody as { message: string }).message\n            : typeof errorBody === 'string'\n              ? errorBody\n              : null) || `Request failed (${response.status})`\n\n      // 如果是401错误，清除认证信息并提示用户重新登录\n      if (response.status === 401) {\n        const authStore = useAuthStore()\n        authStore.clearAuth()\n        // 记录认证失败(前端日志)\n        if (import.meta.env.DEV) {\n          console.warn('🔒 认证失败 - 时间:', new Date().toISOString())\n        }\n        throw new ApiError('认证失败，请重新输入访问密钥', response.status, errorBody)\n      }\n\n      throw new ApiError(errorMessage, response.status, errorBody)\n    }\n\n    if (response.status === 204) return null\n    return this.parseResponseBody(response)\n  }\n\n  async getChannels(): Promise<ChannelsResponse> {\n    return this.request('/messages/channels')\n  }\n\n  async addChannel(channel: Omit<Channel, 'index' | 'latency' | 'status'>): Promise<void> {\n    await this.request('/messages/channels', {\n      method: 'POST',\n      body: JSON.stringify(channel)\n    })\n  }\n\n  async updateChannel(id: number, channel: Partial<Channel>): Promise<void> {\n    await this.request(`/messages/channels/${id}`, {\n      method: 'PUT',\n      body: JSON.stringify(channel)\n    })\n  }\n\n  async deleteChannel(id: number): Promise<void> {\n    await this.request(`/messages/channels/${id}`, {\n      method: 'DELETE'\n    })\n  }\n\n  async addApiKey(channelId: number, apiKey: string): Promise<void> {\n    await this.request(`/messages/channels/${channelId}/keys`, {\n      method: 'POST',\n      body: JSON.stringify({ apiKey })\n    })\n  }\n\n  async removeApiKey(channelId: number, apiKey: string): Promise<void> {\n    await this.request(`/messages/channels/${channelId}/keys/${encodeURIComponent(apiKey)}`, {\n      method: 'DELETE'\n    })\n  }\n\n  async pingChannel(id: number): Promise<PingResult> {\n    return this.request(`/messages/ping/${id}`)\n  }\n\n  async pingAllChannels(): Promise<Array<{ id: number; name: string; latency: number; status: string }>> {\n    return this.request('/messages/ping')\n  }\n\n  async updateLoadBalance(strategy: string): Promise<void> {\n    await this.request('/loadbalance', {\n      method: 'PUT',\n      body: JSON.stringify({ strategy })\n    })\n  }\n\n  async updateResponsesLoadBalance(strategy: string): Promise<void> {\n    await this.request('/responses/loadbalance', {\n      method: 'PUT',\n      body: JSON.stringify({ strategy })\n    })\n  }\n\n  // ============== Responses 渠道管理 API ==============\n\n  async getResponsesChannels(): Promise<ChannelsResponse> {\n    return this.request('/responses/channels')\n  }\n\n  async addResponsesChannel(channel: Omit<Channel, 'index' | 'latency' | 'status'>): Promise<void> {\n    await this.request('/responses/channels', {\n      method: 'POST',\n      body: JSON.stringify(channel)\n    })\n  }\n\n  async updateResponsesChannel(id: number, channel: Partial<Channel>): Promise<void> {\n    await this.request(`/responses/channels/${id}`, {\n      method: 'PUT',\n      body: JSON.stringify(channel)\n    })\n  }\n\n  async deleteResponsesChannel(id: number): Promise<void> {\n    await this.request(`/responses/channels/${id}`, {\n      method: 'DELETE'\n    })\n  }\n\n  async addResponsesApiKey(channelId: number, apiKey: string): Promise<void> {\n    await this.request(`/responses/channels/${channelId}/keys`, {\n      method: 'POST',\n      body: JSON.stringify({ apiKey })\n    })\n  }\n\n  async removeResponsesApiKey(channelId: number, apiKey: string): Promise<void> {\n    await this.request(`/responses/channels/${channelId}/keys/${encodeURIComponent(apiKey)}`, {\n      method: 'DELETE'\n    })\n  }\n\n  async moveApiKeyToTop(channelId: number, apiKey: string): Promise<void> {\n    await this.request(`/messages/channels/${channelId}/keys/${encodeURIComponent(apiKey)}/top`, {\n      method: 'POST'\n    })\n  }\n\n  async moveApiKeyToBottom(channelId: number, apiKey: string): Promise<void> {\n    await this.request(`/messages/channels/${channelId}/keys/${encodeURIComponent(apiKey)}/bottom`, {\n      method: 'POST'\n    })\n  }\n\n  async moveResponsesApiKeyToTop(channelId: number, apiKey: string): Promise<void> {\n    await this.request(`/responses/channels/${channelId}/keys/${encodeURIComponent(apiKey)}/top`, {\n      method: 'POST'\n    })\n  }\n\n  async moveResponsesApiKeyToBottom(channelId: number, apiKey: string): Promise<void> {\n    await this.request(`/responses/channels/${channelId}/keys/${encodeURIComponent(apiKey)}/bottom`, {\n      method: 'POST'\n    })\n  }\n\n  // ============== 多渠道调度 API ==============\n\n  // 重新排序渠道优先级\n  async reorderChannels(order: number[]): Promise<void> {\n    await this.request('/messages/channels/reorder', {\n      method: 'POST',\n      body: JSON.stringify({ order })\n    })\n  }\n\n  // 设置渠道状态\n  async setChannelStatus(channelId: number, status: ChannelStatus): Promise<void> {\n    await this.request(`/messages/channels/${channelId}/status`, {\n      method: 'PATCH',\n      body: JSON.stringify({ status })\n    })\n  }\n\n  // 恢复熔断渠道（重置错误计数）\n  async resumeChannel(channelId: number): Promise<void> {\n    await this.request(`/messages/channels/${channelId}/resume`, {\n      method: 'POST'\n    })\n  }\n\n  // 获取渠道指标\n  async getChannelMetrics(): Promise<ChannelMetrics[]> {\n    return this.request('/messages/channels/metrics')\n  }\n\n  // 获取调度器统计信息\n  async getSchedulerStats(type?: 'messages' | 'responses' | 'gemini'): Promise<{\n    multiChannelMode: boolean\n    activeChannelCount: number\n    traceAffinityCount: number\n    traceAffinityTTL: string\n    failureThreshold: number\n    windowSize: number\n  }> {\n    // Gemini 暂无调度器统计，返回默认值\n    if (type === 'gemini') {\n      return {\n        multiChannelMode: false,\n        activeChannelCount: 0,\n        traceAffinityCount: 0,\n        traceAffinityTTL: '0s',\n        failureThreshold: 0,\n        windowSize: 0\n      }\n    }\n    const query = type === 'responses' ? '?type=responses' : ''\n    return this.request(`/messages/channels/scheduler/stats${query}`)\n  }\n\n  // 获取渠道仪表盘数据（合并 channels + metrics + stats）\n  async getChannelDashboard(type: 'messages' | 'responses' | 'gemini' = 'messages'): Promise<ChannelDashboardResponse> {\n    // Gemini 使用降级实现：组合 getChannels + getMetrics\n    if (type === 'gemini') {\n      return this.getGeminiChannelDashboard()\n    }\n    const query = type === 'responses' ? '?type=responses' : ''\n    return this.request(`/messages/channels/dashboard${query}`)\n  }\n\n  // ============== Responses 多渠道调度 API ==============\n\n  // 重新排序 Responses 渠道优先级\n  async reorderResponsesChannels(order: number[]): Promise<void> {\n    await this.request('/responses/channels/reorder', {\n      method: 'POST',\n      body: JSON.stringify({ order })\n    })\n  }\n\n  // 设置 Responses 渠道状态\n  async setResponsesChannelStatus(channelId: number, status: ChannelStatus): Promise<void> {\n    await this.request(`/responses/channels/${channelId}/status`, {\n      method: 'PATCH',\n      body: JSON.stringify({ status })\n    })\n  }\n\n  // 恢复 Responses 熔断渠道\n  async resumeResponsesChannel(channelId: number): Promise<void> {\n    await this.request(`/responses/channels/${channelId}/resume`, {\n      method: 'POST'\n    })\n  }\n\n  // 获取 Responses 渠道指标\n  async getResponsesChannelMetrics(): Promise<ChannelMetrics[]> {\n    return this.request('/responses/channels/metrics')\n  }\n\n  // ============== 促销期管理 API ==============\n\n  // 设置 Messages 渠道促销期\n  async setChannelPromotion(channelId: number, durationSeconds: number): Promise<void> {\n    await this.request(`/messages/channels/${channelId}/promotion`, {\n      method: 'POST',\n      body: JSON.stringify({ duration: durationSeconds })\n    })\n  }\n\n  // 设置 Responses 渠道促销期\n  async setResponsesChannelPromotion(channelId: number, durationSeconds: number): Promise<void> {\n    await this.request(`/responses/channels/${channelId}/promotion`, {\n      method: 'POST',\n      body: JSON.stringify({ duration: durationSeconds })\n    })\n  }\n\n  // ============== Fuzzy 模式 API ==============\n\n  // 获取 Fuzzy 模式状态\n  async getFuzzyMode(): Promise<{ fuzzyModeEnabled: boolean }> {\n    return this.request('/settings/fuzzy-mode')\n  }\n\n  // 设置 Fuzzy 模式状态\n  async setFuzzyMode(enabled: boolean): Promise<void> {\n    await this.request('/settings/fuzzy-mode', {\n      method: 'PUT',\n      body: JSON.stringify({ enabled })\n    })\n  }\n\n  // ============== 历史指标 API ==============\n\n  // 获取 Messages 渠道历史指标（用于时间序列图表）\n  async getChannelMetricsHistory(duration: '1h' | '6h' | '24h' = '24h'): Promise<MetricsHistoryResponse[]> {\n    return this.request(`/messages/channels/metrics/history?duration=${duration}`)\n  }\n\n  // 获取 Responses 渠道历史指标\n  async getResponsesChannelMetricsHistory(duration: '1h' | '6h' | '24h' = '24h'): Promise<MetricsHistoryResponse[]> {\n    return this.request(`/responses/channels/metrics/history?duration=${duration}`)\n  }\n\n  // ============== Key 级别历史指标 API ==============\n\n  // 获取 Messages 渠道 Key 级别历史指标（用于 Key 趋势图表）\n  async getChannelKeyMetricsHistory(channelId: number, duration: '1h' | '6h' | '24h' | 'today' = '6h'): Promise<ChannelKeyMetricsHistoryResponse> {\n    return this.request(`/messages/channels/${channelId}/keys/metrics/history?duration=${duration}`)\n  }\n\n  // 获取 Responses 渠道 Key 级别历史指标\n  async getResponsesChannelKeyMetricsHistory(channelId: number, duration: '1h' | '6h' | '24h' | 'today' = '6h'): Promise<ChannelKeyMetricsHistoryResponse> {\n    return this.request(`/responses/channels/${channelId}/keys/metrics/history?duration=${duration}`)\n  }\n\n  // ============== 全局统计 API ==============\n\n  // 获取 Messages 全局统计历史\n  async getMessagesGlobalStats(duration: '1h' | '6h' | '24h' | 'today' = '24h'): Promise<GlobalStatsHistoryResponse> {\n    return this.request(`/messages/global/stats/history?duration=${duration}`)\n  }\n\n  // 获取 Responses 全局统计历史\n  async getResponsesGlobalStats(duration: '1h' | '6h' | '24h' | 'today' = '24h'): Promise<GlobalStatsHistoryResponse> {\n    return this.request(`/responses/global/stats/history?duration=${duration}`)\n  }\n\n  // ============== Gemini 渠道管理 API ==============\n\n  async getGeminiChannels(): Promise<ChannelsResponse> {\n    return this.request('/gemini/channels')\n  }\n\n  async addGeminiChannel(channel: Omit<Channel, 'index' | 'latency' | 'status'>): Promise<void> {\n    await this.request('/gemini/channels', {\n      method: 'POST',\n      body: JSON.stringify(channel)\n    })\n  }\n\n  async updateGeminiChannel(id: number, channel: Partial<Channel>): Promise<void> {\n    await this.request(`/gemini/channels/${id}`, {\n      method: 'PUT',\n      body: JSON.stringify(channel)\n    })\n  }\n\n  async deleteGeminiChannel(id: number): Promise<void> {\n    await this.request(`/gemini/channels/${id}`, {\n      method: 'DELETE'\n    })\n  }\n\n  async addGeminiApiKey(channelId: number, apiKey: string): Promise<void> {\n    await this.request(`/gemini/channels/${channelId}/keys`, {\n      method: 'POST',\n      body: JSON.stringify({ apiKey })\n    })\n  }\n\n  async removeGeminiApiKey(channelId: number, apiKey: string): Promise<void> {\n    await this.request(`/gemini/channels/${channelId}/keys/${encodeURIComponent(apiKey)}`, {\n      method: 'DELETE'\n    })\n  }\n\n  async moveGeminiApiKeyToTop(channelId: number, apiKey: string): Promise<void> {\n    await this.request(`/gemini/channels/${channelId}/keys/${encodeURIComponent(apiKey)}/top`, {\n      method: 'POST'\n    })\n  }\n\n  async moveGeminiApiKeyToBottom(channelId: number, apiKey: string): Promise<void> {\n    await this.request(`/gemini/channels/${channelId}/keys/${encodeURIComponent(apiKey)}/bottom`, {\n      method: 'POST'\n    })\n  }\n\n  // ============== Gemini 多渠道调度 API ==============\n\n  async reorderGeminiChannels(order: number[]): Promise<void> {\n    await this.request('/gemini/channels/reorder', {\n      method: 'POST',\n      body: JSON.stringify({ order })\n    })\n  }\n\n  async setGeminiChannelStatus(channelId: number, status: ChannelStatus): Promise<void> {\n    await this.request(`/gemini/channels/${channelId}/status`, {\n      method: 'PATCH',\n      body: JSON.stringify({ status })\n    })\n  }\n\n  // Gemini 恢复渠道（降级实现：后端未实现 resume 端点，直接设置状态为 active）\n  async resumeGeminiChannel(channelId: number): Promise<void> {\n    await this.setGeminiChannelStatus(channelId, 'active')\n  }\n\n  async getGeminiChannelMetrics(): Promise<ChannelMetrics[]> {\n    return this.request('/gemini/channels/metrics')\n  }\n\n  async setGeminiChannelPromotion(channelId: number, durationSeconds: number): Promise<void> {\n    await this.request(`/gemini/channels/${channelId}/promotion`, {\n      method: 'POST',\n      body: JSON.stringify({ duration: durationSeconds })\n    })\n  }\n\n  async updateGeminiLoadBalance(strategy: string): Promise<void> {\n    await this.request('/gemini/loadbalance', {\n      method: 'PUT',\n      body: JSON.stringify({ strategy })\n    })\n  }\n\n  // ============== Gemini 历史指标 API ==============\n\n  // 获取 Gemini 渠道历史指标\n  async getGeminiChannelMetricsHistory(duration: '1h' | '6h' | '24h' = '24h'): Promise<MetricsHistoryResponse[]> {\n    return this.request(`/gemini/channels/metrics/history?duration=${duration}`)\n  }\n\n  // 获取 Gemini 渠道 Key 级别历史指标\n  async getGeminiChannelKeyMetricsHistory(channelId: number, duration: '1h' | '6h' | '24h' | 'today' = '6h'): Promise<ChannelKeyMetricsHistoryResponse> {\n    return this.request(`/gemini/channels/${channelId}/keys/metrics/history?duration=${duration}`)\n  }\n\n  // 获取 Gemini 全局统计历史\n  async getGeminiGlobalStats(duration: '1h' | '6h' | '24h' | 'today' = '24h'): Promise<GlobalStatsHistoryResponse> {\n    return this.request(`/gemini/global/stats/history?duration=${duration}`)\n  }\n\n  async pingGeminiChannel(id: number): Promise<PingResult> {\n    return this.request(`/gemini/ping/${id}`)\n  }\n\n  async pingAllGeminiChannels(): Promise<Array<{ id: number; name: string; latency: number; status: string }>> {\n    const resp = await this.request('/gemini/ping')\n    // 后端返回 { channels: [...] }，需要提取并转换字段名\n    return (resp.channels || []).map((ch: { index: number; name: string; latency: number; success: boolean }) => ({\n      id: ch.index,\n      name: ch.name,\n      latency: ch.latency,\n      status: ch.success ? 'healthy' : 'error'\n    }))\n  }\n\n  // Gemini Dashboard（使用后端统一接口）\n  async getGeminiChannelDashboard(): Promise<ChannelDashboardResponse> {\n    return this.request('/gemini/channels/dashboard')\n  }\n}\n\n// 健康检查响应类型\nexport interface HealthResponse {\n  version?: {\n    version: string\n    buildTime: string\n    gitCommit: string\n  }\n  timestamp: string\n  uptime: number\n  mode: string\n}\n\n/**\n * 获取健康检查信息（包含版本号）\n * 注意：/health 端点不需要认证，直接请求根路径\n */\nexport const fetchHealth = async (): Promise<HealthResponse> => {\n  const baseUrl = import.meta.env.PROD ? '' : (import.meta.env.VITE_BACKEND_URL || '')\n  const response = await fetch(`${baseUrl}/health`)\n  if (!response.ok) {\n    throw new Error(`Health check failed: ${response.status}`)\n  }\n  return response.json()\n}\n\nexport const api = new ApiService()\nexport default api\n"
  },
  {
    "path": "frontend/src/services/version.ts",
    "content": "/**\n * 版本检查服务\n * 参考 gpt-load 项目实现\n */\n\nconst CACHE_KEY = 'claude-proxy-version-info'\nconst CACHE_DURATION = 30 * 60 * 1000 // 30分钟缓存\nconst ERROR_CACHE_DURATION = 5 * 60 * 1000 // 错误状态缓存5分钟，避免频繁请求\nconst GITHUB_API_TIMEOUT = 10000 // 10秒超时\n\nexport interface GitHubRelease {\n  tag_name: string\n  html_url: string\n  published_at: string\n  name: string\n  prerelease?: boolean\n}\n\nexport interface VersionInfo {\n  currentVersion: string\n  latestVersion: string | null\n  isLatest: boolean\n  hasUpdate: boolean\n  releaseUrl: string | null\n  lastCheckTime: number\n  status: 'checking' | 'latest' | 'update-available' | 'error'\n}\n\n// 预发布版本标识正则（如 -rc1, -beta, -alpha 等）\nconst PRERELEASE_PATTERN = /-(alpha|beta|rc|dev|pre|canary|nightly)/i\n\nclass VersionService {\n  private currentVersion: string = ''\n\n  /**\n   * 检查是否为预发布版本\n   */\n  private isPrerelease(version: string): boolean {\n    return PRERELEASE_PATTERN.test(version)\n  }\n\n  /**\n   * 设置当前版本（从 /health 端点获取）\n   */\n  setCurrentVersion(version: string): void {\n    this.currentVersion = version\n  }\n\n  /**\n   * 获取当前版本\n   */\n  getCurrentVersion(): string {\n    return this.currentVersion\n  }\n\n  /**\n   * 从缓存获取版本信息\n   */\n  private getCachedVersionInfo(): VersionInfo | null {\n    try {\n      const cached = localStorage.getItem(CACHE_KEY)\n      if (!cached) {\n        return null\n      }\n\n      const versionInfo: VersionInfo = JSON.parse(cached)\n      const now = Date.now()\n\n      // 根据状态选择不同的缓存时长\n      const cacheDuration = versionInfo.status === 'error'\n        ? ERROR_CACHE_DURATION\n        : CACHE_DURATION\n\n      // 检查缓存是否过期\n      if (now - versionInfo.lastCheckTime > cacheDuration) {\n        return null\n      }\n\n      // 检查缓存中的版本号是否与当前应用版本号一致\n      if (versionInfo.currentVersion !== this.currentVersion) {\n        this.clearCache()\n        return null\n      }\n\n      return versionInfo\n    } catch (error) {\n      console.warn('Failed to parse cached version info:', error)\n      localStorage.removeItem(CACHE_KEY)\n      return null\n    }\n  }\n\n  /**\n   * 保存版本信息到缓存\n   */\n  private setCachedVersionInfo(info: VersionInfo): void {\n    try {\n      localStorage.setItem(CACHE_KEY, JSON.stringify(info))\n    } catch (error) {\n      console.warn('Failed to cache version info:', error)\n    }\n  }\n\n  /**\n   * 清除缓存\n   */\n  clearCache(): void {\n    localStorage.removeItem(CACHE_KEY)\n  }\n\n  /**\n   * 版本比较\n   * @returns -1: current < latest (有更新), 0: 相等, 1: current > latest\n   */\n  private compareVersions(current: string, latest: string): number {\n    // 移除 'v' 前缀，按 '.' 分割成数组\n    const currentParts = current.replace(/^v/, '').split('.').map(Number)\n    const latestParts = latest.replace(/^v/, '').split('.').map(Number)\n\n    // 遍历每一位版本号\n    for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {\n      const currentPart = currentParts[i] || 0\n      const latestPart = latestParts[i] || 0\n\n      if (currentPart < latestPart) {\n        return -1 // 当前版本更低\n      }\n      if (currentPart > latestPart) {\n        return 1 // 当前版本更高\n      }\n    }\n    return 0 // 版本相同\n  }\n\n  /**\n   * 从 GitHub API 获取最新正式版本（过滤预发布版本）\n   */\n  private async fetchLatestVersion(): Promise<GitHubRelease | null> {\n    try {\n      const controller = new AbortController()\n      const timeoutId = setTimeout(() => controller.abort(), GITHUB_API_TIMEOUT)\n\n      // 使用 /releases 端点获取最近的发布列表，然后过滤出第一个正式版本\n      const response = await fetch(\n        'https://api.github.com/repos/BenedictKing/claude-proxy/releases?per_page=10',\n        {\n          headers: {\n            Accept: 'application/vnd.github.v3+json',\n          },\n          signal: controller.signal,\n        }\n      )\n\n      clearTimeout(timeoutId)\n\n      if (response.status === 200) {\n        const releases: GitHubRelease[] = await response.json()\n        // 过滤掉预发布版本，返回第一个正式版本\n        const stableRelease = releases.find(\n          release => !release.prerelease && !this.isPrerelease(release.tag_name)\n        )\n        return stableRelease || null\n      }\n      return null\n    } catch (error) {\n      console.warn('Failed to fetch latest version from GitHub:', error)\n      return null\n    }\n  }\n\n  /**\n   * 检查更新\n   */\n  async checkForUpdates(): Promise<VersionInfo> {\n    // 如果没有当前版本，返回错误状态\n    if (!this.currentVersion) {\n      return {\n        currentVersion: '',\n        latestVersion: null,\n        isLatest: false,\n        hasUpdate: false,\n        releaseUrl: null,\n        lastCheckTime: Date.now(),\n        status: 'error',\n      }\n    }\n\n    // 先检查缓存\n    const cached = this.getCachedVersionInfo()\n    if (cached) {\n      return cached\n    }\n\n    // 创建初始状态\n    const versionInfo: VersionInfo = {\n      currentVersion: this.currentVersion,\n      latestVersion: null,\n      isLatest: false,\n      hasUpdate: false,\n      releaseUrl: null,\n      lastCheckTime: Date.now(),\n      status: 'checking',\n    }\n\n    // 获取最新版本\n    try {\n      const release = await this.fetchLatestVersion()\n\n      if (release) {\n        const comparison = this.compareVersions(this.currentVersion, release.tag_name)\n\n        versionInfo.latestVersion = release.tag_name\n        versionInfo.releaseUrl = release.html_url\n        versionInfo.isLatest = comparison >= 0\n        versionInfo.hasUpdate = comparison < 0\n        versionInfo.status = comparison < 0 ? 'update-available' : 'latest'\n\n        // 成功时缓存结果（30分钟）\n        this.setCachedVersionInfo(versionInfo)\n      } else {\n        versionInfo.status = 'error'\n        // 错误时也缓存（5分钟），避免频繁请求 GitHub API\n        this.setCachedVersionInfo(versionInfo)\n      }\n    } catch (error) {\n      console.warn('Version check failed:', error)\n      versionInfo.status = 'error'\n      // 错误时也缓存（5分钟），避免频繁请求 GitHub API\n      this.setCachedVersionInfo(versionInfo)\n    }\n\n    return versionInfo\n  }\n}\n\n// 导出单例\nexport const versionService = new VersionService()\n"
  },
  {
    "path": "frontend/src/stores/auth.ts",
    "content": "import { defineStore } from 'pinia'\nimport { ref, computed } from 'vue'\n\n/**\n * 认证状态管理 Store\n *\n * 职责：\n * - 管理 API Key 的存储和读取\n * - 管理认证错误和安全状态（失败次数、锁定时间等）\n * - 管理认证 UI 状态（加载中、自动认证等）\n * - 提供响应式的认证状态\n * - 自动持久化到 localStorage\n */\nexport const useAuthStore = defineStore('auth', () => {\n  // ===== 状态 =====\n\n  // API Key\n  const apiKey = ref<string | null>(null)\n\n  // 认证错误消息\n  const authError = ref('')\n\n  // 认证失败次数\n  const authAttempts = ref(0)\n\n  // 认证锁定时间（存储时间戳）\n  const authLockoutTime = ref<number | null>(null)\n\n  // 自动认证进行中\n  const isAutoAuthenticating = ref(true) // 初始化为true，防止登录框闪现\n\n  // 初始化完成标志\n  const isInitialized = ref(false)\n\n  // 认证加载状态\n  const authLoading = ref(false)\n\n  // 认证输入框值\n  const authKeyInput = ref('')\n\n  // ===== 计算属性 =====\n\n  const isAuthenticated = computed(() => !!apiKey.value)\n\n  // 检查是否被锁定\n  const isAuthLocked = computed(() => {\n    if (!authLockoutTime.value) return false\n    return Date.now() < authLockoutTime.value\n  })\n\n  // ===== 操作方法 =====\n\n  function setApiKey(key: string | null) {\n    apiKey.value = key\n    // 同时保存到旧的 localStorage key 以保持兼容性\n    if (key) {\n      localStorage.setItem('proxyAccessKey', key)\n    } else {\n      localStorage.removeItem('proxyAccessKey')\n    }\n  }\n\n  function clearAuth() {\n    apiKey.value = null\n    // 清除旧的 localStorage key\n    localStorage.removeItem('proxyAccessKey')\n  }\n\n  function initializeAuth() {\n    // 优先从旧的 localStorage key 读取（兼容性）\n    const oldKey = localStorage.getItem('proxyAccessKey')\n    if (oldKey) {\n      apiKey.value = oldKey\n      return\n    }\n\n    // 如果没有旧 key，尝试从 Pinia 持久化恢复\n    // （由 persistedstate 插件自动处理）\n  }\n\n  function setAuthError(error: string) {\n    authError.value = error\n  }\n\n  function incrementAuthAttempts() {\n    authAttempts.value++\n  }\n\n  function resetAuthAttempts() {\n    authAttempts.value = 0\n  }\n\n  function setAuthLockout(lockoutTime: Date | null) {\n    authLockoutTime.value = lockoutTime ? lockoutTime.getTime() : null\n  }\n\n  function setAutoAuthenticating(value: boolean) {\n    isAutoAuthenticating.value = value\n  }\n\n  function setInitialized(value: boolean) {\n    isInitialized.value = value\n  }\n\n  function setAuthLoading(value: boolean) {\n    authLoading.value = value\n  }\n\n  function setAuthKeyInput(value: string) {\n    authKeyInput.value = value\n  }\n\n  return {\n    // 状态\n    apiKey,\n    authError,\n    authAttempts,\n    authLockoutTime,\n    isAutoAuthenticating,\n    isInitialized,\n    authLoading,\n    authKeyInput,\n\n    // 计算属性\n    isAuthenticated,\n    isAuthLocked,\n\n    // 方法\n    setApiKey,\n    clearAuth,\n    initializeAuth,\n    setAuthError,\n    incrementAuthAttempts,\n    resetAuthAttempts,\n    setAuthLockout,\n    setAutoAuthenticating,\n    setInitialized,\n    setAuthLoading,\n    setAuthKeyInput,\n  }\n}, {\n  // 持久化配置\n  persist: {\n    key: 'claude-proxy-auth',\n    storage: localStorage,\n    // 仅持久化必要字段，排除瞬态 UI 状态和敏感输入\n    pick: ['apiKey', 'authAttempts', 'authLockoutTime'],\n  },\n})\n"
  },
  {
    "path": "frontend/src/stores/channel.ts",
    "content": "import { defineStore } from 'pinia'\nimport { ref, computed, watch } from 'vue'\nimport { useRouter } from 'vue-router'\nimport { api, type Channel, type ChannelsResponse, type ChannelMetrics, type ChannelDashboardResponse } from '@/services/api'\n\n/**\n * 渠道数据管理 Store\n *\n * 职责：\n * - 管理三种 API 类型的渠道数据（Messages/Responses/Gemini）\n * - 管理渠道指标和统计数据\n * - 提供渠道操作方法（添加、编辑、删除、测试延迟等）\n * - 管理自动刷新定时器\n */\nexport const useChannelStore = defineStore('channel', () => {\n  // ===== 状态 =====\n\n  // 当前选中的 API 类型\n  type ApiTab = 'messages' | 'responses' | 'gemini'\n  const activeTab = ref<ApiTab>('messages')\n\n  // 路由同步：从路由读取当前类型\n  const router = useRouter()\n  const currentChannelType = computed(() => {\n    const route = router.currentRoute.value\n    const type = route.params.type as ApiTab\n    return (type === 'messages' || type === 'responses' || type === 'gemini') ? type : 'messages'\n  })\n\n  // 监听路由变化，同步 activeTab（确保兼容性）\n  watch(currentChannelType, (newType) => {\n    activeTab.value = newType\n  }, { immediate: true })\n\n  // 三种 API 类型的渠道数据\n  const channelsData = ref<ChannelsResponse>({\n    channels: [],\n    current: -1,\n    loadBalance: 'round-robin'\n  })\n\n  const responsesChannelsData = ref<ChannelsResponse>({\n    channels: [],\n    current: -1,\n    loadBalance: 'round-robin'\n  })\n\n  const geminiChannelsData = ref<ChannelsResponse>({\n    channels: [],\n    current: -1,\n    loadBalance: 'round-robin'\n  })\n\n  // Dashboard 数据缓存结构（每个 tab 独立缓存）\n  interface DashboardCache {\n    metrics: ChannelMetrics[]\n    stats: ChannelDashboardResponse['stats'] | undefined\n    recentActivity: ChannelDashboardResponse['recentActivity'] | undefined\n  }\n\n  const dashboardCache = ref<Record<ApiTab, DashboardCache>>({\n    messages: {\n      metrics: [],\n      stats: undefined,\n      recentActivity: undefined\n    },\n    responses: {\n      metrics: [],\n      stats: undefined,\n      recentActivity: undefined\n    },\n    gemini: {\n      metrics: [],\n      stats: undefined,\n      recentActivity: undefined\n    }\n  })\n\n  // 批量延迟测试加载状态\n  const isPingingAll = ref(false)\n\n  // 最后一次刷新状态（用于 systemStatus 更新）\n  const lastRefreshSuccess = ref(true)\n\n  // 自动刷新定时器（串行 setTimeout，避免重入）\n  let autoRefreshTimer: ReturnType<typeof setTimeout> | null = null\n  let autoRefreshRunning = false\n  const AUTO_REFRESH_INTERVAL = 2000 // 2秒\n\n  // 刷新并发控制：同一时间只允许一个 refresh 在跑；期间再次调用会被合并成一次后续刷新\n  let refreshLoopPromise: Promise<void> | null = null\n  let refreshRequested = false\n\n  // ===== 计算属性 =====\n\n  // 根据当前 Tab 返回对应的渠道数据\n  const currentChannelsData = computed(() => {\n    switch (activeTab.value) {\n      case 'messages': return channelsData.value\n      case 'responses': return responsesChannelsData.value\n      case 'gemini': return geminiChannelsData.value\n      default: return channelsData.value\n    }\n  })\n\n  // 根据当前 Tab 返回对应的 Dashboard 数据（独立缓存，避免切换闪烁）\n  const currentDashboardMetrics = computed(() => dashboardCache.value[activeTab.value].metrics)\n  const currentDashboardStats = computed(() => dashboardCache.value[activeTab.value].stats)\n  const currentDashboardRecentActivity = computed(() => dashboardCache.value[activeTab.value].recentActivity)\n\n  // 活跃渠道数（仅 active 状态）\n  const activeChannelCount = computed(() => {\n    const data = currentChannelsData.value\n    if (!data.channels) return 0\n    return data.channels.filter(ch => ch.status === 'active' || ch.status === undefined || ch.status === '').length\n  })\n\n  // 参与故障转移的渠道数（active + suspended）\n  const failoverChannelCount = computed(() => {\n    const data = currentChannelsData.value\n    if (!data.channels) return 0\n    return data.channels.filter(ch => ch.status !== 'disabled').length\n  })\n\n  // ===== 辅助方法 =====\n\n  // 合并渠道数据，保留本地的延迟测试结果\n  const LATENCY_VALID_DURATION = 5 * 60 * 1000 // 5 分钟有效期\n\n  function mergeChannelsWithLocalData(newChannels: Channel[], existingChannels: Channel[] | undefined): Channel[] {\n    if (!existingChannels) return newChannels\n\n    const now = Date.now()\n    return newChannels.map(newCh => {\n      const existingCh = existingChannels.find(ch => ch.index === newCh.index)\n      // 只有在 5 分钟有效期内才保留本地延迟测试结果\n      if (existingCh?.latencyTestTime && (now - existingCh.latencyTestTime) < LATENCY_VALID_DURATION) {\n        return {\n          ...newCh,\n          latency: existingCh.latency,\n          latencyTestTime: existingCh.latencyTestTime\n        }\n      }\n      return newCh\n    })\n  }\n\n  // ===== 操作方法 =====\n\n  /**\n   * 刷新渠道数据\n   */\n  async function refreshChannels() {\n    refreshRequested = true\n    if (refreshLoopPromise) return refreshLoopPromise\n\n    const doRefresh = async (tab: ApiTab) => {\n      try {\n        // Gemini 使用专用的 dashboard API（降级实现）\n        if (tab === 'gemini') {\n          const dashboard = await api.getGeminiChannelDashboard()\n          geminiChannelsData.value = {\n            channels: mergeChannelsWithLocalData(dashboard.channels, geminiChannelsData.value.channels),\n            current: geminiChannelsData.value.current,\n            loadBalance: dashboard.loadBalance\n          }\n          // 更新 Gemini tab 的独立缓存\n          dashboardCache.value.gemini = {\n            metrics: dashboard.metrics,\n            stats: dashboard.stats,\n            recentActivity: dashboard.recentActivity\n          }\n          lastRefreshSuccess.value = true\n          return\n        }\n\n        // Messages / Responses 使用合并的 dashboard 接口\n        const dashboard = await api.getChannelDashboard(tab)\n\n        if (tab === 'messages') {\n          channelsData.value = {\n            channels: mergeChannelsWithLocalData(dashboard.channels, channelsData.value.channels),\n            current: channelsData.value.current, // 保留当前选中状态\n            loadBalance: dashboard.loadBalance\n          }\n          // 更新 Messages tab 的独立缓存\n          dashboardCache.value.messages = {\n            metrics: dashboard.metrics,\n            stats: dashboard.stats,\n            recentActivity: dashboard.recentActivity\n          }\n        } else {\n          responsesChannelsData.value = {\n            channels: mergeChannelsWithLocalData(dashboard.channels, responsesChannelsData.value.channels),\n            current: responsesChannelsData.value.current, // 保留当前选中状态\n            loadBalance: dashboard.loadBalance\n          }\n          // 更新 Responses tab 的独立缓存\n          dashboardCache.value.responses = {\n            metrics: dashboard.metrics,\n            stats: dashboard.stats,\n            recentActivity: dashboard.recentActivity\n          }\n        }\n\n        lastRefreshSuccess.value = true\n      } catch (error) {\n        lastRefreshSuccess.value = false\n        throw error\n      }\n    }\n\n    refreshLoopPromise = (async () => {\n      try {\n        while (refreshRequested) {\n          refreshRequested = false\n          const tab = activeTab.value\n          await doRefresh(tab)\n        }\n      } finally {\n        refreshLoopPromise = null\n      }\n    })()\n\n    return refreshLoopPromise\n  }\n\n  /**\n   * 保存渠道（添加或更新）\n   */\n  async function saveChannel(\n    channel: Omit<Channel, 'index' | 'latency' | 'status'>,\n    editingChannelIndex: number | null,\n    options?: { isQuickAdd?: boolean }\n  ) {\n    const isResponses = activeTab.value === 'responses'\n    const isGemini = activeTab.value === 'gemini'\n\n    if (editingChannelIndex !== null) {\n      // 更新现有渠道\n      if (isGemini) {\n        await api.updateGeminiChannel(editingChannelIndex, channel)\n      } else if (isResponses) {\n        await api.updateResponsesChannel(editingChannelIndex, channel)\n      } else {\n        await api.updateChannel(editingChannelIndex, channel)\n      }\n      return { success: true, message: '渠道更新成功' }\n    } else {\n      // 添加新渠道\n      if (isGemini) {\n        await api.addGeminiChannel(channel)\n      } else if (isResponses) {\n        await api.addResponsesChannel(channel)\n      } else {\n        await api.addChannel(channel)\n      }\n\n      // 快速添加模式：将新渠道设为第一优先级并设置5分钟促销期\n      if (options?.isQuickAdd) {\n        await refreshChannels() // 先刷新获取新渠道的 index\n        const data = isGemini ? geminiChannelsData.value : (isResponses ? responsesChannelsData.value : channelsData.value)\n\n        // 找到新添加的渠道（应该是列表中 index 最大的 active 状态渠道）\n        const activeChannels = data.channels?.filter(ch => ch.status !== 'disabled') || []\n        if (activeChannels.length > 0) {\n          // 新添加的渠道会分配到最大的 index\n          const newChannel = activeChannels.reduce((max, ch) => ch.index > max.index ? ch : max, activeChannels[0])\n\n          try {\n            // 1. 重新排序：将新渠道放到第一位\n            const otherIndexes = activeChannels\n              .filter(ch => ch.index !== newChannel.index)\n              .sort((a, b) => (a.priority ?? a.index) - (b.priority ?? b.index))\n              .map(ch => ch.index)\n            const newOrder = [newChannel.index, ...otherIndexes]\n\n            if (isGemini) {\n              await api.reorderGeminiChannels(newOrder)\n            } else if (isResponses) {\n              await api.reorderResponsesChannels(newOrder)\n            } else {\n              await api.reorderChannels(newOrder)\n            }\n\n            // 2. 设置5分钟促销期（300秒）\n            if (isGemini) {\n              await api.setGeminiChannelPromotion(newChannel.index, 300)\n            } else if (isResponses) {\n              await api.setResponsesChannelPromotion(newChannel.index, 300)\n            } else {\n              await api.setChannelPromotion(newChannel.index, 300)\n            }\n\n            return {\n              success: true,\n              message: '渠道添加成功',\n              quickAddMessage: `渠道 ${channel.name} 已设为最高优先级，5分钟内优先使用`\n            }\n          } catch (err) {\n            console.warn('设置快速添加优先级失败:', err)\n            // 不影响主流程\n          }\n        }\n      }\n\n      return { success: true, message: '渠道添加成功' }\n    }\n  }\n\n  /**\n   * 删除渠道\n   */\n  async function deleteChannel(channelId: number) {\n    if (activeTab.value === 'gemini') {\n      await api.deleteGeminiChannel(channelId)\n    } else if (activeTab.value === 'responses') {\n      await api.deleteResponsesChannel(channelId)\n    } else {\n      await api.deleteChannel(channelId)\n    }\n    await refreshChannels()\n    return { success: true, message: '渠道删除成功' }\n  }\n\n  /**\n   * 测试单个渠道延迟\n   */\n  async function pingChannel(channelId: number) {\n    const result = activeTab.value === 'gemini'\n      ? await api.pingGeminiChannel(channelId)\n      : await api.pingChannel(channelId)\n\n    const data = activeTab.value === 'gemini'\n      ? geminiChannelsData.value\n      : (activeTab.value === 'messages' ? channelsData.value : responsesChannelsData.value)\n\n    const channel = data.channels?.find(c => c.index === channelId)\n    if (channel) {\n      channel.latency = result.latency\n      channel.latencyTestTime = Date.now()  // 记录测试时间，用于 5 分钟后清除\n    }\n\n    return { success: true }\n  }\n\n  /**\n   * 批量测试所有渠道延迟\n   */\n  async function pingAllChannels() {\n    if (isPingingAll.value) return { success: false, message: '正在测试中' }\n\n    isPingingAll.value = true\n    try {\n      const results = activeTab.value === 'gemini'\n        ? await api.pingAllGeminiChannels()\n        : await api.pingAllChannels()\n\n      const data = activeTab.value === 'gemini'\n        ? geminiChannelsData.value\n        : (activeTab.value === 'messages' ? channelsData.value : responsesChannelsData.value)\n\n      const now = Date.now()\n      results.forEach(result => {\n        const channel = data.channels?.find(c => c.index === result.id)\n        if (channel) {\n          channel.latency = result.latency\n          channel.latencyTestTime = now  // 记录测试时间，用于 5 分钟后清除\n        }\n      })\n\n      return { success: true }\n    } finally {\n      isPingingAll.value = false\n    }\n  }\n\n  /**\n   * 更新负载均衡策略\n   */\n  async function updateLoadBalance(strategy: string) {\n    if (activeTab.value === 'gemini') {\n      await api.updateGeminiLoadBalance(strategy)\n      geminiChannelsData.value.loadBalance = strategy\n    } else if (activeTab.value === 'messages') {\n      await api.updateLoadBalance(strategy)\n      channelsData.value.loadBalance = strategy\n    } else {\n      await api.updateResponsesLoadBalance(strategy)\n      responsesChannelsData.value.loadBalance = strategy\n    }\n    return { success: true, message: `负载均衡策略已更新为: ${strategy}` }\n  }\n\n  /**\n   * 启动自动刷新定时器\n   */\n  function startAutoRefresh() {\n    stopAutoRefresh()\n    autoRefreshRunning = true\n\n    const tick = async () => {\n      if (!autoRefreshRunning) return\n      try {\n        await refreshChannels()\n      } catch (error) {\n        console.warn('自动刷新失败:', error)\n      } finally {\n        if (autoRefreshRunning) {\n          autoRefreshTimer = setTimeout(() => {\n            void tick()\n          }, AUTO_REFRESH_INTERVAL)\n        }\n      }\n    }\n\n    autoRefreshTimer = setTimeout(() => {\n      void tick()\n    }, AUTO_REFRESH_INTERVAL)\n  }\n\n  /**\n   * 停止自动刷新定时器\n   */\n  function stopAutoRefresh() {\n    autoRefreshRunning = false\n    if (!autoRefreshTimer) return\n    clearTimeout(autoRefreshTimer)\n    autoRefreshTimer = null\n  }\n\n  /**\n   * 清空所有渠道数据（用于注销）\n   */\n  function clearChannels() {\n    channelsData.value = {\n      channels: [],\n      current: -1,\n      loadBalance: 'round-robin'\n    }\n    responsesChannelsData.value = {\n      channels: [],\n      current: -1,\n      loadBalance: 'round-robin'\n    }\n    geminiChannelsData.value = {\n      channels: [],\n      current: -1,\n      loadBalance: 'round-robin'\n    }\n\n    // 清空所有 tab 的独立缓存\n    dashboardCache.value = {\n      messages: {\n        metrics: [],\n        stats: undefined,\n        recentActivity: undefined\n      },\n      responses: {\n        metrics: [],\n        stats: undefined,\n        recentActivity: undefined\n      },\n      gemini: {\n        metrics: [],\n        stats: undefined,\n        recentActivity: undefined\n      }\n    }\n\n    // 重置状态标志，避免注销后状态残留\n    lastRefreshSuccess.value = true\n    isPingingAll.value = false\n  }\n\n  // ===== 返回公开接口 =====\n  return {\n    // 状态\n    activeTab,\n    channelsData,\n    responsesChannelsData,\n    geminiChannelsData,\n    isPingingAll,\n    lastRefreshSuccess,\n\n    // 计算属性\n    currentChannelsData,\n    currentDashboardMetrics,\n    currentDashboardStats,\n    currentDashboardRecentActivity,\n    activeChannelCount,\n    failoverChannelCount,\n\n    // 方法\n    refreshChannels,\n    saveChannel,\n    deleteChannel,\n    pingChannel,\n    pingAllChannels,\n    updateLoadBalance,\n    startAutoRefresh,\n    stopAutoRefresh,\n    clearChannels,\n  }\n})\n"
  },
  {
    "path": "frontend/src/stores/dialog.ts",
    "content": "import { defineStore } from 'pinia'\nimport { ref } from 'vue'\nimport type { Channel } from '@/services/api'\n\n/**\n * 对话框状态管理 Store\n *\n * 职责：\n * - 管理添加/编辑渠道对话框状态\n * - 管理添加 API 密钥对话框状态\n * - 管理对话框相关的临时数据（编辑中的渠道、新密钥等）\n */\nexport const useDialogStore = defineStore('dialog', () => {\n  // ===== 状态 =====\n\n  // 添加/编辑渠道对话框\n  const showAddChannelModal = ref(false)\n  const editingChannel = ref<Channel | null>(null)\n\n  // 添加 API 密钥对话框\n  const showAddKeyModal = ref(false)\n  const selectedChannelForKey = ref<number>(-1)\n  const newApiKey = ref('')\n\n  // ===== 操作方法 =====\n\n  /**\n   * 打开添加渠道对话框\n   */\n  function openAddChannelModal() {\n    editingChannel.value = null\n    showAddChannelModal.value = true\n  }\n\n  /**\n   * 打开编辑渠道对话框\n   */\n  function openEditChannelModal(channel: Channel) {\n    editingChannel.value = channel\n    showAddChannelModal.value = true\n  }\n\n  /**\n   * 关闭渠道对话框\n   */\n  function closeAddChannelModal() {\n    showAddChannelModal.value = false\n    editingChannel.value = null\n  }\n\n  /**\n   * 打开添加密钥对话框\n   */\n  function openAddKeyModal(channelId: number) {\n    selectedChannelForKey.value = channelId\n    newApiKey.value = ''\n    showAddKeyModal.value = true\n  }\n\n  /**\n   * 关闭密钥对话框\n   */\n  function closeAddKeyModal() {\n    showAddKeyModal.value = false\n    selectedChannelForKey.value = -1\n    newApiKey.value = ''\n  }\n\n  /**\n   * 重置所有对话框状态\n   */\n  function resetDialogState() {\n    showAddChannelModal.value = false\n    editingChannel.value = null\n    showAddKeyModal.value = false\n    selectedChannelForKey.value = -1\n    newApiKey.value = ''\n  }\n\n  return {\n    // 状态\n    showAddChannelModal,\n    editingChannel,\n    showAddKeyModal,\n    selectedChannelForKey,\n    newApiKey,\n\n    // 方法\n    openAddChannelModal,\n    openEditChannelModal,\n    closeAddChannelModal,\n    openAddKeyModal,\n    closeAddKeyModal,\n    resetDialogState,\n  }\n})\n"
  },
  {
    "path": "frontend/src/stores/index.ts",
    "content": "/**\n * Pinia Stores 统一导出\n */\nexport { useAuthStore } from './auth'\nexport { useChannelStore } from './channel'\nexport { usePreferencesStore } from './preferences'\nexport { useDialogStore } from './dialog'\nexport { useSystemStore } from './system'\n"
  },
  {
    "path": "frontend/src/stores/preferences.ts",
    "content": "import { defineStore } from 'pinia'\nimport { ref } from 'vue'\n\n/**\n * 用户偏好设置 Store\n *\n * 职责：\n * - 管理暗色模式偏好（light/dark/auto）\n * - 管理 Fuzzy 模式开关\n * - 管理全局统计面板展开状态\n * - 自动持久化到 localStorage\n */\nexport const usePreferencesStore = defineStore('preferences', () => {\n  // ===== 状态 =====\n\n  // 暗色模式偏好\n  const darkModePreference = ref<'light' | 'dark' | 'auto'>('auto')\n\n  // Fuzzy 模式开关\n  const fuzzyModeEnabled = ref(true)\n\n  // 全局统计面板展开状态\n  const showGlobalStats = ref(false)\n\n  // ===== 操作方法 =====\n\n  /**\n   * 设置暗色模式\n   */\n  function setDarkMode(mode: 'light' | 'dark' | 'auto') {\n    darkModePreference.value = mode\n  }\n\n  /**\n   * 切换暗色模式（循环切换）\n   */\n  function toggleDarkMode() {\n    const modes: Array<'light' | 'dark' | 'auto'> = ['light', 'dark', 'auto']\n    const currentIndex = modes.indexOf(darkModePreference.value)\n    const nextIndex = (currentIndex + 1) % modes.length\n    darkModePreference.value = modes[nextIndex]\n  }\n\n  /**\n   * 设置 Fuzzy 模式\n   */\n  function setFuzzyMode(enabled: boolean) {\n    fuzzyModeEnabled.value = enabled\n  }\n\n  /**\n   * 切换 Fuzzy 模式\n   */\n  function toggleFuzzyMode() {\n    fuzzyModeEnabled.value = !fuzzyModeEnabled.value\n  }\n\n  /**\n   * 切换全局统计面板\n   */\n  function toggleGlobalStats() {\n    showGlobalStats.value = !showGlobalStats.value\n  }\n\n  return {\n    // 状态\n    darkModePreference,\n    fuzzyModeEnabled,\n    showGlobalStats,\n\n    // 方法\n    setDarkMode,\n    toggleDarkMode,\n    setFuzzyMode,\n    toggleFuzzyMode,\n    toggleGlobalStats,\n  }\n}, {\n  // 持久化配置\n  persist: {\n    key: 'claude-proxy-preferences',\n    // 使用条件判断避免在非浏览器环境（SSR、Node 测试）中崩溃\n    storage: typeof window !== 'undefined' ? localStorage : undefined,\n  },\n})\n"
  },
  {
    "path": "frontend/src/stores/system.ts",
    "content": "import { defineStore } from 'pinia'\nimport { ref, computed } from 'vue'\nimport type { VersionInfo } from '@/services/version'\n\n/**\n * 系统状态管理 Store\n *\n * 职责：\n * - 管理系统运行状态（running/error/connecting）\n * - 管理版本信息和版本检查状态\n * - 管理 Fuzzy 模式加载状态\n */\nexport const useSystemStore = defineStore('system', () => {\n  // ===== 状态 =====\n\n  // 系统连接状态\n  type SystemStatus = 'running' | 'error' | 'connecting'\n  const systemStatus = ref<SystemStatus>('connecting')\n\n  // 版本信息\n  const versionInfo = ref<VersionInfo>({\n    currentVersion: '',\n    latestVersion: null,\n    isLatest: false,\n    hasUpdate: false,\n    releaseUrl: null,\n    lastCheckTime: 0,\n    status: 'checking',\n  })\n\n  // 版本检查加载状态\n  const isCheckingVersion = ref(false)\n\n  // Fuzzy 模式加载状态\n  const fuzzyModeLoading = ref(false)\n  const fuzzyModeLoadError = ref(false)\n\n  // ===== 计算属性 =====\n\n  const systemStatusText = computed(() => {\n    switch (systemStatus.value) {\n      case 'running':\n        return '运行中'\n      case 'error':\n        return '连接失败'\n      case 'connecting':\n        return '连接中'\n      default:\n        return '未知'\n    }\n  })\n\n  const systemStatusDesc = computed(() => {\n    switch (systemStatus.value) {\n      case 'running':\n        return '服务正常运行'\n      case 'error':\n        return '无法连接后端'\n      case 'connecting':\n        return '正在连接后端'\n      default:\n        return ''\n    }\n  })\n\n  // ===== 操作方法 =====\n\n  /**\n   * 设置系统状态\n   */\n  function setSystemStatus(status: SystemStatus) {\n    systemStatus.value = status\n  }\n\n  /**\n   * 设置版本信息\n   */\n  function setVersionInfo(info: VersionInfo) {\n    versionInfo.value = info\n  }\n\n  /**\n   * 更新当前版本号\n   */\n  function setCurrentVersion(version: string) {\n    versionInfo.value.currentVersion = version\n  }\n\n  /**\n   * 设置版本检查状态\n   */\n  function setCheckingVersion(checking: boolean) {\n    isCheckingVersion.value = checking\n  }\n\n  /**\n   * 设置 Fuzzy 模式加载状态\n   */\n  function setFuzzyModeLoading(loading: boolean) {\n    fuzzyModeLoading.value = loading\n  }\n\n  /**\n   * 设置 Fuzzy 模式加载错误状态\n   */\n  function setFuzzyModeLoadError(error: boolean) {\n    fuzzyModeLoadError.value = error\n  }\n\n  /**\n   * 重置系统状态\n   */\n  function resetSystemState() {\n    systemStatus.value = 'connecting'\n    versionInfo.value = {\n      currentVersion: '',\n      latestVersion: null,\n      isLatest: false,\n      hasUpdate: false,\n      releaseUrl: null,\n      lastCheckTime: 0,\n      status: 'checking',\n    }\n    isCheckingVersion.value = false\n    fuzzyModeLoading.value = false\n    fuzzyModeLoadError.value = false\n  }\n\n  return {\n    // 状态\n    systemStatus,\n    versionInfo,\n    isCheckingVersion,\n    fuzzyModeLoading,\n    fuzzyModeLoadError,\n\n    // 计算属性\n    systemStatusText,\n    systemStatusDesc,\n\n    // 方法\n    setSystemStatus,\n    setVersionInfo,\n    setCurrentVersion,\n    setCheckingVersion,\n    setFuzzyModeLoading,\n    setFuzzyModeLoadError,\n    resetSystemState,\n  }\n})\n"
  },
  {
    "path": "frontend/src/styles/settings.scss",
    "content": "// Vuetify 样式变量配置 - 复古像素主题\n\n// 复古像素主题：无圆角\n$border-radius-root: 0px !default;\n$btn-border-radius: 0px !default;\n$card-border-radius: 0px !default;\n$sheet-border-radius: 0px !default;\n\n// 自定义颜色 - 复古高对比度\n$primary: #6366F1 !default;\n$secondary: #8B5CF6 !default;\n$accent: #EC4899 !default;\n$success: #10B981 !default;\n$info: #3B82F6 !default;\n$warning: #F59E0B !default;\n$error: #EF4444 !default;\n\n// 字体配置 - 等宽字体\n$body-font-family: 'Courier New', Consolas, 'Liberation Mono', monospace !default;\n$headings-font-family: 'Courier New', Consolas, 'Liberation Mono', monospace !default;\n\n@use 'vuetify/settings';\n"
  },
  {
    "path": "frontend/src/utils/quickInputParser.test.ts",
    "content": "/**\n * 快速添加渠道 - 输入解析测试\n *\n * 测试 isValidApiKey 和 isValidUrl 工具函数\n */\n\nimport { describe, it, expect } from 'vitest'\nimport { isValidApiKey, isValidUrl, parseQuickInput } from './quickInputParser'\n\ndescribe('API Key 识别', () => {\n  describe('OpenAI 格式', () => {\n    it('应识别 OpenAI Legacy 格式 (sk-xxx)', () => {\n      expect(isValidApiKey('sk-7nKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1234')).toBe(true)\n      expect(isValidApiKey('sk-abcdef1234567890abcdef1234567890abcdef123456')).toBe(true)\n    })\n\n    it('应识别 OpenAI Project 格式 (sk-proj-xxx)', () => {\n      expect(isValidApiKey('sk-proj-Aw9' + 'x'.repeat(100))).toBe(true)\n      expect(isValidApiKey('sk-proj-' + 'abcdef1234567890'.repeat(8))).toBe(true)\n    })\n  })\n\n  describe('Anthropic Claude 格式', () => {\n    it('应识别 Anthropic 格式 (sk-ant-api03-xxx)', () => {\n      expect(isValidApiKey('sk-ant-api03-bK9' + 'x'.repeat(80))).toBe(true)\n      expect(isValidApiKey('sk-ant-api03-' + 'abcdef1234567890'.repeat(6))).toBe(true)\n    })\n  })\n\n  describe('Google Gemini 格式', () => {\n    it('应识别 AIza 开头的 key', () => {\n      // Google API Key 总长度 39 字符: AIza (4) + 35 字符\n      expect(isValidApiKey('AIzaSyDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx')).toBe(true)\n      expect(isValidApiKey('AIzaXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX')).toBe(true)\n    })\n\n    it('不应识别非 AIza 开头的类似格式', () => {\n      expect(isValidApiKey('AIzbSyDxxx')).toBe(false)\n      expect(isValidApiKey('Aiza1234567')).toBe(false)\n    })\n  })\n\n  describe('OpenRouter 格式', () => {\n    it('应识别 OpenRouter 格式 (sk-or-v1-xxx)', () => {\n      // OpenRouter 使用混合大小写字母数字\n      expect(isValidApiKey('sk-or-v1-0ndQl1opjKLMNOPqrs' + 'x'.repeat(40))).toBe(true)\n      expect(isValidApiKey('sk-or-v1-' + 'AbCdEf123456'.repeat(5))).toBe(true)\n    })\n  })\n\n  describe('Hugging Face 格式', () => {\n    it('应识别 hf_ 前缀', () => {\n      expect(isValidApiKey('hf_AVd' + 'x'.repeat(31))).toBe(true)\n      expect(isValidApiKey('hf_' + 'abcdef1234567890'.repeat(2) + 'ab')).toBe(true)\n    })\n  })\n\n  describe('Groq 格式', () => {\n    it('应识别 gsk_ 前缀', () => {\n      expect(isValidApiKey('gsk_8sX' + 'x'.repeat(49))).toBe(true)\n      expect(isValidApiKey('gsk_' + 'abcdef1234567890'.repeat(3) + 'abcd')).toBe(true)\n    })\n  })\n\n  describe('Perplexity 格式', () => {\n    it('应识别 pplx- 前缀', () => {\n      expect(isValidApiKey('pplx-f9a' + 'x'.repeat(40))).toBe(true)\n      expect(isValidApiKey('pplx-' + 'abcdef1234567890'.repeat(3))).toBe(true)\n    })\n  })\n\n  describe('Replicate 格式', () => {\n    it('应识别 r8_ 前缀', () => {\n      expect(isValidApiKey('r8_G7b' + 'x'.repeat(20))).toBe(true)\n      expect(isValidApiKey('r8_abcdef1234567890abcdef')).toBe(true)\n    })\n  })\n\n  describe('智谱 AI 格式 (id.secret)', () => {\n    it('应识别智谱 AI 的 id.secret 格式', () => {\n      expect(isValidApiKey('269abc123456789012345678.r8abcdef1234')).toBe(true)\n      expect(isValidApiKey('abcdefghij1234567890abcd.secretkey123456')).toBe(true)\n    })\n  })\n\n  describe('火山引擎格式', () => {\n    it('应识别火山引擎 Ark UUID 格式', () => {\n      expect(isValidApiKey('550e8400-e29b-41d4-a716-446655440000')).toBe(true)\n      expect(isValidApiKey('123e4567-e89b-12d3-a456-426614174000')).toBe(true)\n    })\n\n    it('应识别火山引擎 IAM AK 格式', () => {\n      expect(isValidApiKey('AKLTNmYyYz' + 'x'.repeat(20))).toBe(true)\n      expect(isValidApiKey('AKLTabcdefghij1234567890abcdefgh')).toBe(true)\n    })\n  })\n\n  describe('通用前缀格式 (xx-xxx / xx_xxx)', () => {\n    it('应识别包含数字或混合大小写的后缀', () => {\n      expect(isValidApiKey('sk-proj-abc123xyz')).toBe(true)\n      expect(isValidApiKey('sk-1234567890abcdef')).toBe(true)\n      expect(isValidApiKey('sk-abcDEFghiJKL')).toBe(true)\n      expect(isValidApiKey('ut_abc123456789')).toBe(true)\n      expect(isValidApiKey('api-key12345678901')).toBe(true)\n      expect(isValidApiKey('cr_xxxxxxxxx123')).toBe(true)\n    })\n\n    it('不应识别单字母前缀', () => {\n      expect(isValidApiKey('s-1234567890123')).toBe(false)\n      expect(isValidApiKey('u_1234567890123')).toBe(false)\n    })\n\n    it('不应识别无分隔符的字符串', () => {\n      expect(isValidApiKey('sk123')).toBe(false)\n      expect(isValidApiKey('apikey')).toBe(false)\n    })\n\n    it('不应识别分隔符后无内容的字符串', () => {\n      expect(isValidApiKey('sk-')).toBe(false)\n      expect(isValidApiKey('ut_')).toBe(false)\n    })\n  })\n\n  describe('宽松兜底格式（常见前缀 + 任意后缀）', () => {\n    it('应识别 sk- 前缀的短密钥', () => {\n      expect(isValidApiKey('sk-111')).toBe(true)\n      expect(isValidApiKey('sk-x')).toBe(true)\n      expect(isValidApiKey('sk-abc')).toBe(true)\n      expect(isValidApiKey('sk-test')).toBe(true)\n    })\n\n    it('应识别其他常见前缀的短密钥', () => {\n      expect(isValidApiKey('api-123')).toBe(true)\n      expect(isValidApiKey('key-abc')).toBe(true)\n      expect(isValidApiKey('ut_test')).toBe(true)\n      expect(isValidApiKey('hf_short')).toBe(true)\n      expect(isValidApiKey('gsk_x')).toBe(true)\n      expect(isValidApiKey('cr_1')).toBe(true)\n      expect(isValidApiKey('ms-test')).toBe(true)\n      expect(isValidApiKey('r8_abc')).toBe(true)\n      expect(isValidApiKey('pplx-x')).toBe(true)\n    })\n\n    it('不应识别未知前缀的短字符串', () => {\n      expect(isValidApiKey('xx-111')).toBe(false)\n      expect(isValidApiKey('foo-bar')).toBe(false)\n      expect(isValidApiKey('test_key')).toBe(false)\n    })\n  })\n\n  describe('配置键名格式（应被排除）', () => {\n    it('不应识别全大写下划线分隔的配置键名', () => {\n      expect(isValidApiKey('API_TIMEOUT_MS')).toBe(false)\n      expect(isValidApiKey('ANTHROPIC_BASE_URL')).toBe(false)\n      expect(isValidApiKey('ANTHROPIC_AUTH_TOKEN')).toBe(false)\n      expect(isValidApiKey('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC')).toBe(false)\n      expect(isValidApiKey('DATABASE_URL')).toBe(false)\n      expect(isValidApiKey('SECRET_KEY')).toBe(false)\n    })\n\n    it('不应识别带数字的配置键名', () => {\n      expect(isValidApiKey('API_V2_KEY')).toBe(false)\n      expect(isValidApiKey('REDIS_DB_0')).toBe(false)\n    })\n  })\n\n  describe('JWT 格式', () => {\n    it('应识别有效的 JWT', () => {\n      const validJwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U'\n      expect(isValidApiKey(validJwt)).toBe(true)\n    })\n\n    it('应识别简短但有效的 JWT 格式', () => {\n      // 至少 20 字符，有两个点\n      expect(isValidApiKey('eyJhbGciOiJIUzI1Ni.eyJzdWIiOiIxMjM0.xxx')).toBe(true)\n    })\n\n    it('不应识别只有一个点的 JWT', () => {\n      expect(isValidApiKey('eyJhbGciOiJIUzI1NiIs.xxx')).toBe(false)\n    })\n\n    it('不应识别过短的 JWT', () => {\n      expect(isValidApiKey('eyJ.xxx.yyy')).toBe(false)\n    })\n  })\n\n  describe('长字符串格式 (≥32 字符，需同时包含字母和数字)', () => {\n    it('应识别 32+ 字符的字母数字混合字符串', () => {\n      expect(isValidApiKey('abcdefghijklmnopqrstuvwxyz123456')).toBe(true)\n      expect(isValidApiKey('ABCDEFGHIJKLMNOPQRSTUVWXYZ123456')).toBe(true)\n      expect(isValidApiKey('72f988bf7ab9e0f0a1234567890abcde')).toBe(true) // Azure OpenAI 风格\n    })\n\n    it('应识别包含下划线和横线的长字符串', () => {\n      expect(isValidApiKey('abcdefghijklmnop_qrstuvwxyz-12345')).toBe(true)\n    })\n\n    it('不应识别少于 32 字符的无前缀字符串', () => {\n      expect(isValidApiKey('a'.repeat(31))).toBe(false)\n      expect(isValidApiKey('shortkey')).toBe(false)\n    })\n\n    it('不应识别纯字母的长字符串', () => {\n      expect(isValidApiKey('a'.repeat(32))).toBe(false)\n      expect(isValidApiKey('abcdefghijklmnopqrstuvwxyzabcdef')).toBe(false)\n    })\n\n    it('不应识别包含特殊字符的字符串', () => {\n      expect(isValidApiKey('a'.repeat(30) + '!@')).toBe(false)\n      expect(isValidApiKey('abcdefghijklmnopqrstuvwxyz12345!')).toBe(false)\n    })\n  })\n\n  describe('无效输入', () => {\n    it('不应识别普通单词', () => {\n      expect(isValidApiKey('hello')).toBe(false)\n      expect(isValidApiKey('world')).toBe(false)\n      expect(isValidApiKey('test')).toBe(false)\n    })\n\n    it('不应识别 URL', () => {\n      expect(isValidApiKey('http://localhost')).toBe(false)\n      expect(isValidApiKey('https://api.example.com')).toBe(false)\n    })\n\n    it('不应识别空字符串', () => {\n      expect(isValidApiKey('')).toBe(false)\n    })\n\n    it('不应识别纯数字', () => {\n      expect(isValidApiKey('12345678901234567890123456789012')).toBe(false)\n    })\n  })\n})\n\ndescribe('URL 识别', () => {\n  describe('有效 URL', () => {\n    it('应识别 localhost', () => {\n      expect(isValidUrl('http://localhost')).toBe(true)\n      expect(isValidUrl('http://localhost/')).toBe(true)\n      expect(isValidUrl('http://localhost:3000')).toBe(true)\n      expect(isValidUrl('http://localhost:3000/')).toBe(true)\n      expect(isValidUrl('http://localhost:5688/v1')).toBe(true)\n    })\n\n    it('应识别域名', () => {\n      expect(isValidUrl('https://api.openai.com')).toBe(true)\n      expect(isValidUrl('https://api.openai.com/')).toBe(true)\n      expect(isValidUrl('https://api.openai.com/v1')).toBe(true)\n      expect(isValidUrl('https://api.anthropic.com/v1')).toBe(true)\n    })\n\n    it('应识别带端口的域名', () => {\n      expect(isValidUrl('http://example.com:8080')).toBe(true)\n      expect(isValidUrl('https://api.example.com:443/v1')).toBe(true)\n    })\n\n    it('应识别 IP 地址', () => {\n      expect(isValidUrl('http://127.0.0.1')).toBe(true)\n      expect(isValidUrl('http://192.168.1.1:8080')).toBe(true)\n    })\n\n    it('应识别子域名', () => {\n      expect(isValidUrl('https://api.v2.example.com')).toBe(true)\n      expect(isValidUrl('https://a.b.c.d.example.com/path')).toBe(true)\n    })\n  })\n\n  describe('无效 URL', () => {\n    it('不应识别不完整的 URL', () => {\n      expect(isValidUrl('http://')).toBe(false)\n      expect(isValidUrl('https://')).toBe(false)\n      expect(isValidUrl('http:///')).toBe(false)\n    })\n\n    it('不应识别无协议的 URL', () => {\n      expect(isValidUrl('localhost')).toBe(false)\n      expect(isValidUrl('api.openai.com')).toBe(false)\n      expect(isValidUrl('//api.openai.com')).toBe(false)\n    })\n\n    it('不应识别无效协议', () => {\n      expect(isValidUrl('ftp://example.com')).toBe(false)\n      expect(isValidUrl('ws://example.com')).toBe(false)\n    })\n\n    it('不应识别无效域名格式', () => {\n      expect(isValidUrl('http://-example.com')).toBe(false)\n      expect(isValidUrl('http://example-.com')).toBe(false)\n    })\n  })\n})\n\ndescribe('综合解析场景', () => {\n  it('应正确解析 URL + 多个 API Key', () => {\n    const input = `\n      https://api.openai.com/v1\n      sk-key1abc123456\n      sk-key2def789012\n      sk-key3ghi345678\n    `\n    const result = parseQuickInput(input)\n    expect(result.detectedBaseUrl).toBe('https://api.openai.com/v1')\n    expect(result.detectedApiKeys).toEqual(['sk-key1abc123456', 'sk-key2def789012', 'sk-key3ghi345678'])\n  })\n\n  it('应正确解析 localhost URL', () => {\n    const input = 'http://localhost:5688 sk-1234567890ab sk-abcdef123456'\n    const result = parseQuickInput(input)\n    expect(result.detectedBaseUrl).toBe('http://localhost:5688')\n    expect(result.detectedApiKeys).toEqual(['sk-1234567890ab', 'sk-abcdef123456'])\n  })\n\n  it('应正确解析混合分隔符', () => {\n    const input = 'https://api.example.com, sk-key1234567890; ut_key2abc123456, api-key3def789012'\n    const result = parseQuickInput(input)\n    expect(result.detectedBaseUrl).toBe('https://api.example.com')\n    expect(result.detectedApiKeys).toEqual(['sk-key1234567890', 'ut_key2abc123456', 'api-key3def789012'])\n  })\n\n  it('应忽略不完整的 URL', () => {\n    const input = 'http:// sk-key1234567890'\n    const result = parseQuickInput(input)\n    expect(result.detectedBaseUrl).toBe('')\n    expect(result.detectedApiKeys).toEqual(['sk-key1234567890'])\n  })\n\n  it('应只取第一个 URL', () => {\n    const input = 'https://first.com https://second.com sk-key1234567890'\n    const result = parseQuickInput(input)\n    expect(result.detectedBaseUrl).toBe('https://first.com')\n    expect(result.detectedApiKeys).toEqual(['sk-key1234567890'])\n  })\n\n  it('应去重 API Key', () => {\n    const input = 'sk-key1234567890 sk-key1234567890 sk-key2abcdef123'\n    const result = parseQuickInput(input)\n    expect(result.detectedApiKeys).toEqual(['sk-key1234567890', 'sk-key2abcdef123'])\n  })\n\n  it('应保留 # 结尾（跳过版本号）', () => {\n    const input = 'https://api.example.com/anthropic# sk-key1234567890'\n    const result = parseQuickInput(input)\n    expect(result.detectedBaseUrl).toBe('https://api.example.com/anthropic#')\n  })\n\n  it('应保留无路径的 # 结尾', () => {\n    const input = 'https://api.example.com# sk-key1234567890'\n    const result = parseQuickInput(input)\n    expect(result.detectedBaseUrl).toBe('https://api.example.com#')\n  })\n\n  it('应移除末尾斜杠', () => {\n    const input = 'https://api.example.com/ sk-key1234567890'\n    const result = parseQuickInput(input)\n    expect(result.detectedBaseUrl).toBe('https://api.example.com')\n  })\n\n  it('应正确处理 JWT 格式的 key', () => {\n    const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U'\n    const input = `https://api.example.com ${jwt}`\n    const result = parseQuickInput(input)\n    expect(result.detectedBaseUrl).toBe('https://api.example.com')\n    expect(result.detectedApiKeys).toEqual([jwt])\n  })\n})\n\ndescribe('引号内容提取', () => {\n  it('应从英文双引号中提取 URL 和 API Key', () => {\n    const input = `\"ANTHROPIC_AUTH_TOKEN\": \"sk-lACTyHP69FC46DeD8F67T3BLBkFJ4cE3879908bc4c38a336\",\n\"ANTHROPIC_BASE_URL\": \"https://apic1.ohmycdn.com/api/v1/ai/openai/cc-omg\"`\n    const result = parseQuickInput(input)\n    expect(result.detectedBaseUrl).toBe('https://apic1.ohmycdn.com/api/v1/ai/openai/cc-omg')\n    expect(result.detectedApiKeys).toContain('sk-lACTyHP69FC46DeD8F67T3BLBkFJ4cE3879908bc4c38a336')\n    // 不应识别配置键名\n    expect(result.detectedApiKeys).not.toContain('ANTHROPIC_AUTH_TOKEN')\n    expect(result.detectedApiKeys).not.toContain('ANTHROPIC_BASE_URL')\n  })\n\n  it('应从英文单引号中提取内容', () => {\n    const input = `'sk-test123456789012' 'https://api.example.com/v1'`\n    const result = parseQuickInput(input)\n    expect(result.detectedBaseUrl).toBe('https://api.example.com/v1')\n    expect(result.detectedApiKeys).toEqual(['sk-test123456789012'])\n  })\n\n  it('应从中文双引号中提取内容', () => {\n    const input = `\"sk-chinese123456789\"\"https://api.example.com\"`\n    const result = parseQuickInput(input)\n    expect(result.detectedBaseUrl).toBe('https://api.example.com')\n    expect(result.detectedApiKeys).toEqual(['sk-chinese123456789'])\n  })\n\n  it('应从中文单引号中提取内容', () => {\n    const input = `'sk-chinese789012345''https://api.test.com'`\n    const result = parseQuickInput(input)\n    expect(result.detectedBaseUrl).toBe('https://api.test.com')\n    expect(result.detectedApiKeys).toEqual(['sk-chinese789012345'])\n  })\n\n  it('应正确解析完整的 Claude Code 配置格式', () => {\n    const input = `发一个20$的key，用起来还不错，你们试试，好像是官逆\nsnow里获取不到模型不知道为啥\n{\n  \"env\": {\n    \"ANTHROPIC_AUTH_TOKEN\": \"sk-lACTyHP69FC46DeD8F67T3BLBkFJ4cE3879908bc4c38a336\",\n    \"ANTHROPIC_BASE_URL\": \"https://apic1.ohmycdn.com/api/v1/ai/openai/cc-omg\",\n    \"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC\": 1\n  }\n}`\n    const result = parseQuickInput(input)\n    expect(result.detectedBaseUrl).toBe('https://apic1.ohmycdn.com/api/v1/ai/openai/cc-omg')\n    expect(result.detectedApiKeys).toContain('sk-lACTyHP69FC46DeD8F67T3BLBkFJ4cE3879908bc4c38a336')\n    // 不应识别配置键名\n    expect(result.detectedApiKeys).not.toContain('ANTHROPIC_AUTH_TOKEN')\n    expect(result.detectedApiKeys).not.toContain('ANTHROPIC_BASE_URL')\n    expect(result.detectedApiKeys).not.toContain('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC')\n  })\n\n  it('应正确解析 Claude Code settings.json 格式', () => {\n    const input = `{\n  \"$schema\": \"https://json.schemastore.org/claude-code-settings.json\",\n  \"env\": {\n    \"API_TIMEOUT_MS\": \"200000\",\n    \"ANTHROPIC_BASE_URL\": \"http://localhost:3688/\",\n    \"ANTHROPIC_AUTH_TOKEN\": \"key\",\n    \"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC\": \"1\"\n  },\n  \"includeCoAuthoredBy\": false\n}`\n    const result = parseQuickInput(input)\n    expect(result.detectedBaseUrl).toBe('https://json.schemastore.org/claude-code-settings.json')\n    // 不应识别任何配置键名\n    expect(result.detectedApiKeys).not.toContain('API_TIMEOUT_MS')\n    expect(result.detectedApiKeys).not.toContain('ANTHROPIC_BASE_URL')\n    expect(result.detectedApiKeys).not.toContain('ANTHROPIC_AUTH_TOKEN')\n    expect(result.detectedApiKeys).not.toContain('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC')\n  })\n\n  it('应忽略引号内的非 URL/Key 内容', () => {\n    const input = `\"env\": { \"ANTHROPIC_AUTH_TOKEN\": \"sk-valid1234567890\" }`\n    const result = parseQuickInput(input)\n    expect(result.detectedBaseUrl).toBe('')\n    expect(result.detectedApiKeys).toContain('sk-valid1234567890')\n    expect(result.detectedApiKeys).not.toContain('ANTHROPIC_AUTH_TOKEN')\n  })\n\n  it('应同时支持引号内容和普通分隔', () => {\n    const input = `\"sk-quoted123456789\" sk-plain4567890123 https://api.example.com`\n    const result = parseQuickInput(input)\n    expect(result.detectedBaseUrl).toBe('https://api.example.com')\n    expect(result.detectedApiKeys).toContain('sk-quoted123456789')\n    expect(result.detectedApiKeys).toContain('sk-plain4567890123')\n  })\n\n  it('应支持单边引号（只有开头引号）', () => {\n    const input = `\"http://localhost:5689`\n    const result = parseQuickInput(input)\n    expect(result.detectedBaseUrl).toBe('http://localhost:5689')\n  })\n\n  it('应支持单边引号提取 API Key', () => {\n    const input = `\"sk-test1234567890`\n    const result = parseQuickInput(input)\n    expect(result.detectedApiKeys).toContain('sk-test1234567890')\n  })\n\n  it('应支持单边单引号', () => {\n    const input = `'https://api.example.com/v1`\n    const result = parseQuickInput(input)\n    expect(result.detectedBaseUrl).toBe('https://api.example.com/v1')\n  })\n\n  it('应支持混合完整引号和单边引号', () => {\n    const input = `\"https://api.example.com\" \"sk-key12345678901`\n    const result = parseQuickInput(input)\n    expect(result.detectedBaseUrl).toBe('https://api.example.com')\n    expect(result.detectedApiKeys).toContain('sk-key12345678901')\n  })\n})\n"
  },
  {
    "path": "frontend/src/utils/quickInputParser.ts",
    "content": "/**\n * 快速添加渠道 - 输入解析工具\n *\n * 用于识别 API Key 和 URL 格式\n */\n\n/**\n * 检测字符串是否看起来像配置键名（全大写 + 下划线分隔的单词）\n * 例如：API_TIMEOUT_MS, ANTHROPIC_BASE_URL, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC\n */\nconst looksLikeConfigKey = (token: string): boolean => {\n  // 全大写字母 + 下划线，且由多个单词组成（至少包含一个下划线分隔的段）\n  // 且每个段都是 2+ 字符的大写字母单词\n  if (/^[A-Z][A-Z0-9]*(_[A-Z][A-Z0-9]*)+$/.test(token)) {\n    return true\n  }\n  return false\n}\n\n/**\n * 各平台 API Key 格式的专用正则匹配\n *\n * 国际主流:\n * - OpenAI Legacy: sk-[a-zA-Z0-9]{48}\n * - OpenAI Project: sk-proj-[a-zA-Z0-9-]{100,}\n * - Anthropic: sk-ant-api03-[a-zA-Z0-9-]{80,}\n * - Google Gemini: AIza[0-9A-Za-z-_]{35}\n * - Azure OpenAI: 32位十六进制\n *\n * 新兴生态:\n * - Hugging Face: hf_[a-zA-Z0-9]{34}\n * - Groq: gsk_[a-zA-Z0-9]{52}\n * - Perplexity: pplx-[a-zA-Z0-9]{40,}\n * - Replicate: r8_[a-zA-Z0-9]+\n * - OpenRouter: sk-or-v1-[a-zA-Z0-9]{50,}\n *\n * 国内平台:\n * - DeepSeek/Moonshot/01.AI/SiliconFlow: sk-[a-zA-Z0-9]{48} (兼容 OpenAI)\n * - 智谱 AI: [a-z0-9]{32}\\.[a-z0-9]+ (id.secret 格式)\n * - 火山引擎 Ark: UUID 格式\n * - 火山引擎 IAM: AK 开头\n */\nconst PLATFORM_KEY_PATTERNS: RegExp[] = [\n  // OpenAI Project Key (新格式，最长，优先匹配)\n  /^sk-proj-[a-zA-Z0-9_-]{50,}$/,\n  // Anthropic Claude\n  /^sk-ant-api03-[a-zA-Z0-9_-]{50,}$/,\n  // OpenRouter (混合大小写字母数字)\n  /^sk-or-v1-[a-zA-Z0-9]{50,}$/,\n  // OpenAI Legacy / DeepSeek / Moonshot / 01.AI / SiliconFlow\n  /^sk-[a-zA-Z0-9]{20,}$/,\n  // Google Gemini/PaLM (通常 39 字符，允许一定范围)\n  /^AIza[0-9A-Za-z_-]{30,}$/,\n  // Hugging Face\n  /^hf_[a-zA-Z0-9]{30,}$/,\n  // Groq\n  /^gsk_[a-zA-Z0-9]{40,}$/,\n  // Perplexity\n  /^pplx-[a-zA-Z0-9]{40,}$/,\n  // Replicate\n  /^r8_[a-zA-Z0-9]{20,}$/,\n  // 智谱 AI (id.secret 格式)\n  /^[a-zA-Z0-9]{20,}\\.[a-zA-Z0-9]{10,}$/,\n  // 火山引擎 Ark (UUID 格式)\n  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,\n  // 火山引擎 IAM AK\n  /^AK[A-Z]{2,4}[a-zA-Z0-9]{20,}$/\n]\n\n/**\n * 检测字符串是否为有效的 API Key\n *\n * 支持的格式：\n * 1. 平台特定格式（优先匹配，准确度最高）\n * 2. 通用前缀格式：xx-xxx 或 xx_xxx（如 sk-xxx, ut_xxx, api-xxx）\n * 3. JWT 格式：eyJ 开头，包含两个点分隔的 base64 段\n * 4. 长随机字符串：≥32 字符的字母数字串（必须包含字母和数字）\n * 5. 宽松兜底：常见前缀 + 任意后缀（当以上都不匹配时）\n *\n * 排除的格式：\n * - 配置键名：全大写 + 下划线分隔（如 API_TIMEOUT_MS）\n */\nexport const isValidApiKey = (token: string): boolean => {\n  // 首先排除配置键名格式\n  if (looksLikeConfigKey(token)) {\n    return false\n  }\n\n  // 1. 平台特定格式匹配（最准确）\n  for (const pattern of PLATFORM_KEY_PATTERNS) {\n    if (pattern.test(token)) {\n      return true\n    }\n  }\n\n  // 2. 通用前缀格式（前缀 2-6 字母 + 连字符/下划线 + 至少 10 字符后缀）\n  // 后缀必须包含数字或混合大小写（随机特征）\n  if (/^[a-zA-Z]{2,6}[-_][a-zA-Z0-9_-]{10,}$/.test(token)) {\n    const suffix = token.replace(/^[a-zA-Z]{2,6}[-_]/, '')\n    const hasDigit = /\\d/.test(suffix)\n    const hasMixedCase = /[a-z]/.test(suffix) && /[A-Z]/.test(suffix)\n    if (hasDigit || hasMixedCase) {\n      return true\n    }\n  }\n\n  // 3. JWT 格式 (eyJ 开头，包含两个点分隔的 base64 段，总长度 >= 20)\n  if (/^eyJ[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+\\./.test(token) && token.length >= 20) {\n    return true\n  }\n\n  // 4. 长随机字符串（≥32 字符，必须同时包含字母和数字）\n  if (token.length >= 32 && /^[a-zA-Z0-9_-]+$/.test(token) && /[a-zA-Z]/.test(token) && /\\d/.test(token)) {\n    return true\n  }\n\n  // 5. 宽松兜底：常见 API Key 前缀 + 任意后缀（至少 1 个字符）\n  // 当以上严格规则都不匹配时，放松标准识别常见格式\n  // 支持：sk-xxx, api-xxx, key-xxx, ut_xxx, hf_xxx, gsk_xxx 等\n  if (/^(sk|api|key|ut|hf|gsk|cr|ms|r8|pplx)[-_].+$/i.test(token)) {\n    return true\n  }\n\n  return false\n}\n\n/**\n * 检测字符串是否为有效的 URL\n *\n * 要求：\n * - 必须以 http:// 或 https:// 开头\n * - 必须包含有效域名（域名段不能以横线开头或结尾）\n * - 支持末尾 # 标记（用于跳过自动添加 /v1）\n */\nexport const isValidUrl = (token: string): boolean => {\n  // 域名段不能以横线开头或结尾，支持末尾 # 或 / 或直接结束\n  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(\n    token\n  )\n}\n\n/**\n * 从输入中提取所有 token\n * 按空白/逗号/分号/中文冒号/换行/引号（中英文）/等号/%20 分割\n */\nconst extractTokens = (input: string): string[] => {\n  return input\n    .replace(/%20/g, ' ')\n    .split(/[\\n\\s,;，；：=\"\\u201c\\u201d'\\u2018\\u2019]+/)\n    .filter(t => t.length > 0)\n}\n\n/**\n * 根据 URL 路径检测服务类型，并返回清理后的 baseUrl\n * /messages → claude, /chat/completions → openai, /responses → responses\n */\nconst detectServiceTypeAndCleanUrl = (\n  url: string\n): { serviceType: 'openai' | 'gemini' | 'claude' | 'responses' | null; cleanedUrl: string } => {\n  try {\n    const cleanUrl = url.replace(/#$/, '')\n    const parsed = new URL(cleanUrl)\n    const path = parsed.pathname.toLowerCase()\n\n    // 检测端点并移除\n    const endpoints = ['/messages', '/chat/completions', '/responses', '/generatecontent']\n    for (const ep of endpoints) {\n      if (path.includes(ep)) {\n        // 移除端点路径，保留 /v1 等版本前缀\n        const idx = path.indexOf(ep)\n        parsed.pathname = path.slice(0, idx) || '/'\n        const serviceType =\n          ep === '/messages'\n            ? 'claude'\n            : ep === '/chat/completions'\n              ? 'openai'\n              : ep === '/responses'\n                ? 'responses'\n                : 'gemini'\n        let result = parsed.toString().replace(/\\/$/, '')\n        if (url.endsWith('#')) result += '#'\n        return { serviceType, cleanedUrl: result }\n      }\n    }\n  } catch {\n    // 忽略解析错误\n  }\n  return { serviceType: null, cleanedUrl: url }\n}\n\n// 保留导出以兼容可能的外部使用\nexport const detectServiceType = (url: string): 'openai' | 'gemini' | 'claude' | 'responses' | null => {\n  return detectServiceTypeAndCleanUrl(url).serviceType\n}\n\n/** Base URL 最大数量限制 */\nconst MAX_BASE_URLS = 10\n\n/**\n * 解析快速输入内容，提取 URL 和 API Keys\n *\n * 支持的格式：\n * 1. 纯文本：URL 和 API Key 以空白/逗号/分号/等号分隔\n * 2. 引号包裹：从 \"xxx\" 或 'xxx' 中提取内容（支持 JSON 配置格式）\n * 3. 多 Base URL：所有符合 HTTP 链接格式的都作为 baseUrl（最多 10 个）\n */\nexport const parseQuickInput = (\n  input: string\n): {\n  detectedBaseUrl: string\n  detectedBaseUrls: string[]\n  detectedApiKeys: string[]\n  detectedServiceType: 'openai' | 'gemini' | 'claude' | 'responses' | null\n} => {\n  const detectedBaseUrls: string[] = []\n  let detectedServiceType: 'openai' | 'gemini' | 'claude' | 'responses' | null = null\n  const detectedApiKeys: string[] = []\n\n  const tokens = extractTokens(input)\n\n  for (const token of tokens) {\n    if (isValidUrl(token)) {\n      // 限制最大 URL 数量\n      if (detectedBaseUrls.length >= MAX_BASE_URLS) {\n        continue\n      }\n\n      const endsWithHash = token.endsWith('#')\n      let url = endsWithHash ? token.slice(0, -1) : token\n      url = url.replace(/\\/$/, '')\n      const fullUrl = endsWithHash ? url + '#' : url\n\n      // 检测协议并清理 URL（移除端点路径）\n      const { serviceType, cleanedUrl } = detectServiceTypeAndCleanUrl(fullUrl)\n\n      // 避免重复\n      if (!detectedBaseUrls.includes(cleanedUrl)) {\n        detectedBaseUrls.push(cleanedUrl)\n        // 使用第一个 URL 的服务类型\n        if (!detectedServiceType) {\n          detectedServiceType = serviceType\n        }\n      }\n      continue\n    }\n\n    if (isValidApiKey(token) && !detectedApiKeys.includes(token)) {\n      detectedApiKeys.push(token)\n    }\n  }\n\n  return {\n    detectedBaseUrl: detectedBaseUrls[0] || '',\n    detectedBaseUrls,\n    detectedApiKeys,\n    detectedServiceType\n  }\n}\n"
  },
  {
    "path": "frontend/src/views/ChannelsView.vue",
    "content": "<template>\n  <!-- 渠道编排（高密度列表模式） -->\n  <ChannelOrchestration\n    v-if=\"channelStore.currentChannelsData.channels?.length\"\n    :channels=\"channelStore.currentChannelsData.channels\"\n    :current-channel-index=\"channelStore.currentChannelsData.current ?? 0\"\n    :channel-type=\"channelType\"\n    :dashboard-metrics=\"channelStore.currentDashboardMetrics\"\n    :dashboard-stats=\"channelStore.currentDashboardStats\"\n    :dashboard-recent-activity=\"channelStore.currentDashboardRecentActivity\"\n    class=\"mb-6\"\n    v-bind=\"$attrs\"\n  />\n\n  <!-- 空状态 -->\n  <v-card v-if=\"!channelStore.currentChannelsData.channels?.length\" elevation=\"2\" class=\"text-center pa-12\" rounded=\"lg\">\n    <v-avatar size=\"120\" color=\"primary\" class=\"mb-6\">\n      <v-icon size=\"60\" color=\"white\">mdi-rocket-launch</v-icon>\n    </v-avatar>\n    <div class=\"text-h4 mb-4 font-weight-bold\">暂无渠道配置</div>\n    <div class=\"text-subtitle-1 text-medium-emphasis mb-8\">\n      还没有配置任何API渠道，请添加第一个渠道来开始使用代理服务\n    </div>\n    <v-btn color=\"primary\" size=\"x-large\" prepend-icon=\"mdi-plus\" variant=\"elevated\" @click=\"emitAddChannel\">\n      添加第一个渠道\n    </v-btn>\n  </v-card>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { useChannelStore } from '@/stores/channel'\nimport { useDialogStore } from '@/stores/dialog'\nimport ChannelOrchestration from '@/components/ChannelOrchestration.vue'\n\n// 接收路由参数\nconst props = defineProps<{ type: string }>()\n\n// 转换为类型安全的 channelType\nconst channelType = computed(() =>\n  props.type as 'messages' | 'responses' | 'gemini'\n)\n\nconst channelStore = useChannelStore()\nconst dialogStore = useDialogStore()\n\nconst emitAddChannel = () => {\n  // 打开添加渠道对话框\n  dialogStore.openAddChannelModal()\n}\n</script>\n"
  },
  {
    "path": "frontend/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"preserve\",\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"src/*\"]\n    }\n  },\n  \"include\": [\"src/**/*.ts\", \"src/**/*.vue\"],\n  \"exclude\": [\"dist\", \"node_modules\"]\n}"
  },
  {
    "path": "frontend/vite.config.ts",
    "content": "import { defineConfig, loadEnv } from 'vite'\nimport vue from '@vitejs/plugin-vue'\nimport vuetify from 'vite-plugin-vuetify'\nimport { resolve } from 'path'\n\nexport default defineConfig(({ mode }) => {\n  // 加载环境变量\n  const env = loadEnv(mode, process.cwd(), '')\n\n  const frontendPort = parseInt(env.VITE_FRONTEND_PORT || '5173')\n  const backendUrl = env.VITE_PROXY_TARGET || 'http://localhost:3000'\n\n  return {\n    // 使用绝对路径，适配 Go 嵌入式部署\n    base: '/',\n\n    plugins: [\n      vue(),\n      vuetify({\n        autoImport: false, // 禁用自动导入，使用手动配置的图标\n        styles: {\n          configFile: 'src/styles/settings.scss'\n        }\n      })\n    ],\n    resolve: {\n      alias: {\n        '@': resolve(__dirname, 'src')\n      }\n    },\n    server: {\n      port: frontendPort,\n      proxy: {\n        '/api': {\n          target: backendUrl,\n          changeOrigin: true\n        },\n        '/v1': {\n          target: backendUrl,\n          changeOrigin: true\n        },\n        '/health': {\n          target: backendUrl,\n          changeOrigin: true\n        }\n      }\n    },\n    css: {\n      preprocessorOptions: {\n        scss: {\n          silenceDeprecations: ['import', 'global-builtin', 'if-function']\n        }\n      }\n    },\n    build: {\n      outDir: 'dist',\n      emptyOutDir: true,\n      // 确保资源路径正确\n      assetsDir: 'assets',\n      // 优化代码分割\n      rollupOptions: {\n        output: {\n          manualChunks: {\n            'vue-vendor': ['vue', 'vuetify']\n          }\n        }\n      }\n    }\n  }\n})\n"
  }
]