diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4bc793f..863a0eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,15 +1,14 @@ # ============================================================================= -# coding-proxy: PyPI Publishing Pipeline (4-Stage Unified Pipeline) +# coding-proxy: PyPI Publishing Pipeline (3-Stage Unified Pipeline) # ============================================================================= # Trigger: GitHub Release publication event (release.types: [published]) # -# Architecture: 4-Stage Serial Pipeline (prerelease only — 所有生产发布均走预发布) +# Architecture: 3-Stage Serial Pipeline (prerelease only — 所有生产发布均走预发布) # # Stage 1 (build): 矩阵构建 3.12/3.13/3.14,上传 artifacts # Stage 2 (publish-testpypi): 发布到 TestPyPI 供验证 # Stage 3 (publish-to-pypi): ⏸ Production Approval Gate (environment: pypi) # 审批通过后自动:Promote Release + Publish to PyPI -# Stage 4 (update-changelog): 从 Release notes 生成 Changelog PR → master # # 设计决策 — Stage 3 合并审批与发布的理由: # pypa/gh-action-pypi-publish@release/v1 始终优先尝试 OIDC Trusted Publishing。 @@ -21,9 +20,6 @@ # Pre-requisites — 需要在 GitHub Settings 一次性手动配置: # 1. repo → Settings → Environments → "pypi" # → Required reviewers: 添加审批人员(Production Approval Gate 所需) -# 2. repo → Settings → Actions → General → Workflow permissions -# → "Read and write permissions"(Stage 4 推分支所需) -# → 勾选 "Allow GitHub Actions to create and approve pull requests" # # Authentication: # - TEST_PYPI_API_TOKEN: TestPyPI API Token(repository secret) @@ -197,130 +193,3 @@ jobs: with: skip-existing: true verbose: true - - # =========================================================================== - # Stage 5: UPDATE CHANGELOG -- 自动生成 Changelog PR → master - # =========================================================================== - update-changelog: - name: Update Changelog - runs-on: ubuntu-latest - needs: publish-to-pypi - if: github.event.release.prerelease == true - timeout-minutes: 10 - permissions: - contents: write # git push 新分支 - pull-requests: write # 创建 PR - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - persist-credentials: true - - - name: Extract stable version from prerelease tag - id: version - run: | - PRERELEASE_TAG="${{ github.ref_name }}" - # v0.2.0a1 → v0.2.0(移除预发布后缀 a*/b*/rc*) - STABLE_VERSION=$(echo "$PRERELEASE_TAG" | sed 's/\(v[0-9]*\.[0-9]*\.[0-9]*\).*/\1/') - echo "prerelease_tag=$PRERELEASE_TAG" >> "$GITHUB_OUTPUT" - echo "stable_version=$STABLE_VERSION" >> "$GITHUB_OUTPUT" - echo "branch_name=chore/changelog-$STABLE_VERSION" >> "$GITHUB_OUTPUT" - echo "Extracted: $PRERELEASE_TAG → $STABLE_VERSION" - - - name: Check if Changelog entry already exists - id: check - run: | - STABLE="${{ steps.version.outputs.stable_version }}" - if grep -qF "[$STABLE]" CHANGELOG.md; then - echo "exists=true" >> "$GITHUB_OUTPUT" - echo "ℹ️ Changelog entry for $STABLE already exists, skipping PR creation" - else - echo "exists=false" >> "$GITHUB_OUTPUT" - echo "✅ No existing entry for $STABLE, will create PR" - fi - - - name: Fetch release body and update CHANGELOG.md - if: steps.check.outputs.exists == 'false' - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const { owner, repo } = context.repo; - - // 通过 API 获取 Release body,避免 shell 特殊字符注入问题 - const release = await github.rest.repos.getReleaseByTag({ - owner, - repo, - tag: '${{ steps.version.outputs.prerelease_tag }}', - }); - - const stable = '${{ steps.version.outputs.stable_version }}'; - const date = new Date().toISOString().slice(0, 10); - const releaseUrl = `https://github.com/${owner}/${repo}/releases/tag/${{ steps.version.outputs.prerelease_tag }}`; - const body = release.data.body || ''; - - // 构建新条目 - const newEntry = `\n## [${stable}](${releaseUrl}) — ${date}\n\n${body}\n`; - - // 读取 CHANGELOG.md,在 ## [Unreleased] 行后插入新条目 - let content = fs.readFileSync('CHANGELOG.md', 'utf8'); - const marker = '## [Unreleased]'; - const idx = content.indexOf(marker); - if (idx === -1) { - core.setFailed('CHANGELOG.md 中未找到 ## [Unreleased] 标记,无法插入条目'); - return; - } - const insertPos = idx + marker.length; - content = content.slice(0, insertPos) + newEntry + content.slice(insertPos); - fs.writeFileSync('CHANGELOG.md', content, 'utf8'); - core.info(`✅ CHANGELOG.md updated with entry for ${stable}`); - - - name: Create branch and commit - if: steps.check.outputs.exists == 'false' - run: | - BRANCH="${{ steps.version.outputs.branch_name }}" - STABLE="${{ steps.version.outputs.stable_version }}" - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - git checkout -b "$BRANCH" - git add CHANGELOG.md - git commit -m "docs(changelog): add entry for $STABLE" - git push origin "$BRANCH" - - - name: Create Pull Request - if: steps.check.outputs.exists == 'false' - uses: actions/github-script@v7 - with: - script: | - const { owner, repo } = context.repo; - const stable = '${{ steps.version.outputs.stable_version }}'; - const branch = '${{ steps.version.outputs.branch_name }}'; - const prereleaseTag = '${{ steps.version.outputs.prerelease_tag }}'; - const runUrl = `https://github.com/${owner}/${repo}/actions/runs/${{ github.run_id }}`; - - const pr = await github.rest.pulls.create({ - owner, - repo, - title: `docs(changelog): 补充 ${stable} 发版说明`, - body: [ - `## 摘要`, - ``, - `自动生成的 PR,为 **${stable}** 正式版补充 CHANGELOG 条目。`, - ``, - `- 来源:[\`${prereleaseTag}\`](https://github.com/${owner}/${repo}/releases/tag/${prereleaseTag}) Release notes`, - `- 目标分支:\`master\``, - ``, - `请审阅 CHANGELOG 条目后合并。`, - ``, - `> 🤖 由 [发布流水线](${runUrl}) 自动生成`, - ].join('\n'), - head: branch, - base: 'master', - }); - - core.notice(`✅ PR created: ${pr.data.html_url}`); - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..fdd726d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,57 @@ +# ============================================================================= +# coding-proxy: Pre-commit Hooks Configuration +# ============================================================================= +# 目的: 在代码提交前自动执行 Ruff lint/format 及通用卫生检查,作为 CI 的前置守护 +# +# 设计决策:使用 local hook(language: system + uv run)而非 astral-sh/ruff-pre-commit +# - 遵循 Single Source of Truth 原则:Ruff 版本由 uv.lock 唯一管控,消除版本漂移 +# - 与 CI 命令形态完全镜像(uv run ruff check/format) +# - 符合 AGENTS.md 包管理规范(统一使用 uv) +# +# 安装:uv run pre-commit install +# 手动全量检查:uv run pre-commit run --all-files +# ============================================================================= + +minimum_pre_commit_version: "4.0.0" + +repos: + # --------------------------------------------------------------------------- + # 通用代码卫生检查(pre-commit-hooks 官方钩子集) + # 轻量级、无额外依赖、执行极快 + # --------------------------------------------------------------------------- + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace # 去除行尾空白 + - id: end-of-file-fixer # 确保文件以换行符结尾 + - id: check-yaml + args: ["--unsafe"] # 兼容 GitHub Actions ${{ }} 表达式语法 + - id: check-toml # 校验 TOML 语法(pyproject.toml) + - id: check-merge-conflict # 阻止提交含未解决 merge conflict 标记的文件 + - id: check-added-large-files + args: ["--maxkb=1024"] # 阈值 1MB(assets/dashboard-v0.2.3.png 为 944KB) + + # --------------------------------------------------------------------------- + # Ruff: local hook,直接调用 uv 安装的 Ruff(与 CI 版本完全一致) + # 前置条件:uv sync --dev 已执行 + # --------------------------------------------------------------------------- + - repo: local + hooks: + # 对应 CI: uv run ruff check . + # --fix: 自动修复安全可修复问题(isort、UP 现代化等),降低开发摩擦 + # --exit-non-zero-on-fix: 修复后返回非零码,触发 git 重新暂存已修复文件 + - id: ruff-lint + name: Ruff (lint + auto-fix) + language: system + entry: uv run ruff check --fix --exit-non-zero-on-fix + types_or: [python, pyi] + require_serial: false + + # 对应 CI: uv run ruff format --check . + # 本地直接 format(自动修复格式),CI 保留 --check 模式作为最终防线 + - id: ruff-format + name: Ruff (format) + language: system + entry: uv run ruff format + types_or: [python, pyi] + require_serial: false diff --git a/AGENTS.md b/AGENTS.md index 7750c88..ab7bd32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,7 @@ 2. **Temp Management**: 临时产物(执行计划等)一律收敛至 `.temp/` 并及时清理; 3. **Link Validity**: 确保所有引用的 URL 可访问且具备明确的上下文价值; 4. **Git Commit**: 在需要提交变更到 Git 时,一律使用 Shell 调用 Claude Code 的自定义 Slash Command: `/commit` 进行 git commit 操作(若环境中未安装 Claude Code,则直接读取 `~/.claude/commands/commit.md`,按照其中的规则进行 git commit 操作)。不要执行 Rebase。 + 5. **Pre-commit Hooks**: 克隆仓库后执行 `uv run pre-commit install` 激活本地 Git hooks,使 Ruff lint(含 auto-fix)、Ruff format 及通用代码卫生检查在每次 commit 前自动运行。若 hooks 自动修复了问题,提交会被中断,执行 `git add -p` 审阅修复内容后重新提交即可。 - **Package Management Standardization (包管理规范)**: 1. **Python**: 严禁使用 pip/poetry,**必须**统一使用 `uv` 进行包管理与脚本执行(如 `uv run`); 2. **JavaScript/TypeScript**: 严禁使用 npm/yarn,**必须**统一使用 `pnpm` 进行包管理与脚本执行。 diff --git a/CHANGELOG.md b/CHANGELOG.md index c895603..12ef322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,11 @@ ## [Unreleased] -## [v0.2.3](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.2.3a1) — 2026-04-15 +- fix(request-normalizer): 重设计 zhipu→anthropic 跨供应商 tool_use/tool_result 配对修复——以单遍自包含 `enforce_anthropic_tool_pairing` 替代原有多步串联管线(剥离→重定位→孤儿修复),消除步骤间隐式依赖导致的孤儿 tool_use 漏修问题,彻底根治 `tool_use ids were found without tool_result blocks` 400 异常; +- refactor(vendor-channels): 将供应商转换通道从「目标 vendor 专属」重构为「源→目标绑定」模型——注册表键从 `target_vendor` 改为 `(source, target)` 二元组,通道函数从 `prepare_for_X` 重命名为 `prepare_X_to_Y`,触发逻辑从 `_needs_vendor_channel` 替换为 `_determine_source_vendor`(基于请求内 `failed_tier_name` 和会话历史推断源 vendor),未注册的转换对(如 anthropic→zhipu)不触发任何通道; +- feat(vendor-channels): 新增 zhipu→anthropic、zhipu→copilot、copilot→zhipu 三条源→目标绑定转换通道,在跨供应商故障转移时自动清理源 vendor 产物(thinking 块、cache_control 字段、thinking 参数、tool_use/tool_result 配对),消除 `likely format incompatibility (400 + tool_results)` 错误; + +## [v0.2.3](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.2.3) — 2026-04-16 - feat(dashboard): 新增实时 Web Dashboard 页面,聚合展示流量与用量统计; - docs(user-guide): 补充 POST /v1/messages 完整 API 参考文档; @@ -71,9 +75,9 @@ ## [v0.1.1](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.1.1) — 2026-04-05 > [!IMPORTANT] -> -> **🎉 coding-proxy MVP 惊艳登场!** -> +> +> **🎉 coding-proxy MVP 惊艳登场!** +> > 仅需配置一行环境变量,立刻为你的 Claude Code 接入“永不宕机”的多源智能引擎。主供应商打盹?毫秒级自动无缝切换备用通道,全天候护航你的编码心流,向打断大声说不! ### ✨ 核心亮点 diff --git a/README.md b/README.md index d24fe37..ef5e92b 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,10 @@ When you're deeply immersed in your coding "zone" with **Claude Code** (or any A -- **⛓️ N-tier Chained Failover**: Autonomous descending sequence, supporting Claude's official plans, as well as Coding Plans from GitHub Copilot, Z AI, MiniMax, Alibaba Qwen, Xiaomi, Kimi, Doubao, etc. +- **⛓️ N-tier Chained Failover**: Autonomous descending sequence, supporting Claude's official plans, as well as Coding Plans from GitHub Copilot, Google Antigravity, Z AI, MiniMax, Alibaba Qwen, Xiaomi, Kimi, Doubao, etc. - **🛡️ Smart Resilience & Quota Guardians**: Every single vendor node comes fully armed with an independent **Circuit Breaker** and **Quota Guard** to proactively dodge avalanches without breaking a sweat. - **👻 Phantom-like Transparency**: **100% transparent** to the client! No code tweaks required. Overwrite `ANTHROPIC_BASE_URL` with a single line, and you're good to go. -- **🔄 Universal Alchemy (Formats & Models)**: Native support for two-way request/streaming (SSE) translations between Anthropic ←→ Gemini. Plus, auto/DIY model name mapping (e.g., effortlessly morphing `claude-*` into `glm-*`). +- **🔄 Universal Alchemy (Formats & Models)**: Native support for two-way request/streaming (SSE) translations between Anthropic ←→ Gemini and Anthropic ←→ OpenAI. Plus, auto/DIY model name mapping (e.g., effortlessly morphing `claude-*` into `glm-*`). - **📊 Extreme Observability**: Built-in, zero-BS local monitoring powered by a `SQLite WAL`. The CLI provides a one-click detailed Token usage dashboard (`coding-proxy usage`). - **⚡ Featherweight Standalone Deployment**: A fully asynchronous architecture (`FastAPI` + `httpx`). Zero dependency on Redis, message queues, or other heavy machinery—absolutely no extra baggage for your dev rig. @@ -95,6 +95,7 @@ claude | Command | Description | Example Usage | | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------- | | `start` | **Fire up the proxy server.** Supports custom ports and configuration paths. | `coding-proxy start -p 8080 -c ~/config.yaml` | +| `auth` | **Manage OAuth credentials.** Sub-commands: `login` (browser OAuth), `status` (token validity), `reauth` (re-authenticate), `logout` (clear tokens). | `coding-proxy auth login -p github` | | `status` | **Check proxy health.** Shows circuit breaker states (OPEN/CLOSED) and quota status across all tiers. | `coding-proxy status` | | `usage` | **Token Stats Dashboard.** Stalks every single token consumed, failovers triggered, and latency across day/vendor/model dimensions. | `coding-proxy usage -d 7 -v anthropic` | | `reset` | **The emergency flush button.** Force-reset all circuit breakers and quotas instantly when you've confirmed the main vendor is back from the dead. | `coding-proxy reset` | @@ -118,7 +119,7 @@ graph RL subgraph CodingProxy["⚡ coding-proxy"] direction RL - + Router["RequestRouter
routing/router.py"]:::router Router -->NTier @@ -126,32 +127,32 @@ graph RL subgraph NTier["N-tier"] direction TB - subgraph Tier0 ["Tier 0: Anthropic"] + subgraph Tier0 ["Tier 0: Zhipu"] direction RL - G0{"CB / Quota"}:::gateway -- "✅ Pass" --> API0(("Anthropic API")):::api + G0{"CB / Quota"}:::gateway -- "✅ Pass" --> API0(("GLM API")):::api end - subgraph Tier1 ["Tier 1: GitHub Copilot"] + subgraph Tier1 ["Tier 1: Anthropic"] direction RL - G1{"CB / Quota"}:::gateway -- "✅ Pass" --> API1(("Copilot API")):::api + G1{"CB / Quota"}:::gateway -- "✅ Pass" --> API1(("Anthropic API")):::api end - subgraph Tier2 ["Tier 2: Google Antigravity"] + subgraph Tier2 ["Tier 2: GitHub Copilot"] direction RL - G2{"CB / Quota"}:::gateway -- "✅ Pass" --> API2(("Gemini API")):::api + G2{"CB / Quota"}:::gateway -- "✅ Pass" --> API2(("Copilot API")):::api end - subgraph TierN ["Tier N: Zhipu"] + subgraph Tier3 ["Tier 3: Google Antigravity"] direction RL - APIN(("GLM API")):::fallback + API3(("Gemini API")):::fallback end Tier0 -. "❌ Blocked / API Error" .-> Tier1 Tier1 -. "❌ Blocked / API Error" .-> Tier2 - Tier2 -. "🆘 Safety Net Downgrade" .-> TierN + Tier2 -. "🆘 Safety Net Downgrade" .-> Tier3 end - end + end Client -->|"POST /v1/messages"| CodingProxy ``` diff --git a/assets/dashboard-v0.2.3.png b/assets/dashboard-v0.2.3.png index bbd2602..aef75f7 100644 Binary files a/assets/dashboard-v0.2.3.png and b/assets/dashboard-v0.2.3.png differ diff --git a/docs/arch/config-reference.md b/docs/arch/config-reference.md new file mode 100644 index 0000000..e550bec --- /dev/null +++ b/docs/arch/config-reference.md @@ -0,0 +1,259 @@ +# 配置字段参考 + +> **路径约定**:本文档中模块路径均相对于 `src/coding/proxy/`。 +> +> **定位**:本文档是所有配置参数的**规范来源(Single Source of Truth)**。设计模式章节和模块详情中引用参数默认值时,统一链接至此。 + +[TOC] + +--- + +## 1. 配置模块结构 + +配置模型已从单体 `schema.py` 正交拆分为 5 个子模块: + +| 子模块 | 文件 | 核心类型 | +| --------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **server** | [`config/server.py`](../../src/coding/proxy/config/server.py) | `ServerConfig`, `DatabaseConfig`, `LoggingConfig` | +| **vendors** | [`config/vendors.py`](../../src/coding/proxy/config/vendors.py) | `AnthropicConfig`, `CopilotConfig`, `AntigravityConfig`, `ZhipuConfig`, `MinimaxConfig`, `KimiConfig`, `DoubaoConfig`, `XiaomiConfig`, `AlibabaConfig` | +| **resiliency** | [`config/resiliency.py`](../../src/coding/proxy/config/resiliency.py) | `CircuitBreakerConfig`, `RetryConfig`, `FailoverConfig`, `QuotaGuardConfig` | +| **routing** | [`config/routing.py`](../../src/coding/proxy/config/routing.py) | `VendorType`, `VendorConfig`, `ModelMappingRule`, `ModelPricingEntry` | +| **auth_schema** | [`config/auth_schema.py`](../../src/coding/proxy/config/auth_schema.py) | `AuthConfig` | + +`config/schema.py` 作为聚合入口点 re-export 所有符号,并保留 `ProxyConfig` 顶层模型及旧格式迁移逻辑。 + +--- + +## 2. 配置搜索优先级 + +[`config/loader.py`](../../src/coding/proxy/config/loader.py) 按以下顺序搜索配置文件: + +```mermaid +flowchart TD + A["CLI --config 参数
(显式指定)"] -->|"未指定"| B["./config.yaml
(项目根目录)"] + B -->|"不存在"| C["~/.coding-proxy/config.yaml
(用户目录)"] + C -->|"不存在"| D["Pydantic 默认值"] + + style A fill:#1a5276,color:#fff + style D fill:#7b241c,color:#fff +``` + +**环境变量展开**:语法 `${VARIABLE_NAME}`,递归处理 dict/list/str,未定义变量保留原文。 + +--- + +## 3. 服务器配置 + +### 3.1 ServerConfig + +| 字段 | 类型 | 默认值 | 说明 | +| ------ | ---- | ------------- | -------- | +| `host` | str | `"127.0.0.1"` | 监听地址 | +| `port` | int | `8046` | 监听端口 | + +### 3.2 DatabaseConfig + +| 字段 | 类型 | 默认值 | 说明 | +| -------------------------- | ---- | ----------------------------- | --------------------- | +| `path` | str | `"~/.coding-proxy/usage.db"` | SQLite 数据库文件路径 | +| `compat_state_path` | str | `"~/.coding-proxy/compat.db"` | 兼容性会话存储路径 | +| `compat_state_ttl_seconds` | int | `86400` | 兼容性会话 TTL(秒) | + +### 3.3 LoggingConfig + +| 字段 | 类型 | 默认值 | 说明 | +| -------------- | ----------- | --------- | ---------------------------------------- | +| `level` | str | `"INFO"` | 控制台日志级别 | +| `file` | str \| null | `null` | 文件日志路径(`null` 时输出到控制台) | +| `max_bytes` | int | `5242880` | 单个日志文件最大字节数(5 MB),触发轮转 | +| `backup_count` | int | `5` | 保留的已压缩备份文件数 | + +### 3.4 AuthConfig + +| 字段 | 类型 | 默认值 | 说明 | +| ------------------ | ---- | ------ | ------------------------------------ | +| `token_store_path` | str | `""` | Token Store 文件路径(空则使用默认) | + +--- + +## 4. VendorConfig 通用字段 + +| 字段 | 类型 | 默认值 | 说明 | +| ------------ | ---- | -------- | -------------------------------------------------------------------------------------------------------------------- | +| `vendor` | enum | -- | 供应商类型:`anthropic` / `copilot` / `antigravity` / `zhipu` / `minimax` / `kimi` / `doubao` / `xiaomi` / `alibaba` | +| `enabled` | bool | `true` | 是否启用 | +| `base_url` | str | `""` | API 基础 URL(留空使用各供应商默认值) | +| `timeout_ms` | int | `300000` | 请求超时(毫秒) | + +--- + +## 5. VendorConfig 弹性字段 + +| 字段 | 类型 | 默认值 | 说明 | +| -------------------- | -------------- | -------------------- | --------------------------- | +| `circuit_breaker` | config \| None | `None` | 熔断器配置(None = 终端层) | +| `retry` | config | `RetryConfig()` | 重试策略配置 | +| `quota_guard` | config | `QuotaGuardConfig()` | 日度配额守卫配置 | +| `weekly_quota_guard` | config | `QuotaGuardConfig()` | 周度配额守卫配置 | + + + +### 5.1 CircuitBreakerConfig — 熔断器参数 + +> **设计语义**:参见 [设计模式 -- Circuit Breaker](./design-patterns.md#circuit-breaker) + +| 字段 | 类型 | 默认值 | 说明 | +| -------------------------- | ---- | ------ | --------------------------------- | +| `failure_threshold` | int | `3` | 触发 OPEN 的连续失败次数 | +| `recovery_timeout_seconds` | int | `300` | OPEN → HALF_OPEN 等待秒数 | +| `success_threshold` | int | `2` | HALF_OPEN → CLOSED 所需连续成功数 | +| `max_recovery_seconds` | int | `3600` | 指数退避最大恢复时间(秒) | + +### 5.2 QuotaGuardConfig — 配额守卫参数 + +> **设计语义**:参见 [设计模式 -- QuotaGuard State Machine](./design-patterns.md#quota-guard) + +| 字段 | 类型 | 默认值 | 说明 | +| ------------------------ | ----- | ------- | ------------------------------------ | +| `enabled` | bool | `false` | 是否启用配额守卫 | +| `token_budget` | int | `0` | 滑动窗口内的 Token 预算上限 | +| `window_hours` | float | `5.0` | 滑动窗口大小(小时) | +| `threshold_percent` | float | `99.0` | 触发 QUOTA_EXCEEDED 的用量百分比阈值 | +| `probe_interval_seconds` | int | `300` | QUOTA_EXCEEDED 状态下探测间隔(秒) | + +### 5.3 RetryConfig — 重试策略参数 + +> **设计语义**:参见 [设计模式 -- Retry with Full Jitter](./design-patterns.md#retry) + +| 字段 | 类型 | 默认值 | 说明 | +| -------------------- | ----- | ------ | ---------------- | +| `max_retries` | int | `2` | 最大重试次数 | +| `initial_delay_ms` | int | `500` | 初始延迟(毫秒) | +| `max_delay_ms` | int | `5000` | 最大延迟(毫秒) | +| `backoff_multiplier` | float | `2.0` | 退避倍数 | +| `jitter` | bool | `true` | 是否启用抖动 | + +### 5.4 FailoverConfig — 故障转移参数 + +> **设计语义**:参见 [请求生命周期 -- 故障转移判定](../framework.md#fault-overhead) + +| 字段 | 类型 | 默认值 | +| ------------------------ | --------- | ---------------------------------------------------------------------------------- | +| `status_codes` | list[int] | `[429, 403, 503, 500, 529]` | +| `error_types` | list[str] | `["rate_limit_error", "overloaded_error", "api_error"]` | +| `error_message_patterns` | list[str] | `["quota", "limit exceeded", "usage cap", "capacity", "internal network failure"]` | + +--- + +## 6. 供应商专属字段 + +### 6.1 Copilot 专属字段 + +| 字段 | 类型 | 默认值 | 说明 | +| -------------------------- | ---- | --------------- | -------------------------------------------------- | +| `github_token` | str | `""` | GitHub OAuth token / PAT(支持 `${ENV_VAR}`) | +| `account_type` | str | `"individual"` | 账号类型:`individual` / `business` / `enterprise` | +| `token_url` | str | `"https://..."` | Token 交换端点 | +| `models_cache_ttl_seconds` | int | `300` | 模型列表缓存 TTL | + +### 6.2 Antigravity 专属字段 + +| 字段 | 类型 | 默认值 | 说明 | +| ---------------- | ---- | ----------------------------------- | --------------------------- | +| `client_id` | str | `""` | Google OAuth2 Client ID | +| `client_secret` | str | `""` | Google OAuth2 Client Secret | +| `refresh_token` | str | `""` | Google OAuth2 Refresh Token | +| `model_endpoint` | str | `"models/claude-sonnet-4-20250514"` | Gemini 模型端点路径 | + +### 6.3 原生 Anthropic 兼容供应商共用字段 + +适用于 `zhipu` / `minimax` / `kimi` / `doubao` / `xiaomi` / `alibaba`。 + +| 字段 | 类型 | 默认值 | 说明 | +| --------- | ---- | ------ | ---------------------------- | +| `api_key` | str | `""` | API Key(支持 `${ENV_VAR}`) | + +--- + +## 7. 模型映射规则 + +### 7.1 ModelMappingRule 字段 + +| 字段 | 类型 | 说明 | +| ---------- | --------- | ------------------------------------------ | +| `pattern` | str | 匹配模式(精确/通配符/正则) | +| `target` | str | 目标模型名称 | +| `is_regex` | bool | 是否为正则表达式(默认 `false`) | +| `vendors` | list[str] | 规则作用域(留空应用于所有原生兼容供应商) | + +### 7.2 ModelPricingEntry 字段 + +| 字段 | 类型 | 说明 | +| --------------------------- | ----- | -------------------------------------------------- | +| `vendor` | str | 供应商名称 | +| `model` | str | 实际模型名 | +| `input_cost_per_mtok` | float | 输入 Token 单价($/百万 token,支持 `$`/`¥` 前缀) | +| `output_cost_per_mtok` | float | 输出 Token 单价 | +| `cache_write_cost_per_mtok` | float | 缓存创建 Token 单价 | +| `cache_read_cost_per_mtok` | float | 缓存读取 Token 单价 | + +--- + +## 8. tiers — 显式优先级 + +| 字段 | 类型 | 说明 | +| ------- | ------------------------ | ------------------------------------------ | +| `tiers` | list[VendorType] \| None | 降级链路优先级(None 时回退 vendors 顺序) | + +--- + +## 9. vendors 列表格式(推荐) + +推荐使用 `vendors` 列表格式,每个 vendor 自包含其弹性配置: + +```yaml +vendors: + - vendor: anthropic + enabled: true + base_url: https://api.anthropic.com + timeout_ms: 300000 + circuit_breaker: + failure_threshold: 3 + recovery_timeout_seconds: 300 + quota_guard: + enabled: false + weekly_quota_guard: + enabled: false + + - vendor: copilot + enabled: false + github_token: "${GITHUB_TOKEN}" + account_type: individual + circuit_breaker: + failure_threshold: 3 + quota_guard: + enabled: true + token_budget: 3000000 + window_hours: 4.0 + + - vendor: zhipu + enabled: true + api_key: "${ZHIPU_API_KEY}" + # 无 circuit_breaker -> 终端层 + + - vendor: minimax + enabled: false + api_key: "${MINIMAX_API_KEY}" + +tiers: [anthropic, copilot, zhipu] # 显式优先级(可选) +``` + +### Legacy Flat 格式(向后兼容) + +旧的 flat 格式字段(`primary`/`copilot`/`antigravity`/`fallback`/`circuit_breaker`/`*_quota_guard`)仍受支持,通过 `ProxyConfig._migrate_legacy_fields()` 自动迁移为 vendors 列表格式。 + +迁移规则: +1. `anthropic` 字段名 → `primary` +2. `zhipu` 字段名 → `fallback` +3. 若无 `vendors` 字段,从 legacy flat 字段自动生成 vendors 列表 +4. 迁移时发出 INFO 日志建议迁移至新格式 diff --git a/docs/arch/convert.md b/docs/arch/convert.md new file mode 100644 index 0000000..3933a4d --- /dev/null +++ b/docs/arch/convert.md @@ -0,0 +1,101 @@ +# 格式转换模块(convert/) + +> **路径约定**:本文档中模块路径均相对于 `src/coding/proxy/`。 +> +> **定位**:从 `framework.md` 提取,详述 Anthropic ↔ Gemini ↔ OpenAI 三向格式转换。 + +[TOC] + +--- + +## 1. 模块总览 + +[`convert/`](../../src/coding/proxy/convert/) 模块提供独立的纯函数适配器层,支持三向格式转换: + +| 转换方向 | 模块 | 说明 | +| -------------------------- | ----------------------------------------------------------------------------------------- | ------------------ | +| Anthropic → Gemini | [`convert/anthropic_to_gemini.py`](../../src/coding/proxy/convert/anthropic_to_gemini.py) | 请求格式转换 | +| Gemini → Anthropic | [`convert/gemini_to_anthropic.py`](../../src/coding/proxy/convert/gemini_to_anthropic.py) | 响应格式转换 | +| Gemini SSE → Anthropic SSE | [`convert/gemini_sse_adapter.py`](../../src/coding/proxy/convert/gemini_sse_adapter.py) | 流式事件重构 | +| Anthropic → OpenAI | [`convert/anthropic_to_openai.py`](../../src/coding/proxy/convert/anthropic_to_openai.py) | Copilot 请求适配 | +| OpenAI → Anthropic | [`convert/openai_to_anthropic.py`](../../src/coding/proxy/convert/openai_to_anthropic.py) | Copilot 响应逆适配 | + +--- + +## 2. 请求转换(Anthropic → Gemini) + +**应用位置**:[`convert/anthropic_to_gemini.py`](../../src/coding/proxy/convert/anthropic_to_gemini.py) -- `convert_request()` + +**转换映射**: + +| Anthropic 字段 | Gemini 字段 | 说明 | +| --------------------------------- | ---------------------------------- | ------------------------------------------------ | +| `system`(str \| list) | `systemInstruction.parts[].text` | 支持字符串和文本块列表两种格式 | +| `messages[]` | `contents[]` | 角色映射:`assistant` → `model`,`user` → `user` | +| `content`(text) | `parts[].text` | 文本内容块 | +| `content`(image) | `parts[].inlineData` | Base64 数据 + MIME 类型 | +| `content`(tool_use) | `parts[].functionCall` | `name` + `input` → `args` | +| `content`(tool_result) | `parts[].functionResponse` | `tool_use_id` → `name`,`content` → `result` | +| `max_tokens` | `generationConfig.maxOutputTokens` | | +| `temperature` / `top_p` / `top_k` | `generationConfig.*` | 参数名驼峰转换 | +| `stop_sequences` | `generationConfig.stopSequences` | | + +**不支持的字段**(静默剥离并记录 WARNING):`tools`、`tool_choice`、`metadata`、`extended_thinking`、`thinking` + +--- + +## 3. 响应转换(Gemini → Anthropic) + +**应用位置**:[`convert/gemini_to_anthropic.py`](../../src/coding/proxy/convert/gemini_to_anthropic.py) -- `convert_response()` / `extract_usage()` + +**finishReason 映射**: + +| Gemini | Anthropic | +| --------------------------------- | ------------ | +| `STOP` | `end_turn` | +| `MAX_TOKENS` | `max_tokens` | +| `SAFETY` / `RECITATION` / `OTHER` | `end_turn` | + +**Parts 转换**: +- `text` → `{"type": "text", "text": "..."}` +- `functionCall` → `{"type": "tool_use", "id": "toolu_...", "name": "...", "input": {...}}` + +**Usage 提取**: +- `usageMetadata.promptTokenCount` → `input_tokens` +- `usageMetadata.candidatesTokenCount` → `output_tokens` +- 缓存字段填 0(Gemini 不直接暴露缓存信息) + +--- + +## 4. SSE 流适配 + +**应用位置**:[`convert/gemini_sse_adapter.py`](../../src/coding/proxy/convert/gemini_sse_adapter.py) -- `adapt_sse_stream()` + +将 Gemini SSE 流重构为 Anthropic 消息生命周期事件序列: + +```mermaid +flowchart LR + Input["Gemini SSE chunks"] --> MS["message_start
← 首次收到内容时发出"] + MS --> CBS["content_block_start
← 内容块开始"] + CBS --> CBD["content_block_delta*
← 增量文本"] + CBD --> CBS2["content_block_stop
← 内容块结束"] + CBS2 --> MD["message_delta
← stop_reason + output_tokens"] + MD --> MSP["message_stop
← 消息结束"] + + style Input fill:#1a5276,color:#fff + style MSP fill:#196f3d,color:#fff +``` + +**边界情况处理**: +- 空 parts 后跟有 text 的 chunk → 延迟发出 `message_start` + `content_block_start` +- 流结束但未收到 `finishReason` → 补发默认 `message_delta`(`stop_reason: "end_turn"`)+ `message_stop` + +--- + +## 5. OpenAI 格式转换 + +**应用位置**: +- [`convert/anthropic_to_openai.py`](../../src/coding/proxy/convert/anthropic_to_openai.py) -- Anthropic → OpenAI Chat Completions 请求格式 +- [`convert/openai_to_anthropic.py`](../../src/coding/proxy/convert/openai_to_anthropic.py) -- OpenAI Chat Completions → Anthropic 响应格式 + +专为 CopilotVendor 适配,处理 Anthropic Messages API 与 OpenAI Chat Completions API 之间的双向格式差异(角色映射、工具格式、usage 字段名等)。 diff --git a/docs/arch/design-patterns.md b/docs/arch/design-patterns.md new file mode 100644 index 0000000..0b8c31b --- /dev/null +++ b/docs/arch/design-patterns.md @@ -0,0 +1,607 @@ +# 设计模式详解 + +> **路径约定**:本文档中模块路径均相对于 `src/coding/proxy/`。 +> +> **定位**:本文档从 `framework.md` 中提取,详细阐述 coding-proxy 中运用的 13 种设计模式与工程模式。 + +[TOC] + +--- + +本章涵盖 coding-proxy 中运用的 **13 种设计模式与工程模式**,按职责域正交分为三类:**创建型**(对象构建策略)、**结构型**(组件组织方式)、**行为型与并发**(运行时行为控制)。 + +```mermaid +mindmap + root((设计模式)) + 创建型 + Strategy + Factory + Template Method + 结构型 + Proxy :: 整体架构 + Adapter :: Vendor层 + Composite :: VendorTier + Facade :: RequestRouter + 行为型/并发 + Circuit Breaker + Priority Chain + State Machine :: QuotaGuard + Double-Check Locking + Capability Routing + Retry Full Jitter + Rate Limit Deadline +``` + +--- + +## 3.1 Template Method(模板方法模式) + +> **经典出处**:GoF《Design Patterns: Elements of Reusable Object-Oriented Software》[[1]](#ref1) — 定义算法骨架,将某些步骤延迟到子类实现。 + +**应用位置**:[`vendors/base.py`](../../src/coding/proxy/vendors/base.py) — `BaseVendor` 抽象基类 + +**设计要点**: + +`BaseVendor` 定义了请求处理的算法骨架,将差异化的逻辑延迟到子类: + +```mermaid +classDiagram + direction TB + + class BaseVendor { + <> + +send_message() VendorResponse + +send_message_stream() AsyncIterator + +get_name()* str + +_prepare_request()* tuple + +map_model() str + +_get_endpoint() str + +_pre_send_check() + +_normalize_error_response() + +_on_error_status() + +get_capabilities() VendorCapabilities + +supports_request() tuple + +make_compatibility_decision() CompatibilityDecision + +check_health() bool + +should_trigger_failover() bool + +close() + } + + class NativeAnthropicVendor { + _prepare_request() 模型映射+API Key + map_model() 委托ModelMapper + _normalize_error_response() 401归一化 + } + + class AnthropicVendor { + _prepare_request() 过滤 hop-by-hop 头 + } + + class CopilotVendor { + _prepare_request() 过滤头+异步token注入 + _on_error_status() 401/403 token失效 + } + + class AntigravityVendor { + _prepare_request() Gemini格式转换+OAuth + check_health() OAuth有效性检查 + _on_error_status() 401/403 token失效 + } + + class ZhipuVendor { + _vendor_name = "zhipu" + } + + class MinimaxVendor { + _vendor_name = "minimax" + } + + BaseVendor <|-- NativeAnthropicVendor + BaseVendor <|-- AnthropicVendor + BaseVendor <|-- CopilotVendor + BaseVendor <|-- AntigravityVendor + NativeAnthropicVendor <|-- ZhipuVendor + NativeAnthropicVendor <|-- MinimaxVendor + + style BaseVendor fill:#1a5276,color:#fff + style NativeAnthropicVendor fill:#2e4053,color:#fff + style AnthropicVendor fill:#2874a6,color:#fff + style CopilotVendor fill:#2874a6,color:#fff + style AntigravityVendor fill:#2874a6,color:#fff + style ZhipuVendor fill:#7b241c,color:#fff + style MinimaxVendor fill:#7b241c,color:#fff +``` + +三类 Vendor 子类的差异化实现: + +| 方法 | AnthropicVendor(直接透传) | CopilotVendor(协议转换) | AntigravityVendor(协议转换) | NativeAnthropicVendor 子类(薄透传) | +| -------------------- | --------------------------- | ------------------------- | ---------------------------------- | ------------------------------------ | +| `_prepare_request()` | 过滤 hop-by-hop 头 | 过滤头 + 异步 token 注入 | Gemini 格式转换 + OAuth token | 模型映射 + API Key 替换 | +| `map_model()` | 恒等映射 | 恒等映射 | 恒等映射 | **委托 ModelMapper** | +| `_get_endpoint()` | `/v1/messages` | `/v1/chat/completions` | `/{model}:generateContent` | `/v1/messages`(继承) | +| `_on_error_status()` | 继承基类(空操作) | 401/403 token 失效 | 401/403 token 失效 | 继承基类(空操作) | +| `get_capabilities()` | 全部支持 | 不支持 thinking | 不支持 tools/thinking/metadata | 全部支持(NATIVE) | +| `check_health()` | 继承(True) | 继承(True) | **覆写**(OAuth token 有效性检查) | 继承(True) | + +> **供应商分类体系**详情参见 [供应商模块 -- 分类体系](./vendors.md#vendor-classification)。 + +--- + +## 3.2 Circuit Breaker(熔断器模式) + + + +> **经典出处**:Martin Fowler "CircuitBreaker" (2014)[[2]](#ref2);M. Nygard《Release It! Design and Deploy Production-Ready Software》第 5 章[[3]](#ref3) — 通过快速失败防止级联故障。 + +**应用位置**:[`routing/circuit_breaker.py`](../../src/coding/proxy/routing/circuit_breaker.py) — `CircuitBreaker` 类 + +**状态机**: + +```mermaid +stateDiagram-v2 + [*] --> CLOSED + + CLOSED --> OPEN : 连续 N 次失败\n(failure_threshold) + note right of OPEN : 熔断状态 — 快速失败 + + OPEN --> HALF_OPEN : 超时恢复\n(recovery_timeout_seconds) + + HALF_OPEN --> CLOSED : 连续 M 次成功\n(success_threshold) + note right of HALF_OPEN : 试探状态 + + HALF_OPEN --> OPEN : 任意一次失败\n(退避 × 2) + + CLOSED --> [*] +``` + +**状态转换条件**: + +| 转换 | 条件 | +| ------------------ | --------------------------------------- | +| CLOSED → OPEN | 连续失败次数 ≥ `failure_threshold` | +| OPEN → HALF_OPEN | 距上次失败 ≥ `recovery_timeout_seconds` | +| HALF_OPEN → CLOSED | 连续成功次数 ≥ `success_threshold` | +| HALF_OPEN → OPEN | 任意一次失败 | + +**指数退避 (Exponential Backoff)**:每次从 HALF_OPEN 回退到 OPEN 时,恢复等待时间翻倍(`recovery_timeout *= 2`),上限为 `max_recovery_seconds`。避免对仍未恢复的后端频繁重试。 + +**线程安全**:所有状态变更通过 `threading.Lock` 保护,确保并发请求下状态一致。 + +> **参数默认值**:参见 [配置参考 -- CircuitBreakerConfig](./config-reference.md#elastic-params) + +--- + +## 3.3 Priority Chain(优先级匹配链) + +**应用位置**:[`routing/model_mapper.py`](../../src/coding/proxy/routing/model_mapper.py) — `ModelMapper` 类 + +**设计要点**: + +ModelMapper 采用三级优先级匹配链,按精确度递减依次尝试: + +```mermaid +flowchart TD + A["输入模型名称"] --> B{"① 精确匹配
pattern == model"} + + B -- "命中" --> C["返回 target"] + B -- "未命中" --> D{"② 模式匹配
Regex / Glob"} + + D -- "is_regex=true" --> E["re.fullmatch()"] + D -- "含 *" --> F["fnmatch.fnmatch()"] + E --> G{"命中?"} + F --> G + + G -- "是" --> C + G -- "否" --> H["③ 默认回退
返回 glm-5.1"] + + style A fill:#1a5276,color:#fff + style C fill:#196f3d,color:#fff + style H fill:#7b241c,color:#fff +``` + +**默认映射规则**: + +| 模式 | 目标 | 类型 | +| ------------------ | ------------- | ------------ | +| `claude-sonnet-.*` | `glm-5.1` | 正则 | +| `claude-opus-.*` | `glm-5.1` | 正则 | +| `claude-haiku-.*` | `glm-4.5-air` | 正则 | +| `claude-.*` | `glm-5.1` | 正则(兜底) | + +正则表达式在 `__init__` 时预编译(`re.compile()`),`map()` 调用时直接使用编译后的对象,避免重复编译开销。 + +--- + +## 3.4 Strategy + Factory(策略 + 工厂方法模式) + +> **经典出处**:GoF《Design Patterns》[[1]](#ref1) — 定义创建对象的接口,由策略选择决定实例化哪个类。 + +**应用位置**:[`server/factory.py`](../../src/coding/proxy/server/factory.py) — `_create_vendor_from_config()` 函数 + +**组装顺序**(两阶段构建): + +```mermaid +flowchart TD + Start["create_app(config)"] --> Phase1 + + subgraph Phase1 ["阶段一:构建 vendor → VendorTier 映射表"] + direction TB + P1_Loop["for vendor_cfg in config.vendors"] --> Match["match vendor_cfg.vendor"] + Match --> A["anthropic → AnthropicVendor"] + Match --> B["copilot → CopilotVendor
+ 凭证解析"] + Match --> C["antigravity → AntigravityVendor
+ 凭证解析"] + Match --> D["zhipu/minimax/... →
NativeAnthropicVendor 子类"] + A & B & C & D --> BuildCB["_build_circuit_breaker()"] + BuildCB --> BuildQG["_build_quota_guard()"] + BuildQG --> Map["_vendor_map[name] =
VendorTier(vendor, cb, qg, wqg)"] + Map --> P1_Loop + end + + Phase1 --> Phase2 + + subgraph Phase2 ["阶段二:按 tiers 显式排序"] + direction TB + HasTiers{"config.tiers
存在?"} + HasTiers -- "是" --> Explicit["tiers = [_vendor_map[name]
for name in config.tiers]"] + HasTiers -- "否" --> Implicit["tiers = vendors 原始顺序"] + end + + Phase2 --> Phase3["构建 RuntimeReauthCoordinator
(若有 OAuth provider)"] + Phase3 --> Final["RequestRouter(tiers, ...)"] + + style Start fill:#1a5276,color:#fff + style Final fill:#196f3d,color:#fff + style Phase1 fill:#2e4053,color:#fff + style Phase2 fill:#2e4053,color:#fff +``` + +**凭证合并优先级**:Token Store(持久化) > config.yaml(显式配置)。确保用户通过 CLI 认证命令获取的凭证优先于配置文件中的硬编码值。 + +--- + +## 3.5 Proxy(代理模式)— 整体架构 + +> **经典出处**:GoF《Design Patterns》[[1]](#ref1) — 为其他对象提供一种代理以控制对这个对象的访问。 + +**应用位置**:整体架构 + +coding-proxy 本身即是一个代理服务: + +- 对外暴露与 Anthropic Messages API 完全兼容的 `POST /v1/messages` 接口 +- Claude Code 客户端只需将 `ANTHROPIC_BASE_URL` 指向代理地址 +- 代理在幕后完成后端选择、故障转移、模型映射、用量记录、请求标准化等增值逻辑 +- 支持流式(SSE `text/event-stream`)和非流式(JSON)两种响应模式 +- 额外透传 `/v1/messages/count_tokens` 端点至 Anthropic 主供应商 + +--- + +## 3.6 Composite(组合模式)— VendorTier + +> **经典出处**:GoF《Design Patterns》[[1]](#ref1) — 将对象组合成树形结构以表示"部分-整体"的层次结构。 + +**应用位置**:[`routing/tier.py`](../../src/coding/proxy/routing/tier.py) — `VendorTier` 数据类 + +**设计要点**: + +VendorTier 将多个正交关注点聚合为路由器的最小调度单元: + +```mermaid +classDiagram + class VendorTier { + <> + -vendor : BaseVendor + -circuit_breaker : CircuitBreaker | None + -quota_guard : QuotaGuard | None + -weekly_quota_guard : QuotaGuard | None + -retry_config : RetryConfig | None + -_rate_limit_deadline : float + -- + +name : str + +is_terminal : bool + +can_execute() bool + +can_execute_with_health_check() bool + +record_success(usage_tokens) + +record_failure(...) + +is_rate_limited : bool + } + + note for VendorTier "路由器的最小调度单元\n聚合 vendor + CB + QG + WQG + Retry + RL" + + style VendorTier fill:#1a5276,color:#fff +``` + +**关键方法**: + +| 方法 | 逻辑 | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------- | +| `name` | → `vendor.get_name()` | +| `is_terminal` | → `circuit_breaker is None`(终端层无故障转移) | +| `can_execute()` | CB.can_execute() AND QG.can_use_primary() AND WQG.can_use_primary() | +| `can_execute_with_health_check()` | 三层恢复门控:Rate Limit Deadline → Health Check → Cautious Probe | +| `record_success(usage_tokens)` | CB.record_success() + QG/WQG 探测恢复 + 用量记录 + 清除 RL deadline | +| `record_failure(is_cap_error, retry_after_seconds, rate_limit_deadline)` | CB.record_failure(+retry) + 若 cap error 则通知 QG/WQG + 更新 RL deadline | +| `is_rate_limited` | `_rate_limit_deadline > time.monotonic()` | + +--- + +## 3.7 State Machine(状态机模式)— QuotaGuard + + + +**应用位置**:[`routing/quota_guard.py`](../../src/coding/proxy/routing/quota_guard.py) — `QuotaGuard` 类 + +**设计要点**: + +基于滑动窗口的双态状态机,通过 Token 预算追踪主动避免触发上游配额限制: + +```mermaid +stateDiagram-v2 + [*] --> WITHIN_QUOTA + + WITHIN_QUOTA --> QUOTA_EXCEEDED : 窗口用量 >= budget × threshold%\n或检测到 cap error + note right of QUOTA_EXCEEDED : 超限状态 — 仅允许探测请求 + + QUOTA_EXCEEDED --> WITHIN_QUOTA : 窗口自然滑出 < threshold%\n或探测请求成功 + + WITHIN_QUOTA --> [*] + QUOTA_EXCEEDED --> [*] +``` + +**核心机制**: + +- **滑动窗口**:`deque[(timestamp, tokens)]`,`_expire()` 清除超出 `window_seconds` 的条目 +- **双窗口支持**:同一 `QuotaGuard` 类同时服务于日度 (`quota_guard`) 和周度 (`weekly_quota_guard`) 两个独立实例 +- **探测恢复**:QUOTA_EXCEEDED 状态下,每隔 `probe_interval_seconds` 放行一个探测请求 +- **cap error 模式**:由外部 `_is_cap_error()` 触发,不做预算自动恢复,仅允许探测恢复 +- **基线加载**:启动时从数据库加载历史用量,防止重启后误判配额状态 +- **线程安全**:所有状态变更通过 `threading.Lock` 保护 + +> **参数默认值**:参见 [配置参考 -- QuotaGuardConfig](./config-reference.md#elastic-params) + +--- + +## 3.8 Double-Check Locking(双重检查锁模式) + +**应用位置**: +- [`vendors/copilot.py`](../../src/coding/proxy/vendors/copilot.py) — `CopilotTokenManager` +- [`vendors/antigravity.py`](../../src/coding/proxy/vendors/antigravity.py) — `GoogleOAuthTokenManager` + +**设计要点**: + +两个 Token Manager 均采用相同的异步 DCL 模式,确保高并发下 token 交换仅执行一次: + +```python +# 快速路径(无锁) +if self._access_token and time.monotonic() < self._expires_at: + return self._access_token + +# 慢路径(加锁后二次检查) +async with self._lock: + if self._access_token and time.monotonic() < self._expires_at: + return self._access_token + await self._exchange() # 或 self._refresh() +``` + +| Token Manager | 认证流程 | 有效期 | 提前刷新余量 | +| ------------------------- | ------------------------------------------------------------------------- | -------- | ------------ | +| `CopilotTokenManager` | GitHub token -> GET `copilot_internal/v2/token` -> `token`/`access_token` | ~30 分钟 | 60 秒 | +| `GoogleOAuthTokenManager` | refresh_token -> POST oauth2.googleapis.com/token -> access_token | ~1 小时 | 120 秒 | + +两者均支持**被动刷新**:当后端返回 401/403 时,通过 `_on_error_status()` 调用 `invalidate()` 标记 token 失效,下次请求自动触发重新获取。若 `needs_reauth=True`,还会联动 `RuntimeReauthCoordinator` 触发后台重认证流程。 + +--- + +## 3.9 Facade(外观模式) + +> **经典出处**:GoF《Design Patterns》[[1]](#ref1) — 为子系统中的一组接口提供一个统一的高层接口。 + +**应用位置**:[`routing/router.py`](../../src/coding/proxy/routing/router.py) — `RequestRouter` 类 + +**设计要点**: + +`RequestRouter` 作为薄代理门面(Facade),将路由系统的复杂内部实现封装为简洁的公开接口,内部委托给三个正交分解的子组件: + +```mermaid +graph LR + RR["RequestRouter
(Facade)"] + + RE["_RouteExecutor
tier 迭代门控引擎"] + UR["UsageRecorder
用量/定价/证据"] + SM["RouteSessionManager
兼容性会话管理"] + + RR -->|"route_stream()"| RE + RR -->|"route_message()"| RE + RR -->|"set_pricing_table()"| UR + RR -->|"构造注入"| SM + + RE -->|"execute_stream()
execute_message()"| VT["VendorTier 链"] + UR -->|"record()
log_model_call()"| TL["TokenLogger"] + SM -->|"get_or_create_record()
apply_compat_context()
persist_session()"| CSS["CompatSessionStore"] +``` + +**委托关系**: + +| 公开方法 | 内部委托 | +| ------------------------------ | --------------------------------------------- | +| `route_stream(body, headers)` | -> `_executor.execute_stream(body, headers)` | +| `route_message(body, headers)` | -> `_executor.execute_message(body, headers)` | +| `set_pricing_table(table)` | -> `_recorder.set_pricing_table(table)` | +| `close()` | -> 遍历 `tiers` 调用 `tier.vendor.close()` | + +这种正交 decomposition 使得每个子组件可以独立演进和测试,同时 `RequestRouter` 保持对外接口稳定。 + +--- + +## 3.10 Adapter(适配器模式)— Vendor 层 + +> **经典出处**:GoF《Design Patterns》[[1]](#ref1) — 将一个类的接口转换成客户期望的另一个接口。 + +**应用位置**:[`vendors/`](../../src/coding/proxy/vendors/) — `BaseVendor` 及其具体实现 + +**设计要点**: + +每个 Vendor 子类充当 Adapter 角色,将异构的上游 API 适配为统一的 `BaseVendor` 接口: + +| Vendor | 上游协议 | 适配行为 | +| ----------------------------- | --------------------------- | ------------------------------------------------------------- | +| `AnthropicVendor` | Anthropic Messages API | 透传(近乎零适配开销) | +| `CopilotVendor` | OpenAI Chat Completions API | 请求体/响应体双向格式转换 + token 注入 | +| `AntigravityVendor` | Gemini GenerateContent API | Anthropic ↔ Gemini 双向格式转换 + SSE 流适配 + OAuth | +| `ZhipuVendor` 等 6 个原生兼容 | Anthropic-compatible API | 模型名映射 + API Key 认证头替换(继承 NativeAnthropicVendor) | + +此外,[`convert/`](../../src/coding/proxy/convert/) 模块提供独立的纯函数适配器层,支持三向格式转换: + +| 转换方向 | 模块 | 说明 | +| --------------------------- | ----------------------------------------------------------------------------------------- | ------------------ | +| Anthropic -> Gemini | [`convert/anthropic_to_gemini.py`](../../src/coding/proxy/convert/anthropic_to_gemini.py) | 请求格式转换 | +| Gemini -> Anthropic | [`convert/gemini_to_anthropic.py`](../../src/coding/proxy/convert/gemini_to_anthropic.py) | 响应格式转换 | +| Gemini SSE -> Anthropic SSE | [`convert/gemini_sse_adapter.py`](../../src/coding/proxy/convert/gemini_sse_adapter.py) | 流式事件重构 | +| Anthropic -> OpenAI | [`convert/anthropic_to_openai.py`](../../src/coding/proxy/convert/anthropic_to_openai.py) | Copilot 请求适配 | +| OpenAI -> Anthropic | [`convert/openai_to_anthropic.py`](../../src/coding/proxy/convert/openai_to_anthropic.py) | Copilot 响应逆适配 | + +--- + +## 3.11 Capability-Based Routing(基于能力的路由) + +**应用位置**: +- [`routing/error_classifier.py`](../../src/coding/proxy/routing/error_classifier.py) — `build_request_capabilities()` +- [`vendors/base.py`](../../src/coding/proxy/vendors/base.py) — `BaseVendor.supports_request()` / `get_capabilities()` / `make_compatibility_decision()` +- [`model/vendor.py`](../../src/coding/proxy/model/vendor.py) — `RequestCapabilities` / `VendorCapabilities` / `CapabilityLossReason` + +**设计要点**: + +基于请求能力画像与供应商能力声明的正交匹配矩阵,在路由阶段即排除无法无损承接请求的层级: + +| 维度 \ 能力 | `tools` | `thinking` | `images` | `metadata` | `vend_tools` | +| :----------- | :-----: | :--------: | :------: | :--------: | :----------: | +| **tools** | ✅ OK | — | — | — | ❌ skip | +| **thinking** | — | ✅ OK | — | — | — | +| **images** | — | — | ✅ OK | — | — | +| **metadata** | — | — | — | ✅ OK | — | + +> ✅ = 可承接;❌ = `CapabilityLossReason`(跳过此 tier) + +**匹配流程**: + +1. **能力硬过滤**:`supports_request(request_caps)` -> 返回 `(bool, list[CapabilityLossReason])` + - 若有任何 `CapabilityLossReason`,直接跳过该 tier 并记录原因 +2. **兼容性决策**:`make_compatibility_decision(canonical_request)` -> 返回 `CompatibilityDecision` + - `NATIVE`:完全原生支持,直接放行 + - `SIMULATED`:可通过模拟/投影方式支持(如 thinking 剥离、tool_calling 降级) + - `UNSAFE`:存在不可弥补的语义缺失,跳过该 tier + +**CompatibilityDecision 三态决策矩阵**: + +| 请求特征 | NATIVE | SIMULATED | UNSAFE | +| -------------------------- | ------ | ----------------------- | ------ | +| thinking + 支持 thinking | OK | | | +| thinking + 不支持 thinking | | thinking_simulation | X | +| tools + 支持 tools | OK | | | +| tools + 不支持 tools | | tool_calling_simulation | X | +| metadata + 支持 metadata | OK | | | +| metadata + 不支持 metadata | | metadata_projection | X | +| json_output + 支持 | OK | | | +| json_output + 不支持 | | json_output_projection | X | + +--- + +## 3.12 Retry with Full Jitter(带完全抖动的重试模式) + + + +> **参考**:M. Nygard《Release It!》第 5 章[[3]](#ref3);AWS Architecture Center "Retry Pattern"[[4]](#ref4) + +**应用位置**:[`routing/retry.py`](../../src/coding/proxy/routing/retry.py) — `RetryConfig` / `calculate_delay()` + +**设计要点**: + +传输层重试策略处理瞬态网络故障,与 CircuitBreaker 形成正交互补: + +| 维度 | Retry | CircuitBreaker | +| ------------ | ------------------------------------- | ---------------------- | +| 处理范围 | 单次请求内的瞬态抖动 | 跨请求的持续故障 | +| 恢复时间尺度 | 秒级(500ms ~ 5s) | 分钟级(300s ~ 3600s) | +| 触发条件 | TimeoutException / ConnectError / 5xx | 连续 N 次失败 | +| 失败贡献 | 每次 retry 失败仅向 CB 贡献 1 次计数 | 累积计数触发 OPEN | + +**Full Jitter 计算**: + +$$ +\text{delay} = \text{random}\left(0,\; \min\left(\text{initial\_delay} \times \text{backoff}^{\text{attempt}},\; \text{max\_delay}\right)\right) +$$ + +**可重试异常判定**(`is_retryable_error()`): + +| 异常类型 | 可重试 | 原因 | +| ----------------------------- | ------ | -------------------------- | +| `httpx.TimeoutException` | OK | 瞬态超时 | +| `httpx.ConnectError` | OK | 网络连接失败 | +| `httpx.HTTPStatusError` (5xx) | OK | 服务端瞬时错误 | +| `httpx.HTTPStatusError` (4xx) | NO | 客户端错误(不应重试) | +| `TokenAcquireError` | NO | 认证层错误(应触发重认证) | + +> **参数默认值**:参见 [配置参考 -- RetryConfig](./config-reference.md#elastic-params) + +--- + +## 3.13 Rate Limit Deadline Tracking(速率限制截止追踪) + +**应用位置**: +- [`routing/rate_limit.py`](../../src/coding/proxy/routing/rate_limit.py) — `RateLimitInfo` / `parse_rate_limit_headers()` / `compute_rate_limit_deadline()` +- [`routing/tier.py`](../../src/coding/proxy/routing/tier.py) — `VendorTier._rate_limit_deadline` / `is_rate_limited` / `can_execute_with_health_check()` + +**设计要点**: + +从 HTTP 429/403 响应头精确解析上游速率限制信息,并转化为 monotonic 时间戳用于门控: + +**解析的信息源**: + +| Header | 格式 | 含义 | +| ------------------------------------ | ----------------- | -------------------- | +| `Retry-After` | 秒数或 HTTP Date | 标准速率限制恢复时间 | +| `anthropic-ratelimit-requests-reset` | ISO 8601 datetime | 请求计数重置时间 | +| `anthropic-ratelimit-tokens-reset` | ISO 8601 datetime | Token 配额重置时间 | + +**计算策略**: + +- 取所有可用信号的最大值,并加 **10% 安全余量** +- `compute_effective_retry_seconds()` -> 返回相对秒数(供 CircuitBreaker 退避计算) +- `compute_rate_limit_deadline()` -> 返回绝对 monotonic 时间戳(供 VendorTier 精确门控) + +**三层恢复门控集成**: + +`can_execute_with_health_check()` 方法实现了三层渐进式恢复机制: + +```mermaid +flowchart TD + Entry["can_execute_with_health_check()"] --> L1{"第一层
Rate Limit Deadline"} + L1 -- "_deadline > now?
YES" --> Reject["直接拒绝
(~0 开销)"] + L1 -- "NO" --> L2{"第二层
CB + QG + WQG
综合判断"} + + L2 -- "全部拒绝" --> Skip["直接跳过"] + L2 -- "至少一个允许" --> ProbeCheck{"探测场景?
CB=HALF_OPEN 或
QG=QUOTA_EXCEEDED"} + + ProbeCheck -- "否" --> Result["返回综合结果"] + ProbeCheck -- "是" --> L3{"第三层
Health Check
(慢路径)"} + + L3 -- "check_health()
= False" --> FailRec["record_failure()
→ 拒绝"] + L3 -- "= True" --> Pass["放行
(Cautious Probe)"] + + style Entry fill:#1a5276,color:#fff + style Reject fill:#7b241c,color:#fff + style Pass fill:#196f3d,color:#fff + style FailRec fill:#7b241c,color:#fff + style Skip fill:#7b241c,color:#fff + style Result fill:#2874a6,color:#fff +``` + +--- + +## 参考文献 + +[1] E. Gamma, R. Helm, R. Johnson, and J. Vlissides, *Design Patterns: Elements of Reusable Object-Oriented Software*, Addison-Wesley, 1994. + +[2] M. Fowler, "CircuitBreaker," *martinfowler.com*, 2014. + +[3] M. Nygard, *Release It!: Design and Deploy Production-Ready Software*, 2nd ed., Pragmatic Bookshelf, 2018. + +[4] AWS Architecture Center, "Retry Pattern," *docs.aws.amazon.com*, 2022. diff --git a/docs/arch/routing.md b/docs/arch/routing.md new file mode 100644 index 0000000..bb548ab --- /dev/null +++ b/docs/arch/routing.md @@ -0,0 +1,180 @@ +# 路由模块(routing/) + +> **路径约定**:本文档中模块路径均相对于 `src/coding/proxy/`。 +> +> **定位**:从 `framework.md` 提取,详述 N-tier 链式路由核心的 12 个子模块。 + +[TOC] + +--- + +## 1. 模块总览 + +路由模块正交分解为 12 个子模块,每个子模块职责单一: + +| 子模块 | 文件 | 职责 | +| -------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------- | +| **router** | [`routing/router.py`](../../src/coding/proxy/routing/router.py) | `RequestRouter` — 路由门面(Facade) | +| **executor** | [`routing/executor.py`](../../src/coding/proxy/routing/executor.py) | `_RouteExecutor` — tier 迭代门控引擎 | +| **tier** | [`routing/tier.py`](../../src/coding/proxy/routing/tier.py) | `VendorTier` — 最小调度单元(Composite) | +| **circuit_breaker** | [`routing/circuit_breaker.py`](../../src/coding/proxy/routing/circuit_breaker.py) | `CircuitBreaker` — 熔断器状态机 | +| **quota_guard** | [`routing/quota_guard.py`](../../src/coding/proxy/routing/quota_guard.py) | `QuotaGuard` — 配额守卫状态机 | +| **retry** | [`routing/retry.py`](../../src/coding/proxy/routing/retry.py) | `RetryConfig` / `calculate_delay()` — 重试策略 | +| **rate_limit** | [`routing/rate_limit.py`](../../src/coding/proxy/routing/rate_limit.py) | `RateLimitInfo` / 解析与截止时间计算 | +| **error_classifier** | [`routing/error_classifier.py`](../../src/coding/proxy/routing/error_classifier.py) | 请求能力画像 + 语义拒绝判定 | +| **model_mapper** | [`routing/model_mapper.py`](../../src/coding/proxy/routing/model_mapper.py) | `ModelMapper` — 三级优先级匹配链 | +| **usage_recorder** | [`routing/usage_recorder.py`](../../src/coding/proxy/routing/usage_recorder.py) | 用量记录、定价计算与证据构建 | +| **usage_parser** | [`routing/usage_parser.py`](../../src/coding/proxy/routing/usage_parser.py) | SSE chunk Token 用量提取 | +| **session_manager** | [`routing/session_manager.py`](../../src/coding/proxy/routing/session_manager.py) | 兼容性会话生命周期管理 | + +--- + +## 2. VendorTier + +**文件**:[`routing/tier.py`](../../src/coding/proxy/routing/tier.py) + +```python +@dataclass +class VendorTier: + vendor: BaseVendor + circuit_breaker: CircuitBreaker | None = None + quota_guard: QuotaGuard | None = None + weekly_quota_guard: QuotaGuard | None = None + retry_config: RetryConfig | None = None + _rate_limit_deadline: float = 0.0 +``` + +**向后兼容别名**:`BackendTier = VendorTier`(deprecated) + +**关键方法**: + +| 方法 | 逻辑 | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------- | +| `name` | → `vendor.get_name()` | +| `is_terminal` | → `circuit_breaker is None`(终端层无故障转移) | +| `can_execute()` | CB.can_execute() AND QG.can_use_primary() AND WQG.can_use_primary() | +| `can_execute_with_health_check()` | 三层恢复门控:Rate Limit Deadline → Health Check → Cautious Probe | +| `record_success(usage_tokens)` | CB.record_success() + QG/WQG 探测恢复 + 用量记录 + 清除 RL deadline | +| `record_failure(is_cap_error, retry_after_seconds, rate_limit_deadline)` | CB.record_failure(+retry) + 若 cap error 则通知 QG/WQG + 更新 RL deadline | +| `is_rate_limited` | `_rate_limit_deadline > time.monotonic()` | + +> **设计模式**:Composite 模式,参见 [设计模式 -- Composite](./design-patterns.md#composite) + +--- + +## 3. _RouteExecutor + +**文件**:[`routing/executor.py`](../../src/coding/proxy/routing/executor.py) + +统一的 tier 迭代门控引擎,封装 `execute_stream()` / `execute_message()` 共享的循环逻辑。 + +| 方法 | 说明 | +| ------------------------------------------------------------------ | -------------------------------------------------------------------- | +| `execute_stream(body, headers)` | 流式路由主循环,yield `(chunk, vendor_name)` | +| `execute_message(body, headers)` | 非流式路由主循环,返回 `VendorResponse` | +| `_try_gate_tier(tier, is_last, caps, canonical, session, reasons)` | 单 tier 门控:能力匹配 → 兼容性决策 → 上下文应用 → 健康检查 | +| `_handle_token_error(tier, exc, is_last, failed_name)` | TokenAcquireError 处理 + reauth 触发 | +| `_handle_http_error(tier, exc, ...)` | HTTP 错误处理:语义拒绝 / cap error / rate limit 解析 / failure 记录 | +| `_is_cap_error(resp)` | 静态方法:检测 429/403 + 配额关键词 | + +--- + +## 4. UsageRecorder + +**文件**:[`routing/usage_recorder.py`](../../src/coding/proxy/routing/usage_recorder.py) + +| 方法 | 说明 | +| ------------------------------------------------------------------------ | -------------------------------------------------------- | +| `set_pricing_table(table)` | 注入 PricingTable(lifespan 启动时调用) | +| `build_usage_info(usage_dict)` | 从原始 dict 构建结构化 UsageInfo | +| `log_model_call(vendor, model_requested, model_served, duration, usage)` | 输出 ModelCall 级别 Access Log(含定价) | +| `record(vendor, ..., evidence_records)` | 持久化用量到 TokenLogger + evidence 记录(Copilot 专用) | +| `build_nonstream_evidence_records(...)` | 构建非流式证据记录 | + +--- + +## 5. RouteSessionManager + +**文件**:[`routing/session_manager.py`](../../src/coding/proxy/routing/session_manager.py) + +| 方法 | 说明 | +| ---------------------------------------------------------- | ------------------------------------- | +| `get_or_create_record(session_key, trace_id)` | 获取或创建会话记录 | +| `apply_compat_context(tier, canonical, decision, session)` | 构建 CompatibilityTrace 并注入 vendor | +| `persist_session(trace, session)` | 持久化会话状态到 CompatSessionStore | + +--- + +## 6. Error Classifier + +**文件**:[`routing/error_classifier.py`](../../src/coding/proxy/routing/error_classifier.py) + +| 函数 | 说明 | +| --------------------------------------------------------------- | ------------------------------------------------------ | +| `build_request_capabilities(body)` | 从请求体提取能力画像(tools/thinking/images/metadata) | +| `is_semantic_rejection(status_code, error_type, error_message)` | 判断是否为语义拒绝(400 + 特定模式) | +| `extract_error_payload_from_http_status(exc)` | 从 HTTPStatusError 安全提取 JSON payload | + +--- + +## 7. Rate Limit + +**文件**:[`routing/rate_limit.py`](../../src/coding/proxy/routing/rate_limit.py) + +| 函数/类 | 说明 | +| ------------------------------------------------------- | ------------------------------------------------------------------------ | +| `RateLimitInfo` | 数据类:retry_after / requests_reset_at / tokens_reset_at / is_cap_error | +| `parse_rate_limit_headers(headers, status, error_body)` | 从响应头解析所有速率限制信号 | +| `compute_effective_retry_seconds(info)` | 计算最保守恢复等待时间(相对秒数,+10% 余量) | +| `compute_rate_limit_deadline(info)` | 计算最保守恢复截止时间(绝对 monotonic 时间戳,+10% 余量) | + +--- + +## 8. Retry + +**文件**:[`routing/retry.py`](../../src/coding/proxy/routing/retry.py) + +| 函数/类 | 说明 | +| ------------------------------- | ----------------------------------------------------------------------------------- | +| `RetryConfig` | 数据类:max_retries / initial_delay_ms / max_delay_ms / backoff_multiplier / jitter | +| `is_retryable_error(exc)` | 判断异常是否值得重试 | +| `is_retryable_status(code)` | 判断状态码是否值得重试(5xx) | +| `calculate_delay(attempt, cfg)` | 计算第 N 次重试延迟(含 Full Jitter) | + +> **参数默认值**:参见 [配置参考 -- RetryConfig](./config-reference.md#elastic-params) + +--- + +## 9. CircuitBreaker + +**文件**:[`routing/circuit_breaker.py`](../../src/coding/proxy/routing/circuit_breaker.py) + +状态机:CLOSED → OPEN → HALF_OPEN → CLOSED,含指数退避。 + +> **参数默认值**:参见 [配置参考 -- CircuitBreakerConfig](./config-reference.md#elastic-params) +> +> **设计语义**:参见 [设计模式 -- Circuit Breaker](./design-patterns.md#circuit-breaker) + +--- + +## 10. QuotaGuard + +**文件**:[`routing/quota_guard.py`](../../src/coding/proxy/routing/quota_guard.py) + +基于滑动窗口的双态状态机(WITHIN_QUOTA / QUOTA_EXCEEDED)。 + +**公共方法**: + +| 方法 | 说明 | +| ----------------------------- | ------------------------------------------------ | +| `can_use_primary()` | 综合判断是否允许使用此后端 | +| `record_usage(tokens)` | 记录 Token 用量到滑动窗口 | +| `record_primary_success()` | 探测成功后恢复为 WITHIN_QUOTA | +| `notify_cap_error()` | 外部通知检测到 cap 错误,强制进入 QUOTA_EXCEEDED | +| `load_baseline(total_tokens)` | 从数据库加载历史用量基线 | +| `reset()` | 手动重置为 WITHIN_QUOTA | +| `get_info()` | 获取状态信息(供 `/api/status` 使用) | + +> **参数默认值**:参见 [配置参考 -- QuotaGuardConfig](./config-reference.md#elastic-params) +> +> **设计语义**:参见 [设计模式 -- QuotaGuard State Machine](./design-patterns.md#quota-guard) diff --git a/docs/arch/testing.md b/docs/arch/testing.md new file mode 100644 index 0000000..2c3cf78 --- /dev/null +++ b/docs/arch/testing.md @@ -0,0 +1,114 @@ +# 测试策略 + +> **定位**:从 `framework.md` 提取,详述测试覆盖与工具链。 + +[TOC] + +--- + +## 1. 测试工具 + +- **pytest** (>=9.0) — 测试框架 +- **pytest-asyncio** (>=1.3) — 异步测试支持 +- **monkeypatch** — 环境变量和工作目录隔离 +- **tmp_path** — 临时文件测试 +- **respx** — httpx Mock(用于 vendor 集成测试) + +--- + +## 2. 测试覆盖 + +### 2.1 供应商(vendors) + +| 测试文件 | 覆盖范围 | +| -------------------------- | --------------------------------------------------------------- | +| `test_vendors.py` | 请求头过滤、模型映射、故障转移判断、数据类默认值 | +| `test_copilot.py` | CopilotTokenManager 交换/缓存/过期/失效、CopilotVendor 请求准备 | +| `test_antigravity.py` | GoogleOAuthTokenManager 刷新/缓存/过期/失效、格式转换+token注入 | +| `test_native_vendors.py` | NativeAnthropicVendor 基类行为、401 归一化、子类继承 | +| `test_zhipu.py` | ZhipuVendor 特定行为 | +| `test_mixins.py` | TokenBackendMixin 行为 | +| `test_token_manager.py` | BaseTokenManager 抽象行为 | +| `test_copilot_models.py` | CopilotModelResolver 模型解析与误导向处理 | +| `test_copilot_urls.py` | Copilot URL 工具函数 | +| `test_vendor_streaming.py` | Vendor 流式响应行为 | + +### 2.2 路由(routing) + +| 测试文件 | 覆盖范围 | +| -------------------------- | ---------------------------------------------------------------------- | +| `test_circuit_breaker.py` | 状态转换(CLOSED→OPEN→HALF_OPEN→CLOSED)、恢复超时、指数退避、手动重置 | +| `test_quota_guard.py` | 配额守卫状态机、预算追踪、探测机制、基线加载 | +| `test_model_mapper.py` | 精确匹配、正则匹配、Glob 匹配、默认回退、空规则集 | +| `test_tier.py` | VendorTier 可执行判断、成功/失败记录、终端判定、三层门控、RL deadline | +| `test_router_chain.py` | N-tier 链式路由(2/3/4-tier 降级、CB/QG 跳过、流式/非流式、连接异常) | +| `test_router_executor.py` | _RouteExecutor 门控逻辑、能力匹配、兼容性决策、语义拒绝 | +| `test_error_classifier.py` | 请求能力画像提取、语义拒绝判定、错误 payload 解析 | +| `test_rate_limit.py` | Rate limit header 解析、deadline 计算、cap error 检测 | +| `test_retry.py` | RetryConfig 参数、delay 计算、可重试异常判定 | +| `test_usage_recorder.py` | UsageRecorder 用量构建、定价日志、evidence 记录 | + +### 2.3 配置(config) + +| 测试文件 | 覆盖范围 | +| ----------------------- | ---------------------------------------------------------------------- | +| `test_config_loader.py` | 配置文件搜索优先级、环境变量展开、缺失文件处理、vendors 格式解析 | +| `test_config_init.py` | 配置模块初始化与 re-export 验证 | +| `test_schema.py` | ProxyConfig 校验、legacy 迁移、tiers 引用校验、vendor 专属字段 warning | + +### 2.4 格式转换(convert) + +| 测试文件 | 覆盖范围 | +| ---------------------------------- | ------------------------------------------------------------------------- | +| `test_convert_request.py` | Anthropic→Gemini 请求转换(文本、多轮、system、图片、工具、参数映射) | +| `test_convert_response.py` | Gemini→Anthropic 响应转换(文本、多部件、usage 提取、finishReason 映射) | +| `test_convert_sse.py` | Gemini SSE→Anthropic SSE 流适配(单/多 chunk、各 finishReason、边界情况) | +| `test_copilot_convert_request.py` | Anthropic→OpenAI 请求格式转换 | +| `test_copilot_convert_response.py` | OpenAI→Anthropic 响应格式转换 | + +### 2.5 数据模型(model) + +| 测试文件 | 覆盖范围 | +| ------------------------- | --------------------------------------------------- | +| `test_model_vendor.py` | UsageInfo/VendorResponse/RequestCapabilities 数据类 | +| `test_model_compat.py` | CanonicalRequest/CompatibilityDecision 数据模型 | +| `test_model_constants.py` | 常量定义与使用 | +| `test_model_pricing.py` | ModelPricingEntry 校验、币种一致性 | +| `test_model_token.py` | Token 相关模型 | +| `test_model_auth.py` | 认证相关模型 | + +### 2.6 认证(auth) + +| 测试文件 | 覆盖范围 | +| ------------------------ | ------------------------------------------------- | +| `test_runtime_reauth.py` | RuntimeReauthCoordinator 状态机、幂等触发、锁保护 | +| `test_auto_login.py` | 自动登录流程 | + +### 2.7 流式处理(streaming) + +| 测试文件 | 覆盖范围 | +| ------------------------------------ | ------------------------ | +| `test_streaming_anthropic_compat.py` | Anthropic 流式兼容层行为 | + +### 2.8 服务端与 CLI(server/cli) + +| 测试文件 | 覆盖范围 | +| ---------------------------- | ------------------------------------------------------- | +| `test_app_routes.py` | FastAPI 路由端点测试 | +| `test_request_normalizer.py` | 请求标准化:私有块清洗、tool_use_id 重写、fatal_reasons | +| `test_cli_usage.py` | CLI 用量查询命令 | +| `test_banner.py` | CLI Banner 显示 | +| `test_logging_dual_write.py` | 日志双写机制 | + +### 2.9 其他 + +| 测试文件 | 覆盖范围 | +| ---------------------- | ---------------------------------------------------------------- | +| `test_pricing.py` | PricingTable 加载、单价查询(精确+规范化)、费用计算、币种一致性 | +| `test_token_logger.py` | 用量记录、窗口查询、按供应商/模型过滤、evidence 记录 | +| `test_compat.py` | CanonicalRequest 构建、session_key 派生 | +| `test_parse_usage.py` | 用量解析工具函数 | +| `test_currency.py` | 币种检测与转换 | +| `test_types.py` | 公共类型定义 | +| `test_time_range.py` | 时间范围工具 | +| `test_tiers_config.py` | Tiers 配置验证 | diff --git a/docs/arch/vendors.md b/docs/arch/vendors.md new file mode 100644 index 0000000..2ec79ad --- /dev/null +++ b/docs/arch/vendors.md @@ -0,0 +1,404 @@ +# 供应商模块(vendors/) + +> 路径约定:相对于 `src/coding/proxy/` +> 定位:从 [framework.md](./framework.md) 提取,详述供应商分类体系与各供应商实现。 + +[TOC] + +## 1. 供应商分类体系 + +供应商模块按协议适配深度分为三个层级: + +### Class 1: 直接 Anthropic(Direct) + +- **基类**: [BaseVendor](../../src/coding/proxy/vendors/base.py)(直接子类) +- **供应商**: `AnthropicVendor` — 零适配,直接透传 +- **能力**: 全能力支持,客户端 OAuth token 原样转发 + +### Class 2: 协议转换(Protocol Conversion) + +- **基类**: `BaseVendor` + [TokenBackendMixin](../../src/coding/proxy/vendors/mixins.py) +- **供应商**: + - `CopilotVendor` — Anthropic ↔ OpenAI 双向协议转换 + token 注入 + - `AntigravityVendor` — Anthropic ↔ Gemini 双向协议转换 + SSE 适配 + OAuth2 +- **能力**: 部分能力支持(因供应商而异,通过 `CompatibilityProfile` 声明) + +### Class 3: 原生 Anthropic 兼容(Native Anthropic Compatible) + +- **基类**: [NativeAnthropicVendor](../../src/coding/proxy/vendors/native_anthropic.py)(继承 `BaseVendor`) +- **供应商**: `ZhipuVendor`、`MinimaxVendor`、`KimiVendor`、`DoubaoVendor`、`XiaomiVendor`、`AlibabaVendor` +- **适配方式**: 薄透传 — 仅模型名映射 + API Key 认证头替换 + 401 错误归一化 +- **能力**: 全 NATIVE 能力画像 + +### 类层次结构 + +```mermaid +classDiagram + direction TB + + class BaseVendor { + <> + +get_name() str + +_prepare_request(body, headers) tuple + +send_message(body, headers) VendorResponse + +send_message_stream(body, headers) AsyncIterator + +get_capabilities() VendorCapabilities + +supports_request(caps) tuple + +make_compatibility_decision(req) CompatibilityDecision + +map_model(model) str + +check_health() bool + +close() + } + + class TokenBackendMixin { + +_on_error_status(status_code) + +check_health() bool + +_get_token_diagnostics() dict + } + + class NativeAnthropicVendor { + _vendor_name: str 子类覆写 + _display_name: str 子类覆写 + +_prepare_request(body, headers) tuple + +send_message(body, headers) VendorResponse + +send_message_stream(body, headers) AsyncIterator + } + + class AnthropicVendor { + +get_name() str + } + + class CopilotVendor { + +get_name() str + +_get_endpoint() str + } + + class AntigravityVendor { + +get_name() str + +map_model(model) str + } + + class ZhipuVendor { + +_vendor_name = "zhipu" + } + class MinimaxVendor { + +_vendor_name = "minimax" + } + class KimiVendor { + +_vendor_name = "kimi" + } + class DoubaoVendor { + +_vendor_name = "doubao" + } + class XiaomiVendor { + +_vendor_name = "xiaomi" + } + class AlibabaVendor { + +_vendor_name = "alibaba" + } + + BaseVendor <|-- AnthropicVendor : Class 1 直接透传 + BaseVendor <|-- CopilotVendor : Class 2 协议转换 + BaseVendor <|-- AntigravityVendor : Class 2 协议转换 + BaseVendor <|-- NativeAnthropicVendor : Class 3 薄透传基类 + NativeAnthropicVendor <|-- ZhipuVendor + NativeAnthropicVendor <|-- MinimaxVendor + NativeAnthropicVendor <|-- KimiVendor + NativeAnthropicVendor <|-- DoubaoVendor + NativeAnthropicVendor <|-- XiaomiVendor + NativeAnthropicVendor <|-- AlibabaVendor + + TokenBackendMixin o-- CopilotVendor : mixin + TokenBackendMixin o-- AntigravityVendor : mixin +``` + +## 2. BaseVendor 抽象基类 + +文件: [vendors/base.py](../../src/coding/proxy/vendors/base.py) + +`BaseVendor` 采用**模板方法模式**,固化了请求发送的核心流程,子类仅通过抽象方法和钩子方法注入差异化逻辑。 + +### API 签名 + +```python +class BaseVendor(ABC): + def __init__(self, base_url: str, timeout_ms: int, failover_config: FailoverConfig | None = None) + + # ── 抽象方法(子类必须实现) ────────────────────────── + @abstractmethod + def get_name(self) -> str + + @abstractmethod + async def _prepare_request(self, body: dict, headers: dict) -> tuple[dict, dict] + + # ── 核心方法(模板固定流程) ────────────────────────── + async def send_message(self, body: dict, headers: dict) -> VendorResponse + async def send_message_stream(self, body: dict, headers: dict) -> AsyncIterator[bytes] + + # ── 能力与兼容性 ───────────────────────────────────── + def get_capabilities(self) -> VendorCapabilities + def supports_request(self, caps: RequestCapabilities) -> tuple[bool, list[CapabilityLossReason]] + def make_compatibility_decision(self, request: CanonicalRequest) -> CompatibilityDecision + def map_model(self, model: str) -> str # 默认恒等映射 + + # ── 钩子方法(子类可选覆写) ────────────────────────── + def _get_endpoint(self) -> str # 默认 /v1/messages + def _pre_send_check(self, body, headers) -> None # 快速失败(如缺少 API key) + def _normalize_error_response(self, status, resp, vendor_resp) -> VendorResponse + def _on_error_status(self, status_code: int) -> None # 错误状态码钩子 + async def check_health(self) -> bool # 默认 True + + # ── 故障转移 ───────────────────────────────────────── + def should_trigger_failover(self, status_code: int, body: dict | None) -> bool + + # ── 生命周期 ───────────────────────────────────────── + async def close() -> None +``` + +### 模板方法流程 + +`send_message` / `send_message_stream` 遵循固定流程: + +1. **`_pre_send_check()`** — 发送前快速失败检查 +2. **`_prepare_request()`** — 子类注入差异化逻辑(协议转换、认证注入等) +3. **HTTP 请求** — 通过惰性创建的 `httpx.AsyncClient` 发送 +4. **错误处理** — `_on_error_status()` 钩子 + `_normalize_error_response()` 归一化 +5. **响应解析** — 构建标准化的 `VendorResponse` + +## 3. NativeAnthropicVendor 基类 + +文件: [vendors/native_anthropic.py](../../src/coding/proxy/vendors/native_anthropic.py) + +适用于端点已完整支持 Anthropic Messages API 协议的供应商。仅执行两项最小适配: + +1. **模型名映射** — Claude 模型名 → 供应商模型名(完全委托 `ModelMapper`) +2. **认证头替换** — 剥离原始 `authorization` / `x-api-key`,注入供应商 `x-api-key` + +此外提供 **401 错误归一化**,覆盖流式和非流式两种响应路径,确保跨供应商错误格式一致性。 + +### 子类约定 + +子类仅需覆写两个类属性: + +| 属性 | 类型 | 说明 | +| --------------- | ----- | ------------------------------------------------------------------ | +| `_vendor_name` | `str` | 供应商标识名(`get_name()` 返回值 & `ModelMapper` 的 vendor 参数) | +| `_display_name` | `str` | 错误消息中的显示名(如 `"Zhipu"`、`"MiniMax"`) | + +### 能力声明 + +所有 `NativeAnthropicVendor` 子类自动继承全 NATIVE 能力画像: + +``` +thinking=NATIVE, tool_calling=NATIVE, tool_streaming=NATIVE, +mcp_tools=NATIVE, images=NATIVE, metadata=NATIVE, +json_output=NATIVE, usage_tokens=NATIVE +``` + +## 4. 供应商注册表 + +| 供应商 | 文件 | 协议 | 认证方式 | 能力 | +| ----------------- | ----------------------------------------------------------------------- | ------------------------ | --------------------------------- | ----------------------------------------------------- | +| AnthropicVendor | [vendors/anthropic.py](../../src/coding/proxy/vendors/anthropic.py) | Anthropic Messages API | OAuth token 透传 | 全能力支持 | +| CopilotVendor | [vendors/copilot.py](../../src/coding/proxy/vendors/copilot.py) | OpenAI Chat Completions | GitHub token → Copilot token 交换 | thinking 为 SIMULATED | +| AntigravityVendor | [vendors/antigravity.py](../../src/coding/proxy/vendors/antigravity.py) | Gemini GenerateContent | Google OAuth2 refresh_token | tool_streaming / metadata / usage_tokens 为 SIMULATED | +| ZhipuVendor | [vendors/zhipu.py](../../src/coding/proxy/vendors/zhipu.py) | Anthropic-compatible API | x-api-key | 全能力 (NATIVE) | +| MinimaxVendor | [vendors/minimax.py](../../src/coding/proxy/vendors/minimax.py) | Anthropic-compatible API | x-api-key | 全能力 (NATIVE) | +| KimiVendor | [vendors/kimi.py](../../src/coding/proxy/vendors/kimi.py) | Anthropic-compatible API | x-api-key | 全能力 (NATIVE) | +| DoubaoVendor | [vendors/doubao.py](../../src/coding/proxy/vendors/doubao.py) | Anthropic-compatible API | x-api-key | 全能力 (NATIVE) | +| XiaomiVendor | [vendors/xiaomi.py](../../src/coding/proxy/vendors/xiaomi.py) | Anthropic-compatible API | x-api-key | 全能力 (NATIVE) | +| AlibabaVendor | [vendors/alibaba.py](../../src/coding/proxy/vendors/alibaba.py) | Anthropic-compatible API | x-api-key | 全能力 (NATIVE) | + +> 各供应商的默认 `base_url`、超时等配置项详见 [config-reference.md](./config-reference.md)。 + +## 5. 数据类型 + +文件: [model/vendor.py](../../src/coding/proxy/model/vendor.py) + +### UsageInfo + +```python +@dataclass +class UsageInfo: + """一次调用的 Token 用量.""" + input_tokens: int = 0 + output_tokens: int = 0 + cache_creation_tokens: int = 0 + cache_read_tokens: int = 0 + request_id: str = "" +``` + +### VendorResponse + +```python +@dataclass +class VendorResponse: + """供应商响应结果.""" + status_code: int = 200 + usage: UsageInfo = field(default_factory=UsageInfo) + is_streaming: bool = False + raw_body: bytes = b"{}" + error_type: str | None = None + error_message: str | None = None + model_served: str | None = None + response_headers: dict[str, str] = field(default_factory=dict) +``` + +### CapabilityLossReason + +```python +class CapabilityLossReason(Enum): + """请求语义与供应商能力不匹配的原因.""" + TOOLS = "tools" + THINKING = "thinking" + IMAGES = "images" + VENDOR_TOOLS = "vendor_tools" + METADATA = "metadata" +``` + +### RequestCapabilities + +```python +@dataclass(frozen=True) +class RequestCapabilities: + """一次请求实际使用到的能力画像.""" + has_tools: bool = False + has_thinking: bool = False + has_images: bool = False + has_metadata: bool = False +``` + +### VendorCapabilities + +```python +@dataclass(frozen=True) +class VendorCapabilities: + """供应商能力声明.""" + supports_tools: bool = True + supports_thinking: bool = True + supports_images: bool = True + emits_vendor_tool_events: bool = False + supports_metadata: bool = True +``` + +### NoCompatibleVendorError + +```python +class NoCompatibleVendorError(RuntimeError): + """当前请求没有可安全承接的供应商.""" + def __init__(self, message: str, *, reasons: list[str] | None = None) -> None +``` + +## 6. 辅助模块 + +### TokenBackendMixin + +文件: [vendors/mixins.py](../../src/coding/proxy/vendors/mixins.py) + +为基于 token 的供应商(Copilot、Antigravity)提供共享行为: + +- `_on_error_status()` — 401/403 时自动调用 `token_manager.invalidate()` 触发被动刷新 +- `check_health()` — 基于 token 可获取性的健康检查 +- `_get_token_diagnostics()` — 收集 token 管理、模型解析、请求适配等诊断信息 + +使用方式:通过 Python Mixin 模式与 `BaseVendor` 组合,需在 `__init__` 中显式调用 `TokenBackendMixin.__init__(self, token_manager)`。 + +### BaseTokenManager + +文件: [vendors/token_manager.py](../../src/coding/proxy/vendors/token_manager.py) + +Token 缓存与自动刷新的抽象基类,提供 **DCL(Double-Check Locking)** 并发安全骨架: + +- `get_token()` — 获取有效 token(带缓存 + 自动刷新 + DCL 并发安全) +- `invalidate()` — 标记当前 token 失效(触发下次请求时被动刷新) +- `_acquire()` — 抽象方法,子类实现具体 token 获取逻辑 + +子类仅需实现 `_acquire()` 返回 `(access_token, expires_in_seconds)` 元组。内置 `_REFRESH_MARGIN = 60s` 提前刷新余量。 + +### CopilotTokenManager + +文件: [vendors/copilot_token_manager.py](../../src/coding/proxy/vendors/copilot_token_manager.py) + +GitHub token → Copilot token 交换管理: + +- **流程**: `GitHub token` → `GET copilot_internal/v2/token` → `Copilot access_token` +- **有效期**: ~30 分钟,内置 60s 提前刷新余量 +- **诊断**: 维护 `CopilotExchangeDiagnostics` 记录最近一次交换详情 +- **热更新**: `update_github_token()` 支持运行时重认证后 token 替换 + +### GoogleOAuthTokenManager + +文件: [vendors/antigravity.py](../../src/coding/proxy/vendors/antigravity.py)(内嵌) + +Google OAuth2 refresh_token → access_token 刷新管理: + +- 继承 `BaseTokenManager`,实现 `_acquire()` 通过 Google OAuth2 token endpoint 刷新 access_token +- 内嵌于 `antigravity.py` 而非独立文件,因仅 AntigravityVendor 使用 + +### CopilotModelResolver + +文件: [vendors/copilot_models.py](../../src/coding/proxy/vendors/copilot_models.py) + +Copilot 模型目录管理与解析策略: + +- **目录缓存**: 维护 `CopilotModelCatalog`,基于可配置 TTL 判断新鲜度 +- **依赖倒置**: 通过 `request_fn` 回调注入 HTTP 请求能力,不直接持有 client 引用 +- **解析策略**: 优先配置规则显式映射 → 次级内部家族匹配策略(同家族优先,不跨家族降级) +- **错误处理**: `build_model_not_supported_response()` 构建标准化的模型不支持错误响应 + +## 7. 向后兼容别名 + +代码库从 "Backend" 命名体系迁移至 "Vendor" 命名体系,保留了以下别名(计划 v2 移除): + +| 旧名称 | 新名称 | 定义位置 | +| -------------------------- | ------------------------- | ----------------------------------------------------------------------- | +| `BaseBackend` | `BaseVendor` | [vendors/base.py](../../src/coding/proxy/vendors/base.py) | +| `NoCompatibleBackendError` | `NoCompatibleVendorError` | [vendors/base.py](../../src/coding/proxy/vendors/base.py) | +| `BackendCapabilities` | `VendorCapabilities` | [model/vendor.py](../../src/coding/proxy/model/vendor.py) | +| `BackendResponse` | `VendorResponse` | [model/vendor.py](../../src/coding/proxy/model/vendor.py) | +| `TierConfig` | `VendorConfig` | [config/routing.py](../../src/coding/proxy/config/routing.py) | +| `BackendType` | `VendorType` | [config/routing.py](../../src/coding/proxy/config/routing.py) | +| `AnthropicBackend` | `AnthropicVendor` | [vendors/anthropic.py](../../src/coding/proxy/vendors/anthropic.py) | +| `CopilotBackend` | `CopilotVendor` | [vendors/copilot.py](../../src/coding/proxy/vendors/copilot.py) | +| `AntigravityBackend` | `AntigravityVendor` | [vendors/antigravity.py](../../src/coding/proxy/vendors/antigravity.py) | +| `ZhipuBackend` | `ZhipuVendor` | [vendors/zhipu.py](../../src/coding/proxy/vendors/zhipu.py) | + +## 8. 添加新供应商指南 + +### Path A: 原生 Anthropic 兼容供应商(推荐,适用于简单场景) + +当新供应商的端点已完整支持 Anthropic Messages API 协议时,仅需薄透传: + +1. **创建子类** — 在 [vendors/](../../src/coding/proxy/vendors/) 目录下新建文件,继承 `NativeAnthropicVendor` + +```python +# vendors/example.py +from ..config.schema import FailoverConfig +from ..config.vendors import ExampleConfig +from ..routing.model_mapper import ModelMapper +from .native_anthropic import NativeAnthropicVendor + + +class ExampleVendor(NativeAnthropicVendor): + _vendor_name = "example" + _display_name = "Example" + + def __init__(self, config: ExampleConfig, model_mapper: ModelMapper, + failover_config: FailoverConfig | None = None) -> None: + super().__init__(config, model_mapper, failover_config) +``` + +2. **添加 `VendorType` 枚举值** — 在 [config/routing.py](../../src/coding/proxy/config/routing.py) 的 `VendorType` Literal 中追加 `"example"` +3. **添加供应商配置类** — 在 [config/schema.py](../../src/coding/proxy/config/schema.py) 或 [config/vendors.py](../../src/coding/proxy/config/vendors.py) 中定义 `ExampleConfig`(或复用已有的 `api_key` 字段) +4. **添加工厂分支** — 在 [server/factory.py](../../src/coding/proxy/server/factory.py) 的 `_create_vendor_from_config()` 中添加 `case "example":` 分支 +5. **添加配置条目** — 在 `config.yaml` 的 `vendors` 列表中添加对应条目 + +### Path B: 自定义协议供应商(适用于需要协议转换的场景) + +当新供应商使用非 Anthropic 协议(如 OpenAI、Gemini 等)时: + +1. **创建子类** — 在 [vendors/](../../src/coding/proxy/vendors/) 目录下新建文件,继承 `BaseVendor` +2. **实现抽象方法** — `get_name()` 和 `_prepare_request()` +3. **覆写可扩展方法** — 按需覆写 `map_model()`、`get_capabilities()`、`_get_endpoint()` 等;如需声明非默认兼容性画像则覆写 `get_compatibility_profile()` +4. **如需 token 管理** — 组合 `TokenBackendMixin`,实现 `BaseTokenManager` 子类 +5. **后续步骤** — 同 Path A 的 2-5 步 diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 8be7cea..6b35b38 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -209,15 +209,15 @@ graph TD CI 流水线中使用的工具及其版本均与项目实际配置严格对齐: -| 工具 | 版本 / 引用 | 来源 (Action) | 与项目配置的对齐关系 | -| -------------- | ------------ | ---------------------------------------- | -------------------------------------------------------------------------- | +| 工具 | 版本 / 引用 | 来源 (Action) | 与项目配置的对齐关系 | +| -------------- | ----------------------------------- | ---------------------------------------- | -------------------------------------------------------------------------- | | Python | `["3.12", "3.13", "3.14"]` (matrix) | `actions/setup-python@v5` | 对齐 [`pyproject.toml`](../pyproject.toml) 中 `requires-python = ">=3.12"` | -| uv | latest (v4) | `astral-sh/setup-uv@v4` | 项目强制包管理器(见 AGENTS.md 包管理规范) | -| build | latest | `uv pip install --system build` | PEP 517 构建前端,后端为 hatchling | -| twine | latest | `uv pip install --system twine` | 包元数据校验与上传工具 | -| Publish Action | `release/v1` | `pypa/gh-action-pypi-publish@release/v1` | PyPA 官方发布 Action[[3]](#ref3) | -| Checkout | v4 | `actions/checkout@v4` | 代码检出(`persist-credentials: false` 最小化凭据暴露) | -| GitHub Script | v7 | `actions/github-script@v7` | 用于 promote.yml 中的 Release 元数据查询与更新 | +| uv | latest (v4) | `astral-sh/setup-uv@v4` | 项目强制包管理器(见 AGENTS.md 包管理规范) | +| build | latest | `uv pip install --system build` | PEP 517 构建前端,后端为 hatchling | +| twine | latest | `uv pip install --system twine` | 包元数据校验与上传工具 | +| Publish Action | `release/v1` | `pypa/gh-action-pypi-publish@release/v1` | PyPA 官方发布 Action[[3]](#ref3) | +| Checkout | v4 | `actions/checkout@v4` | 代码检出(`persist-credentials: false` 最小化凭据暴露) | +| GitHub Script | v7 | `actions/github-script@v7` | 用于 promote.yml 中的 Release 元数据查询与更新 | ### 2.4 GitHub Environments 配置详情 @@ -458,12 +458,12 @@ flowchart TD #### Job 2:promote(执行提升) -| 步骤 | Action | 目的 | -| ---------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------- | -| ① Checkout | `actions/checkout@v4` with `ref: ${{ needs.validate.outputs.tag_name }}` | 从**精确的 git tag** 检出源码,确保确定性重建 | -| ② Update Release | `actions/github-script@v7` | 将 GitHub Release 更新为 `prerelease: false`, `make_latest: true` | -| ③ Build | Python [3.12, 3.13, 3.14] (matrix) + uv + build deps → `python -m build` → `twine check` | 复现与 `release.yml` 完全一致的构建过程 | -| ④ Publish | `pypa/gh-action-pypi-publish@release/v1` | 发布到 PyPI Production | +| 步骤 | Action | 目的 | +| ---------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| ① Checkout | `actions/checkout@v4` with `ref: ${{ needs.validate.outputs.tag_name }}` | 从**精确的 git tag** 检出源码,确保确定性重建 | +| ② Update Release | `actions/github-script@v7` | 将 GitHub Release 更新为 `prerelease: false`, `make_latest: true` | +| ③ Build | Python [3.12, 3.13, 3.14] (matrix) + uv + build deps → `python -m build` → `twine check` | 复现与 `release.yml` 完全一致的构建过程 | +| ④ Publish | `pypa/gh-action-pypi-publish@release/v1` | 发布到 PyPI Production | **为什么从 git tag 重建?** @@ -699,14 +699,14 @@ twine upload --repository-url https://test.pypi.org/legacy/ * \ CI 流水线中的工具版本选择并非随意,每一项都与项目配置严格对应: -| CI 配置 | 项目配置 | 对齐关系 | -| --------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------- | -| `python-version: "${{ matrix.python-version }}"` | `requires-python = ">=3.12"` in [`pyproject.toml`](../pyproject.toml) | CI 构建环境必须满足项目的最低 Python 版本要求(matrix: 3.12 / 3.13 / 3.14) | -| `hatchling.build` (build-backend) | `[build-system] requires = ["hatchling"]` | 构建后端声明必须一致 | -| `uv pip install --system` | AGENTS.md 强制使用 `uv` | GitHub Actions Runner 默认无激活的 virtualenv,需 `--system` 标志 | -| `retention-days: 14` | — | Artifact 保留两周,覆盖正常的验证窗口期(通常 1-3 天) | -| `attestations: false` (TestPyPI) | — | TestPyPI 平台不支持 PyPA attestations 功能,必须显式禁用否则报错 | -| `verbose: true` (TestPyPI) | — | 启用详细日志以便在 HTTP 400 错误时查看服务端响应体 | +| CI 配置 | 项目配置 | 对齐关系 | +| ------------------------------------------------ | --------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `python-version: "${{ matrix.python-version }}"` | `requires-python = ">=3.12"` in [`pyproject.toml`](../pyproject.toml) | CI 构建环境必须满足项目的最低 Python 版本要求(matrix: 3.12 / 3.13 / 3.14) | +| `hatchling.build` (build-backend) | `[build-system] requires = ["hatchling"]` | 构建后端声明必须一致 | +| `uv pip install --system` | AGENTS.md 强制使用 `uv` | GitHub Actions Runner 默认无激活的 virtualenv,需 `--system` 标志 | +| `retention-days: 14` | — | Artifact 保留两周,覆盖正常的验证窗口期(通常 1-3 天) | +| `attestations: false` (TestPyPI) | — | TestPyPI 平台不支持 PyPA attestations 功能,必须显式禁用否则报错 | +| `verbose: true` (TestPyPI) | — | 启用详细日志以便在 HTTP 400 错误时查看服务端响应体 | --- @@ -716,16 +716,16 @@ CI 流水线中的工具版本选择并非随意,每一项都与项目配置 [`.github/workflows/release.yml`](../.github/workflows/release.yml) 文件结构一览: -| 行范围 | 区块 | 内容摘要 | -| ------- | ----------------------- | ----------------------------------------------------------------------------- | -| 1–37 | Header 注释 | 架构说明、前置条件、参考文献 | -| 39 | `name` | `"Release / Publish to PyPI"` | -| 41–43 | `on:` | `release: types: [published]` | -| 45–46 | `permissions` | 根级别 `contents: read` | -| 48–50 | `concurrency` | 按 workflow+ref 分组,cancel-in-progress: true | +| 行范围 | 区块 | 内容摘要 | +| ------- | ----------------------- | ---------------------------------------------------------------------------------------------------- | +| 1–37 | Header 注释 | 架构说明、前置条件、参考文献 | +| 39 | `name` | `"Release / Publish to PyPI"` | +| 41–43 | `on:` | `release: types: [published]` | +| 45–46 | `permissions` | 根级别 `contents: read` | +| 48–50 | `concurrency` | 按 workflow+ref 分组,cancel-in-progress: true | | 52–91 | `jobs.build` | 构建作业:checkout → Python [3.12, 3.13, 3.14] (matrix) → uv → build → twine check → upload artifact | -| 93–123 | `jobs.publish-testpypi` | TestPyPI 发布(条件:`prerelease == true`) | -| 125–152 | `jobs.publish-pypi` | PyPI Production 发布(条件:`prerelease == false`) | +| 93–123 | `jobs.publish-testpypi` | TestPyPI 发布(条件:`prerelease == true`) | +| 125–152 | `jobs.publish-pypi` | PyPI Production 发布(条件:`prerelease == false`) | ### 8.2 promote.yml 结构索引 @@ -743,16 +743,16 @@ CI 流水线中的工具版本选择并非随意,每一项都与项目配置 ### 8.3 关键配置参数速查 -| 参数 | `release.yml` | `promote.yml` | 说明 | -| ----------------------------------- | ---------------------- | --------------- | ---------------------------------------- | -| `runs-on` | `ubuntu-latest` | `ubuntu-latest` | GitHub-hosted runner | -| `python-version` | `${{ matrix.python-version }}` | `${{ matrix.python-version }}` | 必须与 `requires-python` 一致(matrix: 3.12 / 3.13 / 3.14) | -| `timeout-minutes` (build) | `10` | N/A | 构建超时上限 | -| `timeout-minutes` (publish/promote) | `10` | `15` | 发布超时(promote 含重建步骤,预留更长) | -| `retention-days` | `14` | N/A | Artifact 保留天数 | -| `skip-existing` | `true` (testpypi job) | N/A | TestPyPI 幂等安全(重复发布不报错) | -| `attestations` | `false` (testpypi job) | N/A | TestPyPI 不支持,必须显式禁用 | -| `verbose` | `true` (testpypi job) | N/A | 启用详细日志便于排查 400 错误 | +| 参数 | `release.yml` | `promote.yml` | 说明 | +| ----------------------------------- | ------------------------------ | ------------------------------ | ----------------------------------------------------------- | +| `runs-on` | `ubuntu-latest` | `ubuntu-latest` | GitHub-hosted runner | +| `python-version` | `${{ matrix.python-version }}` | `${{ matrix.python-version }}` | 必须与 `requires-python` 一致(matrix: 3.12 / 3.13 / 3.14) | +| `timeout-minutes` (build) | `10` | N/A | 构建超时上限 | +| `timeout-minutes` (publish/promote) | `10` | `15` | 发布超时(promote 含重建步骤,预留更长) | +| `retention-days` | `14` | N/A | Artifact 保留天数 | +| `skip-existing` | `true` (testpypi job) | N/A | TestPyPI 幂等安全(重复发布不报错) | +| `attestations` | `false` (testpypi job) | N/A | TestPyPI 不支持,必须显式禁用 | +| `verbose` | `true` (testpypi job) | N/A | 启用详细日志便于排查 400 错误 | --- diff --git a/docs/framework.md b/docs/framework.md index e4023f5..179c419 100644 --- a/docs/framework.md +++ b/docs/framework.md @@ -2,7 +2,9 @@ > **路径约定**:本文档中模块路径均相对于 `src/coding/proxy/`,例如 `vendors/base.py` 指 `src/coding/proxy/vendors/base.py`。 > -> **版本**:v2 — 反映当前代码库实际架构(vendors 主架构 + 正交分解路由系统)。 +> **版本**:v3 — 正交分解架构:枢纽文档 + 专题子文档([`arch/`](./arch/))。 +> +> **专题子文档索引**:[设计模式](./arch/design-patterns.md) · [供应商模块](./arch/vendors.md) · [路由模块](./arch/routing.md) · [配置参考](./arch/config-reference.md) · [格式转换](./arch/convert.md) · [测试策略](./arch/testing.md) [TOC] @@ -24,7 +26,7 @@ Claude Code 作为日常 AI 编程助手,其底层依赖 Anthropic Messages AP - **GitHub Copilot**:提供 Anthropic 兼容(实际为 OpenAI Chat Completions 协议)的 Claude API 端点,通过 GitHub OAuth token / PAT 完成 token 交换 - **Google Antigravity Claude**:通过 Google Gemini/Vertex AI 端点提供 Claude 模型访问,需 Anthropic ↔ Gemini 双向格式转换 -- **智谱 (Zhipu)**:提供与 Anthropic 兼容的 GLM API 接口(`/api/anthropic`),作为终端兜底 +- **智谱 (Zhipu)** / **MiniMax** / **Kimi** / **豆包 (Doubao)** / **小米 (Xiaomi)** / **阿里 (Alibaba)**:提供原生 Anthropic 兼容端点,仅需模型映射 + API Key 替换 **coding-proxy 的核心诉求**:当任一后端不可用时,自动、无缝地沿 N-tier 层级链降级到下一个可用后端,对 Claude Code 客户端完全透明,用户无需手动干预。 @@ -68,6 +70,7 @@ graph TD Routes["routes.py
路由注册"] Factory["factory.py
Vendor/Tier 构建工厂"] Normalizer["request_normalizer.py
请求标准化"] + Dashboard["dashboard.py
状态面板"] end subgraph Router["RequestRouter (Facade)
routing/router.py"] @@ -106,8 +109,8 @@ graph TD G_WQG["WQG"] end - subgraph TN["VendorTier N: ZhipuVendor(终端)"] - Z_V["ZhipuVendor"] + subgraph TN["VendorTier N: 原生兼容终端
Zhipu / Minimax / Kimi / ..."] + Z_V["NativeAnthropicVendor 子类"] end Gate --> T0 @@ -118,7 +121,7 @@ graph TD A_V --> API_A["Anthropic API"] C_V --> API_C["GitHub Copilot API
(OpenAI Chat Completions)"] G_V --> API_G["Google Gemini API"] - Z_V --> API_Z["智谱 GLM API"] + Z_V --> API_Z["各原生兼容端点"] style Server fill:#1a5276,color:#fff style Router fill:#2874a6,color:#fff @@ -138,570 +141,40 @@ graph TD | **RL** | Rate Limit Deadline(速率限制截止) | [`routing/tier.py`](../src/coding/proxy/routing/tier.py) + [`routing/rate_limit.py`](../src/coding/proxy/routing/rate_limit.py) | | **Tier** | VendorTier(供应商层级) | [`routing/tier.py`](../src/coding/proxy/routing/tier.py) | -### 2.2 模块职责一览 - -| 模块 | 路径 | 职责 | -| ------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| **vendors** | [`vendors/`](../src/coding/proxy/vendors/) | **供应商适配器(主架构)**:`BaseVendor` 抽象基类 + 4 个具体实现(Anthropic/Copilot/Antigravity/Zhipu) | -| **backends** | [`backends/`](../src/coding/proxy/backends/) | 向后兼容层(遗留),类型别名指向 `vendors/` 模块 | -| **routing** | [`routing/`](../src/coding/proxy/routing/) | N-tier 链式路由核心(正交分解为 executor/tier/CB/QG/retry/rate_limit/error_classifier/session_manager/usage_recorder/usage_parser/model_mapper) | -| **compat** | [`compat/`](../src/coding/proxy/compat/) | 兼容性抽象系统:`CanonicalRequest` / `CompatibilityDecision` / `session_store` | -| **auth** | [`auth/`](../src/coding/proxy/auth/) | 认证系统:OAuth providers(GitHub Device Flow / Google OAuth2)/ runtime reauth / token store | -| **model** | [`model/`](../src/coding/proxy/model/) | 数据模型正交分解:vendor / compat / constants / pricing / token / backend | -| **config** | [`config/`](../src/coding/proxy/config/) | Pydantic v2 配置模型(正交拆分为 server/vendors/resiliency/routing/auth_schema)+ YAML 加载器 | -| **convert** | [`convert/`](../src/coding/proxy/convert/) | API 格式转换(Anthropic ↔ Gemini ↔ OpenAI 三向转换,含 SSE 流适配) | -| **logging** | [`logging/`](../src/coding/proxy/logging/) | Token 用量 SQLite 持久化、evidence 记录、统计查询与 Rich 格式化展示 | -| **server** | [`server/`](../src/coding/proxy/server/) | FastAPI 应用工厂与生命周期管理(正交拆分为 app.py/factory.py/routes.py/request_normalizer.py/responses.py) | -| **cli** | [`cli/`](../src/coding/proxy/cli/) | Typer 命令行入口(start/status/usage/reset/auth) | -| **streaming** | [`streaming/`](../src/coding/proxy/streaming/) | Anthropic 兼容流式处理 | -| **pricing** | [`pricing.py`](../src/coding/pricing.py) | 定价表(`PricingTable`):按 (vendor, model) 查询单价并计算费用 | - ---- - -## 3. 设计模式详解 - -本章涵盖 coding-proxy 中运用的 **13 种设计模式与工程模式**,按职责域正交分为三类:**创建型**(对象构建策略)、**结构型**(组件组织方式)、**行为型与并发**(运行时行为控制)。 - -```mermaid -mindmap - root((设计模式)) - 创建型 - Strategy + Factory - Template Method - 结构型 - Proxy :: 整体架构 - Adapter :: Vendor层 - Composite :: VendorTier - Facade :: RequestRouter - 行为型/并发 - Circuit Breaker - Priority Chain - State Machine :: QuotaGuard - Double-Check Locking - Capability Routing - Retry Full Jitter - Rate Limit Deadline -``` - -### 3.1 Template Method(模板方法模式) - -> **经典出处**:GoF《Design Patterns: Elements of Reusable Object-Oriented Software》[[1]](#ref1) — 定义算法骨架,将某些步骤延迟到子类实现。 - -**应用位置**:[`vendors/base.py`](../src/coding/proxy/vendors/base.py) — `BaseVendor` 抽象基类 - -**设计要点**: - -`BaseVendor` 定义了请求处理的算法骨架,将差异化的逻辑延迟到子类: - -```mermaid -classDiagram - direction TB - - class BaseVendor { - <> - +send_message() VendorResponse - +send_message_stream() AsyncIterator - +get_name()* str - +_prepare_request()* tuple - +map_model() str - +_get_endpoint() str - +_pre_send_check() - +_normalize_error_response() - +_on_error_status() - +get_capabilities() VendorCapabilities - +supports_request() tuple - +make_compatibility_decision() CompatibilityDecision - +check_health() bool - +should_trigger_failover() bool - +close() - } - - class AnthropicVendor { - _prepare_request() 过滤 hop-by-hop 头 - } - - class CopilotVendor { - _prepare_request() 过滤头+异步token注入 - _on_error_status() 401/403 token失效 - } - - class AntigravityVendor { - _prepare_request() Gemini格式转换+OAuth - check_health() OAuth有效性检查 - _on_error_status() 401/403 token失效 - } - - class ZhipuVendor { - _prepare_request() 模型映射+API Key - map_model() claude-* → glm-* - } - - BaseVendor <|-- AnthropicVendor - BaseVendor <|-- CopilotVendor - BaseVendor <|-- AntigravityVendor - BaseVendor <|-- ZhipuVendor - - style BaseVendor fill:#1a5276,color:#fff - style AnthropicVendor fill:#2874a6,color:#fff - style CopilotVendor fill:#2874a6,color:#fff - style AntigravityVendor fill:#2874a6,color:#fff - style ZhipuVendor fill:#7b241c,color:#fff -``` - -四个具体 Vendor 子类的差异化实现: - -| 方法 | AnthropicVendor | CopilotVendor | AntigravityVendor | ZhipuVendor | -| -------------------- | ------------------ | ------------------------ | ---------------------------------- | ------------------------------- | -| `_prepare_request()` | 过滤 hop-by-hop 头 | 过滤头 + 异步 token 注入 | Gemini 格式转换 + OAuth token | 模型映射 + API Key | -| `map_model()` | 恒等映射 | 恒等映射 | 恒等映射 | **覆写**(claude-* → glm-*) | -| `_get_endpoint()` | `/v1/messages` | `/v1/chat/completions` | `/{model}:generateContent` | `/api/anthropic/v1/messages` | -| `_on_error_status()` | 继承基类(空操作) | 401/403 token 失效 | 401/403 token 失效 | 继承基类(空操作) | -| `get_capabilities()` | 全部支持 | 不支持 thinking | 不支持 tools/thinking/metadata | 不支持 thinking/images/metadata | -| `check_health()` | 继承(True) | 继承(True) | **覆写**(OAuth token 有效性检查) | 继承(True) | - -### 3.2 Circuit Breaker(熔断器模式) - -> **经典出处**:Martin Fowler "CircuitBreaker" (2014)[[2]](#ref2);M. Nygard《Release It! Design and Deploy Production-Ready Software》第 5 章[[3]](#ref3) — 通过快速失败防止级联故障。 - -**应用位置**:[`routing/circuit_breaker.py`](../src/coding/proxy/routing/circuit_breaker.py) — `CircuitBreaker` 类 - -**状态机**: - -```mermaid -stateDiagram-v2 - [*] --> CLOSED - - CLOSED --> OPEN : 连续 N 次失败\n(failure_threshold) - note right of OPEN : 熔断状态 — 快速失败 - - OPEN --> HALF_OPEN : 超时恢复\n(recovery_timeout_seconds) - - HALF_OPEN --> CLOSED : 连续 M 次成功\n(success_threshold) - note right of HALF_OPEN : 试探状态 - - HALF_OPEN --> OPEN : 任意一次失败\n(退避 × 2) - - CLOSED --> [*] -``` - -**状态转换条件**: - -| 转换 | 条件 | 默认值 | -| ------------------ | --------------------------------------- | ------ | -| CLOSED → OPEN | 连续失败次数 ≥ `failure_threshold` | 3 次 | -| OPEN → HALF_OPEN | 距上次失败 ≥ `recovery_timeout_seconds` | 300 秒 | -| HALF_OPEN → CLOSED | 连续成功次数 ≥ `success_threshold` | 2 次 | -| HALF_OPEN → OPEN | 任意一次失败 | — | - -**指数退避 (Exponential Backoff)**:每次从 HALF_OPEN 回退到 OPEN 时,恢复等待时间翻倍(`recovery_timeout *= 2`),上限为 `max_recovery_seconds`(默认 3600 秒)。避免对仍未恢复的后端频繁重试。 - -**线程安全**:所有状态变更通过 `threading.Lock` 保护,确保并发请求下状态一致。 - -### 3.3 Priority Chain(优先级匹配链) - -**应用位置**:[`routing/model_mapper.py`](../src/coding/proxy/routing/model_mapper.py) — `ModelMapper` 类 - -**设计要点**: - -ModelMapper 采用三级优先级匹配链,按精确度递减依次尝试: - -```mermaid -flowchart TD - A["输入模型名称"] --> B{"① 精确匹配
pattern == model"} - - B -- "命中" --> C["返回 target"] - B -- "未命中" --> D{"② 模式匹配
Regex / Glob"} - - D -- "is_regex=true" --> E["re.fullmatch()"] - D -- "含 *" --> F["fnmatch.fnmatch()"] - E --> G{"命中?"} - F --> G - - G -- "是" --> C - G -- "否" --> H["③ 默认回退
返回 glm-5.1"] - - style A fill:#1a5276,color:#fff - style C fill:#196f3d,color:#fff - style H fill:#7b241c,color:#fff -``` - -**默认映射规则**: - -| 模式 | 目标 | 类型 | -| ------------------ | ------------- | ------------ | -| `claude-sonnet-.*` | `glm-5.1` | 正则 | -| `claude-opus-.*` | `glm-5.1` | 正则 | -| `claude-haiku-.*` | `glm-4.5-air` | 正则 | -| `claude-.*` | `glm-5.1` | 正则(兜底) | - -正则表达式在 `__init__` 时预编译(`re.compile()`),`map()` 调用时直接使用编译后的对象,避免重复编译开销。 - -### 3.4 Strategy + Factory(策略 + 工厂方法模式) - -> **经典出处**:GoF《Design Patterns》[[1]](#ref1) — 定义创建对象的接口,由策略选择决定实例化哪个类。 - -**应用位置**:[`server/factory.py`](../src/coding/proxy/server/factory.py) — `_create_vendor_from_config()` 函数 - -**组装顺序**(两阶段构建): - -```mermaid -flowchart TD - Start["create_app(config)"] --> Phase1 - - subgraph Phase1 ["阶段一:构建 vendor → VendorTier 映射表"] - direction TB - P1_Loop["for vendor_cfg in config.vendors"] --> Match["match vendor_cfg.vendor"] - Match --> A["anthropic → AnthropicVendor"] - Match --> B["copilot → CopilotVendor
+ 凭证解析"] - Match --> C["antigravity → AntigravityVendor
+ 凭证解析"] - Match --> D["zhipu → ZhipuVendor"] - A & B & C & D --> BuildCB["_build_circuit_breaker()"] - BuildCB --> BuildQG["_build_quota_guard()"] - BuildQG --> Map["_vendor_map[name] =
VendorTier(vendor, cb, qg, wqg)"] - Map --> P1_Loop - end - - Phase1 --> Phase2 - - subgraph Phase2 ["阶段二:按 tiers 显式排序"] - direction TB - HasTiers{"config.tiers
存在?"} - HasTiers -- "是" --> Explicit["tiers = [_vendor_map[name]
for name in config.tiers]"] - HasTiers -- "否" --> Implicit["tiers = vendors 原始顺序"] - end - - Phase2 --> Phase3["构建 RuntimeReauthCoordinator
(若有 OAuth provider)"] - Phase3 --> Final["RequestRouter(tiers, ...)"] - - style Start fill:#1a5276,color:#fff - style Final fill:#196f3d,color:#fff - style Phase1 fill:#2e4053,color:#fff - style Phase2 fill:#2e4053,color:#fff -``` - -**凭证合并优先级**:Token Store(持久化) > config.yaml(显式配置)。确保用户通过 CLI 认证命令获取的凭证优先于配置文件中的硬编码值。 - -### 3.5 Proxy(代理模式)— 整体架构 - -> **经典出处**:GoF《Design Patterns》[[1]](#ref1) — 为其他对象提供一种代理以控制对这个对象的访问。 - -**应用位置**:整体架构 - -coding-proxy 本身即是一个代理服务: - -- 对外暴露与 Anthropic Messages API 完全兼容的 `POST /v1/messages` 接口 -- Claude Code 客户端只需将 `ANTHROPIC_BASE_URL` 指向代理地址 -- 代理在幕后完成后端选择、故障转移、模型映射、用量记录、请求标准化等增值逻辑 -- 支持流式(SSE `text/event-stream`)和非流式(JSON)两种响应模式 -- 额外透传 `/v1/messages/count_tokens` 端点至 Anthropic 主供应商 - -### 3.6 Composite(组合模式)— VendorTier - -> **经典出处**:GoF《Design Patterns》[[1]](#ref1) — 将对象组合成树形结构以表示"部分-整体"的层次结构。 - -**应用位置**:[`routing/tier.py`](../src/coding/proxy/routing/tier.py) — `VendorTier` 数据类 - -**设计要点**: - -VendorTier 将多个正交关注点聚合为路由器的最小调度单元: - -```mermaid -classDiagram - class VendorTier { - <> - -vendor : BaseVendor - -circuit_breaker : CircuitBreaker | None - -quota_guard : QuotaGuard | None - -weekly_quota_guard : QuotaGuard | None - -retry_config : RetryConfig | None - -_rate_limit_deadline : float - -- - +name : str - +is_terminal : bool - +can_execute() bool - +can_execute_with_health_check() bool - +record_success(usage_tokens) - +record_failure(...) - +is_rate_limited : bool - } - - note for VendorTier "路由器的最小调度单元\n聚合 vendor + CB + QG + WQG + Retry + RL" - - style VendorTier fill:#1a5276,color:#fff -``` - -**关键方法**: - -| 方法 | 逻辑 | -| ------------------------------------------------------------------------ | ------------------------------------------------------------------------- | -| `name` | → `vendor.get_name()` | -| `is_terminal` | → `circuit_breaker is None`(终端层无故障转移) | -| `can_execute()` | CB.can_execute() AND QG.can_use_primary() AND WQG.can_use_primary() | -| `can_execute_with_health_check()` | 三层恢复门控:Rate Limit Deadline → Health Check → Cautious Probe | -| `record_success(usage_tokens)` | CB.record_success() + QG/WQG 探测恢复 + 用量记录 + 清除 RL deadline | -| `record_failure(is_cap_error, retry_after_seconds, rate_limit_deadline)` | CB.record_failure(+retry) + 若 cap error 则通知 QG/WQG + 更新 RL deadline | -| `is_rate_limited` | `_rate_limit_deadline > time.monotonic()` | - -### 3.7 State Machine(状态机模式)— QuotaGuard - -**应用位置**:[`routing/quota_guard.py`](../src/coding/proxy/routing/quota_guard.py) — `QuotaGuard` 类 - -**设计要点**: - -基于滑动窗口的双态状态机,通过 Token 预算追踪主动避免触发上游配额限制: - -```mermaid -stateDiagram-v2 - [*] --> WITHIN_QUOTA - - WITHIN_QUOTA --> QUOTA_EXCEEDED : 窗口用量 >= budget × threshold%\n或检测到 cap error - note right of QUOTA_EXCEEDED : 超限状态 — 仅允许探测请求 - - QUOTA_EXCEEDED --> WITHIN_QUOTA : 窗口自然滑出 < threshold%\n或探测请求成功 - - WITHIN_QUOTA --> [*] - QUOTA_EXCEEDED --> [*] -``` - -**核心机制**: - -- **滑动窗口**:`deque[(timestamp, tokens)]`,`_expire()` 清除超出 `window_seconds` 的条目 -- **双窗口支持**:同一 `QuotaGuard` 类同时服务于日度 (`quota_guard`) 和周度 (`weekly_quota_guard`) 两个独立实例 -- **探测恢复**:QUOTA_EXCEEDED 状态下,每隔 `probe_interval_seconds` 放行一个探测请求 -- **cap error 模式**:由外部 `_is_cap_error()` 触发,不做预算自动恢复,仅允许探测恢复 -- **基线加载**:启动时从数据库加载历史用量,防止重启后误判配额状态 -- **线程安全**:所有状态变更通过 `threading.Lock` 保护 - -### 3.8 Double-Check Locking(双重检查锁模式) - -**应用位置**: -- [`vendors/copilot.py`](../src/coding/proxy/vendors/copilot.py) — `CopilotTokenManager` -- [`vendors/antigravity.py`](../src/coding/proxy/vendors/antigravity.py) — `GoogleOAuthTokenManager` - -**设计要点**: - -两个 Token Manager 均采用相同的异步 DCL 模式,确保高并发下 token 交换仅执行一次: - -```python -# 快速路径(无锁) -if self._access_token and time.monotonic() < self._expires_at: - return self._access_token - -# 慢路径(加锁后二次检查) -async with self._lock: - if self._access_token and time.monotonic() < self._expires_at: - return self._access_token - await self._exchange() # 或 self._refresh() -``` - -| Token Manager | 认证流程 | 有效期 | 提前刷新余量 | -| ------------------------- | ------------------------------------------------------------------------- | -------- | ------------ | -| `CopilotTokenManager` | GitHub token -> GET `copilot_internal/v2/token` -> `token`/`access_token` | ~30 分钟 | 60 秒 | -| `GoogleOAuthTokenManager` | refresh_token -> POST oauth2.googleapis.com/token -> access_token | ~1 小时 | 120 秒 | - -两者均支持**被动刷新**:当后端返回 401/403 时,通过 `_on_error_status()` 调用 `invalidate()` 标记 token 失效,下次请求自动触发重新获取。若 `needs_reauth=True`,还会联动 `RuntimeReauthCoordinator` 触发后台重认证流程。 - -### 3.9 Facade(外观模式) - -> **经典出处**:GoF《Design Patterns》[[1]](#ref1) — 为子系统中的一组接口提供一个统一的高层接口。 - -**应用位置**:[`routing/router.py`](../src/coding/proxy/routing/router.py) — `RequestRouter` 类 - -**设计要点**: - -`RequestRouter` 作为薄代理门面(Facade),将路由系统的复杂内部实现封装为简洁的公开接口,内部委托给三个正交分解的子组件: - -```mermaid -graph LR - RR["RequestRouter
(Facade)"] - - RE["_RouteExecutor
tier 迭代门控引擎"] - UR["UsageRecorder
用量/定价/证据"] - SM["RouteSessionManager
兼容性会话管理"] - - RR -->|"route_stream()"| RE - RR -->|"route_message()"| RE - RR -->|"set_pricing_table()"| UR - RR -->|"构造注入"| SM - - RE -->|"execute_stream()
execute_message()"| VT["VendorTier 链"] - UR -->|"record()
log_model_call()"| TL["TokenLogger"] - SM -->|"get_or_create_record()
apply_compat_context()
persist_session()"| CSS["CompatSessionStore"] -``` - -**委托关系**: - -| 公开方法 | 内部委托 | -| ------------------------------ | --------------------------------------------- | -| `route_stream(body, headers)` | -> `_executor.execute_stream(body, headers)` | -| `route_message(body, headers)` | -> `_executor.execute_message(body, headers)` | -| `set_pricing_table(table)` | -> `_recorder.set_pricing_table(table)` | -| `close()` | -> 遍历 `tiers` 调用 `tier.vendor.close()` | - -这种正交 decomposition 使得每个子组件可以独立演进和测试,同时 `RequestRouter` 保持对外接口稳定。 - -### 3.10 Adapter(适配器模式)— Vendor 层 - -> **经典出处**:GoF《Design Patterns》[[1]](#ref1) — 将一个类的接口转换成客户期望的另一个接口。 - -**应用位置**:[`vendors/`](../src/coding/proxy/vendors/) — `BaseVendor` 及其具体实现 - -**设计要点**: - -每个 Vendor 子类充当 Adapter 角色,将异构的上游 API 适配为统一的 `BaseVendor` 接口: - -| Vendor | 上游协议 | 适配行为 | -| ------------------- | --------------------------- | ------------------------------------------------------ | -| `AnthropicVendor` | Anthropic Messages API | 透传(近乎零适配开销) | -| `CopilotVendor` | OpenAI Chat Completions API | 请求体/响应体双向格式转换 + token 注入 | -| `AntigravityVendor` | Gemini GenerateContent API | Anthropic <-> Gemini 双向格式转换 + SSE 流适配 + OAuth | -| `ZhipuVendor` | Anthropic-compatible API | 模型名映射 + API Key 认证头替换 | - -此外,[`convert/`](../src/coding/proxy/convert/) 模块提供独立的纯函数适配器层,支持三向格式转换: - -| 转换方向 | 模块 | 说明 | -| --------------------------- | -------------------------------------------------------------------------------------- | ------------------ | -| Anthropic -> Gemini | [`convert/anthropic_to_gemini.py`](../src/coding/proxy/convert/anthropic_to_gemini.py) | 请求格式转换 | -| Gemini -> Anthropic | [`convert/gemini_to_anthropic.py`](../src/coding/proxy/convert/gemini_to_anthropic.py) | 响应格式转换 | -| Gemini SSE -> Anthropic SSE | [`convert/gemini_sse_adapter.py`](../src/coding/proxy/convert/gemini_sse_adapter.py) | 流式事件重构 | -| Anthropic -> OpenAI | [`convert/anthropic_to_openai.py`](../src/coding/proxy/convert/anthropic_to_openai.py) | Copilot 请求适配 | -| OpenAI -> Anthropic | [`convert/openai_to_anthropic.py`](../src/coding/proxy/convert/openai_to_anthropic.py) | Copilot 响应逆适配 | - -### 3.11 Capability-Based Routing(基于能力的路由) - -**应用位置**: -- [`routing/error_classifier.py`](../src/coding/proxy/routing/error_classifier.py) — `build_request_capabilities()` -- [`vendors/base.py`](../src/coding/proxy/vendors/base.py) — `BaseVendor.supports_request()` / `get_capabilities()` / `make_compatibility_decision()` -- [`model/vendor.py`](../src/coding/proxy/model/vendor.py) — `RequestCapabilities` / `VendorCapabilities` / `CapabilityLossReason` - -**设计要点**: - -基于请求能力画像与供应商能力声明的正交匹配矩阵,在路由阶段即排除无法无损承接请求的层级: - -| 维度 \ 能力 | `tools` | `thinking` | `images` | `metadata` | `vend_tools` | -| :----------- | :-----: | :--------: | :------: | :--------: | :----------: | -| **tools** | ✅ OK | — | — | — | ❌ skip | -| **thinking** | — | ✅ OK | — | — | — | -| **images** | — | — | ✅ OK | — | — | -| **metadata** | — | — | — | ✅ OK | — | - -> ✅ = 可承接;❌ = `CapabilityLossReason`(跳过此 tier) - -**匹配流程**: - -1. **能力硬过滤**:`supports_request(request_caps)` -> 返回 `(bool, list[CapabilityLossReason])` - - 若有任何 `CapabilityLossReason`,直接跳过该 tier 并记录原因 -2. **兼容性决策**:`make_compatibility_decision(canonical_request)` -> 返回 `CompatibilityDecision` - - `NATIVE`:完全原生支持,直接放行 - - `SIMULATED`:可通过模拟/投影方式支持(如 thinking 剥离、tool_calling 降级) - - `UNSAFE`:存在不可弥补的语义缺失,跳过该 tier - -**CompatibilityDecision 三态决策矩阵**: - -| 请求特征 | NATIVE | SIMULATED | UNSAFE | -| -------------------------- | ------ | ----------------------- | ------ | -| thinking + 支持 thinking | OK | | | -| thinking + 不支持 thinking | | thinking_simulation | X | -| tools + 支持 tools | OK | | | -| tools + 不支持 tools | | tool_calling_simulation | X | -| metadata + 支持 metadata | OK | | | -| metadata + 不支持 metadata | | metadata_projection | X | -| json_output + 支持 | OK | | | -| json_output + 不支持 | | json_output_projection | X | - -### 3.12 Retry with Full Jitter(带完全抖动的重试模式) - -> **参考**:M. Nygard《Release It!》第 5 章[[3]](#ref3);AWS Architecture Center "Retry Pattern"[[4]](#ref4) - -**应用位置**:[`routing/retry.py`](../src/coding/proxy/routing/retry.py) — `RetryConfig` / `calculate_delay()` - -**设计要点**: - -传输层重试策略处理瞬态网络故障,与 CircuitBreaker 形成正交互补: - -| 维度 | Retry | CircuitBreaker | -| ------------ | ------------------------------------- | ---------------------- | -| 处理范围 | 单次请求内的瞬态抖动 | 跨请求的持续故障 | -| 恢复时间尺度 | 秒级(500ms ~ 5s) | 分钟级(300s ~ 3600s) | -| 触发条件 | TimeoutException / ConnectError / 5xx | 连续 N 次失败 | -| 失败贡献 | 每次 retry 失败仅向 CB 贡献 1 次计数 | 累积计数触发 OPEN | - -**Full Jitter 计算**: - -$$ -\text{delay} = \text{random}\left(0,\; \min\left(\text{initial\_delay} \times \text{backoff}^{\text{attempt}},\; \text{max\_delay}\right)\right) -$$ - -| 参数 | `max_retries` | `initial_delay_ms` | `max_delay_ms` | `backoff_multiplier` | `jitter` | -| ------ | :-----------: | :----------------: | :------------: | :------------------: | :------: | -| 默认值 | 2 | 500 | 5000 | 2.0 | ✅ true | - -**可重试异常判定**(`is_retryable_error()`): - -| 异常类型 | 可重试 | 原因 | -| ----------------------------- | ------ | -------------------------- | -| `httpx.TimeoutException` | OK | 瞬态超时 | -| `httpx.ConnectError` | OK | 网络连接失败 | -| `httpx.HTTPStatusError` (5xx) | OK | 服务端瞬时错误 | -| `httpx.HTTPStatusError` (4xx) | NO | 客户端错误(不应重试) | -| `TokenAcquireError` | NO | 认证层错误(应触发重认证) | - -### 3.13 Rate Limit Deadline Tracking(速率限制截止追踪) - -**应用位置**: -- [`routing/rate_limit.py`](../src/coding/proxy/routing/rate_limit.py) — `RateLimitInfo` / `parse_rate_limit_headers()` / `compute_rate_limit_deadline()` -- [`routing/tier.py`](../src/coding/proxy/routing/tier.py) — `VendorTier._rate_limit_deadline` / `is_rate_limited` / `can_execute_with_health_check()` +### 2.2 供应商分类体系 -**设计要点**: +系统支持 9 种供应商类型(`VendorType`),按适配复杂度分为三组: -从 HTTP 429/403 响应头精确解析上游速率限制信息,并转化为 monotonic 时间戳用于门控: +| 分类 | 基类 | 适配行为 | 供应商 | +| ----------------------- | ----------------------- | ------------------------------- | --------------------------------------------- | +| **直接 Anthropic** | `BaseVendor` | 零适配,直接透传 | Anthropic | +| **协议转换** | `BaseVendor` + Mixin | 双向格式转换 + Token 管理 | Copilot(OpenAI)、Antigravity(Gemini) | +| **原生 Anthropic 兼容** | `NativeAnthropicVendor` | 薄透传:模型映射 + API Key 替换 | Zhipu、MiniMax、Kimi、Doubao、Xiaomi、Alibaba | -**解析的信息源**: +> **供应商模块完整文档**:参见 [供应商模块(vendors/)](./arch/vendors.md) -| Header | 格式 | 含义 | -| ------------------------------------ | ----------------- | -------------------- | -| `Retry-After` | 秒数或 HTTP Date | 标准速率限制恢复时间 | -| `anthropic-ratelimit-requests-reset` | ISO 8601 datetime | 请求计数重置时间 | -| `anthropic-ratelimit-tokens-reset` | ISO 8601 datetime | Token 配额重置时间 | +### 2.3 模块职责一览 -**计算策略**: - -- 取所有可用信号的最大值,并加 **10% 安全余量** -- `compute_effective_retry_seconds()` -> 返回相对秒数(供 CircuitBreaker 退避计算) -- `compute_rate_limit_deadline()` -> 返回绝对 monotonic 时间戳(供 VendorTier 精确门控) - -**三层恢复门控集成**: - -`can_execute_with_health_check()` 方法实现了三层渐进式恢复机制: - -```mermaid -flowchart TD - Entry["can_execute_with_health_check()"] --> L1{"第一层
Rate Limit Deadline"} - L1 -- "_deadline > now?
YES" --> Reject["直接拒绝
(~0 开销)"] - L1 -- "NO" --> L2{"第二层
CB + QG + WQG
综合判断"} - - L2 -- "全部拒绝" --> Skip["直接跳过"] - L2 -- "至少一个允许" --> ProbeCheck{"探测场景?
CB=HALF_OPEN 或
QG=QUOTA_EXCEEDED"} - - ProbeCheck -- "否" --> Result["返回综合结果"] - ProbeCheck -- "是" --> L3{"第三层
Health Check
(慢路径)"} - - L3 -- "check_health()
= False" --> FailRec["record_failure()
→ 拒绝"] - L3 -- "= True" --> Pass["放行
(Cautious Probe)"] - - style Entry fill:#1a5276,color:#fff - style Reject fill:#7b241c,color:#fff - style Pass fill:#196f3d,color:#fff - style FailRec fill:#7b241c,color:#fff - style Skip fill:#7b241c,color:#fff - style Result fill:#2874a6,color:#fff -``` +| 模块 | 路径 | 职责 | 专题文档 | +| ------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | +| **vendors** | [`vendors/`](../src/coding/proxy/vendors/) | **供应商适配器(主架构)**:`BaseVendor` 抽象基类 + `NativeAnthropicVendor` + 9 个具体实现 | [供应商模块](./arch/vendors.md) | +| **routing** | [`routing/`](../src/coding/proxy/routing/) | N-tier 链式路由核心(正交分解为 executor/tier/CB/QG/retry/rate_limit 等 12 个子模块) | [路由模块](./arch/routing.md) | +| **compat** | [`compat/`](../src/coding/proxy/compat/) | 兼容性抽象系统:`CanonicalRequest` / `CompatibilityDecision` / `session_store` | | +| **auth** | [`auth/`](../src/coding/proxy/auth/) | 认证系统:OAuth providers(GitHub Device Flow / Google OAuth2)/ runtime reauth / token store | | +| **model** | [`model/`](../src/coding/proxy/model/) | 数据模型正交分解:vendor / compat / constants / pricing / token / auth | | +| **config** | [`config/`](../src/coding/proxy/config/) | Pydantic v2 配置模型(正交拆分为 server/vendors/resiliency/routing/auth_schema)+ YAML 加载器 | [配置参考](./arch/config-reference.md) | +| **convert** | [`convert/`](../src/coding/proxy/convert/) | API 格式转换(Anthropic ↔ Gemini ↔ OpenAI 三向转换,含 SSE 流适配) | [格式转换](./arch/convert.md) | +| **logging** | [`logging/`](../src/coding/proxy/logging/) | Token 用量 SQLite 持久化([`db.py`](../src/coding/proxy/logging/db.py))、统计查询([`stats.py`](../src/coding/proxy/logging/stats.py))、Rich 格式化 | | +| **server** | [`server/`](../src/coding/proxy/server/) | FastAPI 应用工厂与生命周期管理(app/factory/routes/normalizer/responses/dashboard) | | +| **streaming** | [`streaming/`](../src/coding/proxy/streaming/) | Anthropic 兼容流式处理([`anthropic_compat.py`](../src/coding/proxy/streaming/anthropic_compat.py)) | | +| **cli** | [`cli/`](../src/coding/proxy/cli/) | Typer 命令行入口(start/status/usage/reset/auth)+ Banner 显示 | | +| **pricing** | [`pricing.py`](../src/coding/pricing.py) | 定价表(`PricingTable`):按 (vendor, model) 查询单价并计算费用 | | --- -## 4. 请求生命周期 +## 3. 请求生命周期 -### 4.1 完整请求流程 +### 3.1 完整请求流程 ```mermaid flowchart TD @@ -778,7 +251,7 @@ flowchart TD style Normalize fill:#2874a6,color:#fff ``` -### 4.2 流式请求处理 +### 3.2 流式请求处理 流式请求使用 `StreamingResponse` + 异步生成器 `_stream_proxy()`: @@ -790,10 +263,10 @@ flowchart TD 4. 流结束后: - `UsageRecorder.build_usage_info(usage)` 构建结构化用量信息 - 检查缺失信号并 WARNING 日志 - - `tier.record_success(input_tokens + output_tokens)` -- 通知 CB 成功 + QG/WQG 记录用量 + 清除 RL deadline - - `UsageRecorder.log_model_call()` -- 输出含定价信息的 Access Log - - `RouteSessionManager.persist_session()` -- 持久化兼容性会话状态 - - `UsageRecorder.record()` -- 记录完整用量到 TokenLogger(含 evidence_records) + - `tier.record_success(input_tokens + output_tokens)` — 通知 CB 成功 + QG/WQG 记录用量 + 清除 RL deadline + - `UsageRecorder.log_model_call()` — 输出含定价信息的 Access Log + - `RouteSessionManager.persist_session()` — 持久化兼容性会话状态 + - `UsageRecorder.record()` — 记录完整用量到 TokenLogger(含 evidence_records) **故障转移时**:清空已收集的 usage 数据,从下一个 tier 重新开始流式传输。 @@ -806,7 +279,7 @@ flowchart TD | `TimeoutException/ConnectError/ReadError` | `error` event + `api_error` | | `HTTPStatusError` | `error` event + 提取的 error type/message | -### 4.3 非流式请求处理 +### 3.3 非流式请求处理 非流式请求直接调用 `send_message()` 获取完整 `VendorResponse`: @@ -818,507 +291,128 @@ flowchart TD 3. 捕获 `TokenAcquireError` -> `handle_token_error()` + 触发 reauth -> 下一 tier(非最后一层) 4. 捕获 `TimeoutException/ConnectError/ReadError` -> `record_failure()` -> 下一 tier -**VendorResponse 新增字段**: +**VendorResponse 关键字段**: | 字段 | 类型 | 说明 | | ------------------ | ---------------- | --------------------------------------------- | | `model_served` | `str \| None` | 后端实际使用的模型名(可能经 map_model 转换) | | `response_headers` | `dict[str, str]` | 原始响应头(用于 rate limit 解析) | -### 4.4 故障转移判定逻辑 +### 3.4 故障转移判定逻辑 + + 故障转移的判定在 `BaseVendor.should_trigger_failover()` 中实现,依据三层条件(可通过配置文件自定义): -| 层级 | 条件 | 默认值 | -| ----------- | ------------------------------------------ | ------------------------------------------------------- | -| HTTP 状态码 | `status_code in failover.status_codes` | `[429, 403, 503, 500]` | -| 错误类型 | `error.type in failover.error_types` | `["rate_limit_error", "overloaded_error", "api_error"]` | -| 错误消息 | `pattern in error.message`(不区分大小写) | `["quota", "limit exceeded", "usage cap", "capacity"]` | +| 层级 | 条件 | 默认值 | +| ----------- | ------------------------------------------ | ---------------------------------------------------------------------------------- | +| HTTP 状态码 | `status_code in failover.status_codes` | `[429, 403, 503, 500, 529]` | +| 错误类型 | `error.type in failover.error_types` | `["rate_limit_error", "overloaded_error", "api_error"]` | +| 错误消息 | `pattern in error.message`(不区分大小写) | `["quota", "limit exceeded", "usage cap", "capacity", "internal network failure"]` | **特殊规则**:对于 429 和 503 状态码,即使无法解析响应体(body),也会强制触发故障转移。 **语义拒绝独立路径**:`is_semantic_rejection()` 检测 400 状态码下的 `invalid_request_error` 类型或特定消息模式(如 `should match pattern`、`validation`、`tool_use_id`、`server_tool_use`),此类错误**不记录 failure** 且**不触发故障转移计数**,直接跳至下一 tier。 -**终端供应商行为**:ZhipuVendor 因构造时不传入 `failover_config`,`should_trigger_failover()` 始终返回 `False`。 +**终端供应商行为**:无 `circuit_breaker` 配置的供应商(终端层),`should_trigger_failover()` 始终返回 `False`。 -### 4.5 生命周期管理 +### 3.5 生命周期管理 通过 `lifespan` 异步上下文管理器管理应用生命周期([`server/app.py`](../src/coding/proxy/server/app.py)): **启动 (Startup)**: -1. `TokenLogger.init()` -- 创建 SQLite 数据库表和索引 -2. `CompatSessionStore.init()` -- 初始化兼容性会话存储 -3. `PricingTable(config.pricing)` -- 加载模型定价表并注入 Router +1. `TokenLogger.init()` — 创建 SQLite 数据库表和索引 +2. `CompatSessionStore.init()` — 初始化兼容性会话存储 +3. `PricingTable(config.pricing)` — 加载模型定价表并注入 Router 4. 为每个启用了 QuotaGuard 的 tier 加载基线: - `token_logger.query_window_total(qg.window_hours, vendor=tier.name)` -> `quota_guard.load_baseline(total)` - 同理处理 `weekly_quota_guard` **关闭 (Shutdown)**: -1. `router.close()` -- 关闭所有 vendor 的 HTTP 客户端(含 TokenManager 客户端) -2. `compat_session_store.close()` -- 关闭会话存储连接 -3. `token_logger.close()` -- 关闭数据库连接 +1. `router.close()` — 关闭所有 vendor 的 HTTP 客户端(含 TokenManager 客户端) +2. `compat_session_store.close()` — 关闭会话存储连接 +3. `token_logger.close()` — 关闭数据库连接 --- -## 5. 模块详细设计 - -### 5.1 vendors/ -- 供应商模块(主架构) - -**BaseVendor**([`vendors/base.py`](../src/coding/proxy/vendors/base.py))-- 抽象基类,模板方法模式的核心: - -```python -class BaseVendor(ABC): - def __init__(self, base_url, timeout_ms, failover_config=None) - - # -- 抽象方法 -- - @abstractmethod - def get_name(self) -> str: ... - @abstractmethod - async def _prepare_request(self, body, headers) -> tuple[dict, dict]: ... - - # -- 核心方法(模板固定流程) -- - async def send_message(self, body, headers) -> VendorResponse: ... - async def send_message_stream(self, body, headers) -> AsyncIterator[bytes]: ... - - # -- 能力与兼容性 -- - def get_capabilities(self) -> VendorCapabilities: ... - def supports_request(self, caps) -> tuple[bool, list[CapabilityLossReason]]: ... - def make_compatibility_decision(self, request) -> CompatibilityDecision: ... - def map_model(self, model) -> str: ... - - # -- 钩子方法(子类可选覆写) -- - def _get_endpoint(self) -> str: ... # 默认 /v1/messages - def _pre_send_check(self, body, headers): ... # 默认 no-op - def _normalize_error_response(self, status, response, resp) -> VendorResponse: ... - def _on_error_status(self, status_code): ... # 默认 no-op - def check_health() -> Awaitable[bool]: ... # 默认返回 True - - # -- 故障转移 -- - def should_trigger_failover(self, status_code, body) -> bool: ... - - # -- 生命周期 -- - async def close(): ... -``` - -**数据类型**(定义于 [`model/vendor.py`](../src/coding/proxy/model/vendor.py),由 `base.py` re-export): - -```python -@dataclass -class UsageInfo: - input_tokens: int = 0 - output_tokens: int = 0 - cache_creation_tokens: int = 0 - cache_read_tokens: int = 0 - request_id: str = "" - -@dataclass -class VendorResponse: - status_code: int = 200 - usage: UsageInfo = field(default_factory=UsageInfo) - is_streaming: bool = False - raw_body: bytes = b"{}" - error_type: str | None = None - error_message: str | None = None - model_served: str | None = None # 后端实际使用的模型 - response_headers: dict[str, str] = field(default_factory=dict) - -class NoCompatibleVendorError(RuntimeError): - def __init__(self, message, *, reasons=None): ... - reasons: list[str] - -class CapabilityLossReason(Enum): - TOOLS = "tools" - THINKING = "thinking" - IMAGES = "images" - VENDOR_TOOLS = "vendor_tools" - METADATA = "metadata" - -@dataclass(frozen=True) -class RequestCapabilities: - has_tools: bool = False - has_thinking: bool = False - has_images: bool = False - has_metadata: bool = False - -@dataclass(frozen=True) -class VendorCapabilities: - supports_tools: bool = True - supports_thinking: bool = True - supports_images: bool = True - emits_vendor_tool_events: bool = False - supports_metadata: bool = True -``` - -**四个具体 Vendor 实现**: - -| Vendor | 文件 | 协议 | 认证方式 | 特殊能力 | -| --------------------- | ---------------------------------------------------------------------- | ------------------------ | ---------------------------------- | ------------------------------------------------- | -| **AnthropicVendor** | [`vendors/anthropic.py`](../src/coding/proxy/vendors/anthropic.py) | Anthropic Messages API | 透传 OAuth token | 全能力支持,旁路 count_tokens | -| **CopilotVendor** | [`vendors/copilot.py`](../src/coding/proxy/vendors/copilot.py) | OpenAI Chat Completions | GitHub token -> Copilot token 交换 | 不支持 thinking;内置模型探测 | -| **AntigravityVendor** | [`vendors/antigravity.py`](../src/coding/proxy/vendors/antigravity.py) | Gemini GenerateContent | Google OAuth2 refresh_token | 不支持 tools/thinking/metadata;覆写 health check | -| **ZhipuVendor** | [`vendors/zhipu.py`](../src/coding/proxy/vendors/zhipu.py) | Anthropic-compatible API | x-api-key | 不支持 thinking/images/metadata;覆写 map_model | - -**向后兼容别名汇总**(均标记 `deprecated`,保障迁移期兼容性): - -| 旧名称 | 新名称 | 定义位置 | -| -------------------------- | ------------------------- | ----------------- | -| `BaseBackend` | `BaseVendor` | `vendors/base.py` | -| `NoCompatibleBackendError` | `NoCompatibleVendorError` | `vendors/base.py` | -| `BackendTier` | `VendorTier` | `routing/tier.py` | -| `BackendCapabilities` | `VendorCapabilities` | `model/vendor.py` | -| `BackendResponse` | `VendorResponse` | `model/vendor.py` | - -### 5.2 routing/ -- 路由模块 - -#### 5.2.1 VendorTier([`routing/tier.py`](../src/coding/proxy/routing/tier.py)) - -```python -@dataclass -class VendorTier: - vendor: BaseVendor - circuit_breaker: CircuitBreaker | None = None - quota_guard: QuotaGuard | None = None - weekly_quota_guard: QuotaGuard | None = None # 周度配额守卫 - retry_config: RetryConfig | None = None # 重试策略参数 - _rate_limit_deadline: float = 0.0 # Rate Limit 截止 monotonic 时间戳 -``` - -**向后兼容别名**:`BackendTier = VendorTier`(deprecated,详见 [§5.1 别名汇总表](#51-vendors--供应商模块主架构)) - -#### 5.2.2 _RouteExecutor([`routing/executor.py`](../src/coding/proxy/routing/executor.py)) - -统一的 tier 迭代门控引擎,封装 `execute_stream()` / `execute_message()` 共享的循环逻辑: - -**职责**: -- 按优先级遍历 tiers,执行能力门控与健康检查 -- 委托具体的流式/非流式执行给 vendor -- 统一处理 `TokenAcquireError` / HTTP 错误 / 语义拒绝 -- 成功后委托 UsageRecorder 记录用量与定价日志 - -**关键方法**: - -| 方法 | 说明 | -| ------------------------------------------------------------------ | -------------------------------------------------------------------- | -| `execute_stream(body, headers)` | 流式路由主循环,yield `(chunk, vendor_name)` | -| `execute_message(body, headers)` | 非流式路由主循环,返回 `VendorResponse` | -| `_try_gate_tier(tier, is_last, caps, canonical, session, reasons)` | 单 tier 门控:能力匹配 -> 兼容性决策 -> 上下文应用 -> 健康检查 | -| `_handle_token_error(tier, exc, is_last, failed_name)` | TokenAcquireError 处理 + reauth 触发 | -| `_handle_http_error(tier, exc, ...)` | HTTP 错误处理:语义拒绝 / cap error / rate limit 解析 / failure 记录 | -| `_is_cap_error(resp)` | 静态方法:检测 429/403 + 配额关键词 | - -#### 5.2.3 UsageRecorder([`routing/usage_recorder.py`](../src/coding/proxy/routing/usage_recorder.py)) - -封装路由层的用量记录、定价计算与证据构建: - -| 方法 | 说明 | -| ------------------------------------------------------------------------ | -------------------------------------------------------- | -| `set_pricing_table(table)` | 注入 PricingTable(lifespan 启动时调用) | -| `build_usage_info(usage_dict)` | 从原始 dict 构建结构化 UsageInfo | -| `log_model_call(vendor, model_requested, model_served, duration, usage)` | 输出 ModelCall 级别 Access Log(含定价) | -| `record(vendor, ..., evidence_records)` | 持久化用量到 TokenLogger + evidence 记录(Copilot 专用) | -| `build_nonstream_evidence_records(...)` | 构建非流式证据记录 | - -#### 5.2.4 RouteSessionManager([`routing/session_manager.py`](../src/coding/proxy/routing/session_manager.py)) - -管理单次路由请求的兼容性会话生命周期: - -| 方法 | 说明 | -| ---------------------------------------------------------- | ------------------------------------- | -| `get_or_create_record(session_key, trace_id)` | 获取或创建会话记录 | -| `apply_compat_context(tier, canonical, decision, session)` | 构建 CompatibilityTrace 并注入 vendor | -| `persist_session(trace, session)` | 持久化会话状态到 CompatSessionStore | - -#### 5.2.5 Error Classifier([`routing/error_classifier.py`](../src/coding/proxy/routing/error_classifier.py)) - -HTTP 错误分类与请求能力画像提取: - -| 函数 | 说明 | -| --------------------------------------------------------------- | ------------------------------------------------------ | -| `build_request_capabilities(body)` | 从请求体提取能力画像(tools/thinking/images/metadata) | -| `is_semantic_rejection(status_code, error_type, error_message)` | 判断是否为语义拒绝(400 + 特定模式) | -| `extract_error_payload_from_http_status(exc)` | 从 HTTPStatusError 安全提取 JSON payload | - -#### 5.2.6 Rate Limit([`routing/rate_limit.py`](../src/coding/proxy/routing/rate_limit.py)) - -速率限制信息解析与截止时间计算: - -| 函数/类 | 说明 | -| ------------------------------------------------------- | ------------------------------------------------------------------------ | -| `RateLimitInfo` | 数据类:retry_after / requests_reset_at / tokens_reset_at / is_cap_error | -| `parse_rate_limit_headers(headers, status, error_body)` | 从响应头解析所有速率限制信号 | -| `compute_effective_retry_seconds(info)` | 计算最保守恢复等待时间(相对秒数,+10% 余量) | -| `compute_rate_limit_deadline(info)` | 计算最保守恢复截止时间(绝对 monotonic 时间戳,+10% 余量) | - -#### 5.2.7 Retry([`routing/retry.py`](../src/coding/proxy/routing/retry.py)) - -传输层重试策略: - -| 函数/类 | 说明 | -| ------------------------------- | ----------------------------------------------------------------------------------- | -| `RetryConfig` | 数据类:max_retries / initial_delay_ms / max_delay_ms / backoff_multiplier / jitter | -| `is_retryable_error(exc)` | 判断异常是否值得重试 | -| `is_retryable_status(code)` | 判断状态码是否值得重试(5xx) | -| `calculate_delay(attempt, cfg)` | 计算第 N 次重试延迟(含 Full Jitter) | - -#### 5.2.8 CircuitBreaker 参数([`routing/circuit_breaker.py`](../src/coding/proxy/routing/circuit_breaker.py)) - -| 参数 | 类型 | 默认值 | 说明 | -| -------------------------- | ---- | ------ | ------------------------------------ | -| `failure_threshold` | int | 3 | 触发 OPEN 的连续失败次数 | -| `recovery_timeout_seconds` | int | 300 | OPEN -> HALF_OPEN 等待秒数 | -| `success_threshold` | int | 2 | HALF_OPEN -> CLOSED 所需连续成功次数 | -| `max_recovery_seconds` | int | 3600 | 指数退避最大恢复时间(秒) | - -#### 5.2.9 QuotaGuard 参数([`routing/quota_guard.py`](../src/coding/proxy/routing/quota_guard.py)) - -| 参数 | 类型 | 默认值 | 说明 | -| ------------------------ | ----- | ------ | ------------------------------------ | -| `enabled` | bool | False | 是否启用配额守卫 | -| `token_budget` | int | 0 | 滑动窗口内的 Token 预算上限 | -| `window_hours` | float | 5.0 | 滑动窗口大小(小时),默认 5 小时 | -| `threshold_percent` | float | 99.0 | 触发 QUOTA_EXCEEDED 的用量百分比阈值 | -| `probe_interval_seconds` | int | 300 | QUOTA_EXCEEDED 状态下探测间隔(秒) | - -**QuotaGuard 公共方法**: - -| 方法 | 说明 | -| ----------------------------- | ------------------------------------------------ | -| `can_use_primary()` | 综合判断是否允许使用此后端 | -| `record_usage(tokens)` | 记录 Token 用量到滑动窗口 | -| `record_primary_success()` | 探测成功后恢复为 WITHIN_QUOTA | -| `notify_cap_error()` | 外部通知检测到 cap 错误,强制进入 QUOTA_EXCEEDED | -| `load_baseline(total_tokens)` | 从数据库加载历史用量基线 | -| `reset()` | 手动重置为 WITHIN_QUOTA | -| `get_info()` | 获取状态信息(供 `/api/status` 使用) | - -### 5.3 compat/ -- 兼容性抽象模块 - -#### 5.3.1 CanonicalRequest([`compat/canonical.py`](../src/coding/proxy/compat/canonical.py) + [`model/compat.py`](../src/coding/proxy/model/compat.py)) - -供应商无关的 Claude/Anthropic 语义抽象,从原始请求体提取: - -```python -@dataclass -class CanonicalRequest: - session_key: str # 会话标识(用于兼容性状态关联) - trace_id: str # 追踪 ID(UUID) - request_id: str # 原始请求 ID - model: str # 请求模型名 - messages: list[CanonicalMessagePart] # 消息内容(正交分解) - thinking: CanonicalThinking # 思考模式配置 - metadata: dict # 元数据 - tool_names: list[str] # 工具名称列表 - supports_json_output: bool # 是否要求 JSON 结构化输出 -``` - -**session_key 派生策略**(优先级从高到低): -1. `x-claude-session-id` / `x-session-id` / `session-id` 请求头 -2. `metadata.session_id` / `conversation_id` / `user_id` -3. SHA-256 哈希(model + system + tools + 最近 6 条消息) - -#### 5.3.2 CompatibilityDecision([`model/compat.py`](../src/coding/proxy/model/compat.py)) - -```python -class CompatibilityStatus(Enum): - NATIVE = "native" # 完全原生支持 - SIMULATED = "simulated" # 可通过模拟/投影方式支持 - UNSAFE = "unsafe" # 存在不可弥补的语义缺失 - UNKNOWN = "unknown" # 未知(保守处理) - -@dataclass -class CompatibilityDecision: - status: CompatibilityStatus - simulation_actions: list[str] # 需要执行的模拟动作 - unsupported_semantics: list[str] # 不支持的语义列表 -``` - -#### 5.3.3 CompatibilityProfile([`vendors/base.py`](../src/coding/proxy/vendors/base.py) -> `get_compatibility_profile()`) - -将 `VendorCapabilities` 映射为细粒度的兼容性状态: - -| VendorCapabilities | thinking | tool_calling | tool_streaming | mcp_tools | images | metadata | json_output | usage_tokens | -| ------------------ | -------- | ------------ | -------------- | --------- | ------ | -------- | ----------- | ------------ | -| supports=True | NATIVE | NATIVE | SIMULATED | UNKNOWN | NATIVE | NATIVE | UNKNOWN | SIMULATED | -| supports=False | UNSAFE | UNSAFE | UNSAFE | UNSAFE | UNSAFE | UNSAFE | UNSAFE | SIMULATED | - -#### 5.3.4 CompatSessionStore([`compat/session_store.py`](../src/coding/proxy/compat/session_store.py)) - -兼容性会话持久化存储,跨请求保持供应商适配状态: - -- 基于 SQLite / JSON 文件的 KV 存储 -- TTL 自动过期清理 -- `upsert()` 原子更新会话状态 - -### 5.4 model/ -- 数据模型模块 - -数据模型的正交分解,遵循单一职责原则: - -| 子模块 | 文件 | 核心类型 | -| ------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **vendor** | [`model/vendor.py`](../src/coding/proxy/model/vendor.py) | `UsageInfo`, `VendorResponse`, `NoCompatibleVendorError`, `RequestCapabilities`, `VendorCapabilities`, `CapabilityLossReason`, 兼容别名(`Backend*`),Copilot 诊断类, 工具函数 | -| **compat** | [`model/compat.py`](../src/coding/proxy/model/compat.py) | `CanonicalRequest`, `CanonicalMessagePart`, `CanonicalThinking`, `CanonicalToolCall`, `CompatibilityDecision`, `CompatibilityProfile`, `CompatibilityStatus`, `CompatibilityTrace` | -| **constants** | [`model/constants.py`](../src/coding/proxy/model/constants.py) | `PROXY_SKIP_HEADERS`, `RESPONSE_SANITIZE_SKIP_HEADERS` 等常量 | -| **pricing** | [`model/pricing.py`](../src/coding/proxy/model/pricing.py) | `ModelPricing`, `CostValue`, `Currency` | -| **token** | [`model/token.py`](../src/coding/proxy/model/token.py) | Token 相关模型 | - -### 5.5 auth/ -- 认证模块 - -#### 5.5.1 OAuth Provider 架构([`auth/providers/`](../src/coding/proxy/auth/providers/)) - -| Provider | 文件 | OAuth 流程 | 用途 | -| ---------------------------- | --------------------------------------------------------------------- | -------------------------------------------- | ---------------------- | -| **GitHubDeviceFlowProvider** | [`providers/github.py`](../src/coding/proxy/auth/providers/github.py) | GitHub Device Authorization Grant | Copilot token 获取 | -| **GoogleOAuthProvider** | [`providers/google.py`](../src/coding/proxy/auth/providers/google.py) | OAuth 2.0 Authorization Code + Refresh Token | Antigravity token 获取 | -| **BaseOAuthProvider** | [`providers/base.py`](../src/coding/proxy/auth/providers/base.py) | 抽象基类 | 统一 login() 接口 | - -#### 5.5.2 RuntimeReauthCoordinator([`auth/runtime.py`](../src/coding/proxy/auth/runtime.py)) - -运行时 OAuth 重认证协调器,当 TokenManager 报告凭证过期时后台触发浏览器登录: - -**状态机**:`IDLE -> PENDING -> COMPLETED / FAILED` - -**关键方法**: +## 4. 模块概览 -| 方法 | 说明 | -| ------------------------------- | -------------------------------------------------------------- | -| `request_reauth(provider_name)` | 幂等触发重认证(后台 asyncio.Task) | -| `get_status()` | 返回所有 provider 的 `{state, error?, completed_ago_seconds?}` | +### 4.1 compat/ — 兼容性抽象 -**与熔断器的协同**:重认证期间 TokenManager 持续抛 `TokenAcquireError` -> Router 触发 failover -> CB 可能 OPEN -> 请求路由到下一层级 -> 重认证完成 -> CB 恢复 -> 后端重新可用。 +供应商无关的语义抽象,跨请求保持供应商适配状态: -#### 5.5.3 TokenStoreManager([`auth/store.py`](../src/coding/proxy/auth/store.py)) +- **CanonicalRequest**([`compat/canonical.py`](../src/coding/proxy/compat/canonical.py)):从原始请求体提取供应商无关的语义结构(session_key / model / messages / thinking / tool_names 等) +- **CompatibilityDecision**([`model/compat.py`](../src/coding/proxy/model/compat.py)):三态决策(NATIVE / SIMULATED / UNSAFE),指导路由层跳过或适配 +- **CompatSessionStore**([`compat/session_store.py`](../src/coding/proxy/compat/session_store.py)):兼容性会话持久化存储,基于 SQLite KV,支持 TTL 自动过期 -Token 持久化管理器,支持凭证的跨进程/跨重启保持: +### 4.2 model/ — 数据模型 -- 存储:JSON 文件(`~/.coding-proxy/tokens.json` 或自定义路径) -- 按 provider 名称(`"github"` / `"google"`)分区存储 -- 提供 `get(name)` / `set(name, tokens)` / `load()` 接口 +数据模型正交分解,遵循单一职责原则: -**凭证合并优先级**(在 `server/factory.py` 中实现):Token Store > config.yaml +| 子模块 | 文件 | 核心类型 | +| ------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| **vendor** | [`model/vendor.py`](../src/coding/proxy/model/vendor.py) | `UsageInfo`, `VendorResponse`, `NoCompatibleVendorError`, `RequestCapabilities`, `VendorCapabilities`, `CapabilityLossReason` | +| **compat** | [`model/compat.py`](../src/coding/proxy/model/compat.py) | `CanonicalRequest`, `CompatibilityDecision`, `CompatibilityProfile`, `CompatibilityStatus`, `CompatibilityTrace` | +| **constants** | [`model/constants.py`](../src/coding/proxy/model/constants.py) | `PROXY_SKIP_HEADERS`, `RESPONSE_SANITIZE_SKIP_HEADERS` 等常量 | +| **pricing** | [`model/pricing.py`](../src/coding/proxy/model/pricing.py) | `ModelPricing`, `CostValue`, `Currency` | +| **token** | [`model/token.py`](../src/coding/proxy/model/token.py) | Token 相关模型 | +| **auth** | [`model/auth.py`](../src/coding/proxy/model/auth.py) | 认证相关模型 | -### 5.6 config/ -- 配置模块 +### 4.3 auth/ — 认证模块 -#### 5.6.1 正交分解结构 +| 组件 | 文件 | 职责 | +| ---------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------- | +| **GitHubDeviceFlowProvider** | [`providers/github.py`](../src/coding/proxy/auth/providers/github.py) | GitHub Device Authorization Grant | +| **GoogleOAuthProvider** | [`providers/google.py`](../src/coding/proxy/auth/providers/google.py) | OAuth 2.0 Authorization Code + Refresh Token | +| **RuntimeReauthCoordinator** | [`runtime.py`](../src/coding/proxy/auth/runtime.py) | 运行时 OAuth 重认证协调(IDLE → PENDING → COMPLETED / FAILED) | +| **TokenStoreManager** | [`store.py`](../src/coding/proxy/auth/store.py) | Token 持久化(JSON 文件),按 provider 分区存储 | -配置模型已从单体 `schema.py` 正交拆分为 5 个子模块: +### 4.4 logging/ — 日志模块 -| 子模块 | 文件 | 核心类型 | -| --------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| **server** | [`config/server.py`](../src/coding/proxy/config/server.py) | `ServerConfig`, `DatabaseConfig`, `LoggingConfig` | -| **vendors** | [`config/vendors.py`](../src/coding/proxy/config/vendors.py) | `VendorConfig`, `VendorType`, `AnthropicConfig`, `CopilotConfig`, `AntigravityConfig`, `ZhipuConfig` | -| **resiliency** | [`config/resiliency.py`](../src/coding/proxy/config/resiliency.py) | `CircuitBreakerConfig`, `RetryConfig`, `FailoverConfig`, `QuotaGuardConfig` | -| **routing** | [`config/routing.py`](../src/coding/proxy/config/routing.py) | `VendorType`, `VendorConfig`, `ModelMappingRule`, `ModelPricingEntry` | -| **auth_schema** | [`config/auth_schema.py`](../src/coding/proxy/config/auth_schema.py) | `AuthConfig` | +| 组件 | 文件 | 职责 | +| --------------- | -------------------------------------------------- | ------------------------------------------ | +| **TokenLogger** | [`db.py`](../src/coding/proxy/logging/db.py) | SQLite 用量持久化、窗口查询、evidence 记录 | +| **统计工具** | [`stats.py`](../src/coding/proxy/logging/stats.py) | 统计查询与 Rich 格式化展示 | -`config/schema.py` 作为聚合入口点 re-export 所有符号,并保留 `ProxyConfig` 顶层模型及旧格式迁移逻辑。 +**usage_log 表结构**: -#### 5.6.2 vendors 列表格式(推荐) +| 列名 | 类型 | 说明 | +| ----------------------- | ---------- | ------------ | +| `id` | INTEGER PK | 自增主键 | +| `ts` | TEXT | ISO 8601 UTC | +| `vendor` | TEXT | 供应商标识 | +| `model_requested` | TEXT | 请求模型名 | +| `model_served` | TEXT | 实际模型名 | +| `input_tokens` | INTEGER | 输入 Token | +| `output_tokens` | INTEGER | 输出 Token | +| `cache_creation_tokens` | INTEGER | 缓存创建 | +| `cache_read_tokens` | INTEGER | 缓存读取 | +| `duration_ms` | INTEGER | 耗时(ms) | +| `success` | BOOLEAN | 是否成功 | +| `failover` | BOOLEAN | 是否故障转移 | +| `request_id` | TEXT | 请求 ID | -新的 `vendors` 列表格式是推荐配置方式,每个 vendor 自包含其弹性配置: +### 4.5 server/ — 服务模块 -```yaml -vendors: - - vendor: anthropic - enabled: true - base_url: https://api.anthropic.com - timeout_ms: 300000 - circuit_breaker: - failure_threshold: 3 - recovery_timeout_seconds: 300 - quota_guard: - enabled: false - weekly_quota_guard: - enabled: false - - - vendor: copilot - enabled: false - github_token: "${GITHUB_TOKEN}" - account_type: individual - circuit_breaker: - failure_threshold: 3 - quota_guard: - enabled: true - token_budget: 3000000 - window_hours: 4.0 - - - vendor: zhipu - enabled: true - api_key: "${ZHIPU_API_KEY}" - # 无 circuit_breaker -> 终端层 - -tiers: [anthropic, copilot, zhipu] # 显式优先级(可选) -``` - -#### 5.6.3 Legacy Flat 格式(向后兼容) - -旧的 flat 格式字段(`primary`/`copilot`/`antigravity`/`fallback`/`circuit_breaker`/`*_quota_guard`)仍受支持,通过 `ProxyConfig._migrate_legacy_fields()` 自动迁移为 vendors 列表格式。 - -迁移规则: -1. `anthropic` 字段名 -> `primary` -2. `zhipu` 字段名 -> `fallback` -3. 若无 `vendors` 字段,从 legacy flat 字段自动生成 vendors 列表 -4. 迁移时发出 INFO 日志建议迁移至新格式 - -#### 5.6.4 配置搜索优先级([`config/loader.py`](../src/coding/proxy/config/loader.py)) - -```mermaid -flowchart TD - A["CLI --config 参数
(显式指定)"] -->|"未指定"| B["./config.yaml
(项目根目录)"] - B -->|"不存在"| C["~/.coding-proxy/config.yaml
(用户目录)"] - C -->|"不存在"| D["Pydantic 默认值"] - - style A fill:#1a5276,color:#fff - style D fill:#7b241c,color:#fff -``` - -**环境变量展开**:语法 `${VARIABLE_NAME}`,递归处理 dict/list/str,未定义变量保留原文。 - -### 5.7 logging/ -- 日志模块 - -#### 5.7.1 usage_log 表结构([`logging/db.py`](../src/coding/proxy/logging/db.py)) - -| 列名 | 类型 | 说明 | -| ----------------------- | ---------- | ----------------------------------------------------------------------- | -| `id` | INTEGER PK | 自增主键 | -| `ts` | TEXT | 时间戳(ISO 8601 格式,UTC) | -| `vendor` | TEXT | 供应商标识(`"anthropic"` / `"copilot"` / `"antigravity"` / `"zhipu"`) | -| `model_requested` | TEXT | 客户端请求的模型名称 | -| `model_served` | TEXT | 实际使用的模型名称 | -| `input_tokens` | INTEGER | 输入 Token 数 | -| `output_tokens` | INTEGER | 输出 Token 数 | -| `cache_creation_tokens` | INTEGER | 缓存创建 Token 数 | -| `cache_read_tokens` | INTEGER | 缓存读取 Token 数 | -| `duration_ms` | INTEGER | 请求耗时(毫秒) | -| `success` | BOOLEAN | 是否成功 | -| `failover` | BOOLEAN | 是否经过故障转移 | -| `request_id` | TEXT | 请求 ID | - -**索引**:`idx_usage_ts`(时间戳)、`idx_usage_vendor`(供应商名) - -**Evidence 记录**(Copilot 专用):`log_evidence()` 方法记录原始用量 JSON 与解析结果的对照,用于诊断 OpenAI -> Anthropic 用量映射准确性。 - -### 5.8 server/ -- 服务模块 +| 文件 | 职责 | +| --------------------------------------------------------------------------- | --------------------------------------------------------- | +| [`app.py`](../src/coding/proxy/server/app.py) | FastAPI 应用工厂 `create_app()` + `lifespan` 生命周期管理 | +| [`factory.py`](../src/coding/proxy/server/factory.py) | Vendor/Tier 构建工厂 + 凭证解析 | +| [`routes.py`](../src/coding/proxy/server/routes.py) | 路由端点按职责分组注册 | +| [`request_normalizer.py`](../src/coding/proxy/server/request_normalizer.py) | 入站请求标准化(清洗供应商私有块) | +| [`responses.py`](../src/coding/proxy/server/responses.py) | 响应辅助工具(JSON error / stream error 构建) | +| [`dashboard.py`](../src/coding/proxy/server/dashboard.py) | 状态面板(Web Dashboard) | -#### 5.8.1 正交分解 - -| 文件 | 职责 | -| ---------------------------------------------------------------------------------- | --------------------------------------------------------- | -| [`server/app.py`](../src/coding/proxy/server/app.py) | FastAPI 应用工厂 `create_app()` + `lifespan` 生命周期管理 | -| [`server/factory.py`](../src/coding/proxy/server/factory.py) | Vendor/Tier 构建工厂 + 凭证解析 | -| [`server/routes.py`](../src/coding/proxy/server/routes.py) | 路由端点按职责分组注册 | -| [`server/request_normalizer.py`](../src/coding/proxy/server/request_normalizer.py) | 入站请求标准化(清洗供应商私有块) | -| [`server/responses.py`](../src/coding/proxy/server/responses.py) | 响应辅助工具(JSON error / stream error 构建) | - -#### 5.8.2 API 端点清单 +**API 端点清单**: | 端点 | 方法 | 分组 | 说明 | | --------------------------- | -------- | ------- | -------------------------------------------- | | `/v1/messages` | POST | core | 代理 Anthropic Messages API(流式 + 非流式) | | `/v1/messages/count_tokens` | POST | core | Token 计数透传(旁路直通 Anthropic) | | `/health` | GET | health | 健康检查 | -| `HEAD /` / `GET /` | HEAD/GET | health | 根路径连通性探测(Claude Code 建连前发送) | +| `HEAD /` / `GET /` | HEAD/GET | health | 根路径连通性探测 | | `/api/status` | GET | status | 各 tier 的 CB/QG/WQG/RL/诊断状态 | | `/api/reset` | POST | admin | 重置所有 tier 的熔断器和配额守卫 | | `/api/copilot/diagnostics` | GET | copilot | Copilot 认证与交换链路脱敏诊断 | @@ -1326,143 +420,35 @@ flowchart TD | `/api/reauth/status` | GET | reauth | 运行时重认证状态查询 | | `/api/reauth/{provider}` | POST | reauth | 手动触发指定 provider 重认证 | ---- - -## 6. 配置系统设计 - -### 6.1 完整配置字段参考(vendors 列表格式) - -**server -- 服务器配置** - -| 字段 | 类型 | 默认值 | 说明 | -| ------ | ---- | ------------- | -------- | -| `host` | str | `"127.0.0.1"` | 监听地址 | -| `port` | int | `8046` | 监听端口 | - -**database -- 数据库配置** - -| 字段 | 类型 | 默认值 | 说明 | -| -------------------------- | ---- | ----------------------------------- | --------------------- | -| `path` | str | `"~/.coding-proxy/usage.db"` | SQLite 数据库文件路径 | -| `compat_state_path` | str | `"~/.coding-proxy/compat_state.db"` | 兼容性会话存储路径 | -| `compat_state_ttl_seconds` | int | `86400` | 兼容性会话 TTL(秒) | - -**logging -- 日志配置** - -| 字段 | 类型 | 默认值 | 说明 | -| ------- | ----------- | -------- | --------------------------------- | -| `level` | str | `"INFO"` | 日志级别 | -| `file` | str \| null | `null` | 日志文件路径(null 输出到控制台) | - -**auth -- 认证配置** - -| 字段 | 类型 | 默认值 | 说明 | -| ------------------ | ---- | ------ | ------------------------------------ | -| `token_store_path` | str | `""` | Token Store 文件路径(空则使用默认) | - -**VendorConfig 通用字段** - -| 字段 | 类型 | 默认值 | 说明 | -| ------------ | ---- | -------- | ------------------------------------------------------------- | -| `vendor` | enum | -- | 供应商类型:`anthropic` / `copilot` / `antigravity` / `zhipu` | -| `enabled` | bool | `true` | 是否启用 | -| `base_url` | str | `""` | API 基础 URL(留空使用默认值) | -| `timeout_ms` | int | `300000` | 请求超时(毫秒) | - -**VendorConfig 弹性字段** - -| 字段 | 类型 | 默认值 | 说明 | -| -------------------- | -------------- | -------------------- | --------------------------- | -| `circuit_breaker` | config \| None | `None` | 熔断器配置(None = 终端层) | -| `retry` | config | `RetryConfig()` | 重试策略配置 | -| `quota_guard` | config | `QuotaGuardConfig()` | 日度配额守卫配置 | -| `weekly_quota_guard` | config | `QuotaGuardConfig()` | 周度配额守卫配置 | - -**Copilot 专属字段** +### 4.6 streaming/ — 流式处理 -| 字段 | 类型 | 默认值 | 说明 | -| -------------------------- | ---- | --------------- | -------------------------------------------------- | -| `github_token` | str | `""` | GitHub OAuth token / PAT(支持 `${ENV_VAR}`) | -| `account_type` | str | `"individual"` | 账号类型:`individual` / `business` / `enterprise` | -| `token_url` | str | `"https://..."` | Token 交换端点 | -| `models_cache_ttl_seconds` | int | `300` | 模型列表缓存 TTL | +[`streaming/anthropic_compat.py`](../src/coding/proxy/streaming/anthropic_compat.py) 提供 Anthropic 兼容的流式处理层,负责 SSE 事件的重构与适配。 -**Antigravity 专属字段** - -| 字段 | 类型 | 默认值 | 说明 | -| ---------------- | ---- | ----------------------------------- | --------------------------- | -| `client_id` | str | `""` | Google OAuth2 Client ID | -| `client_secret` | str | `""` | Google OAuth2 Client Secret | -| `refresh_token` | str | `""` | Google OAuth2 Refresh Token | -| `model_endpoint` | str | `"models/claude-sonnet-4-20250514"` | Gemini 模型端点路径 | - -**Zhipu 专属字段** - -| 字段 | 类型 | 默认值 | 说明 | -| --------- | ---- | ------ | --------------------------------- | -| `api_key` | str | `""` | 智谱 API Key(支持 `${ENV_VAR}`) | - -> **弹性参数速查**:以下 4 类参数的详细语义参见对应设计模式章节——[§3.2 CircuitBreaker](#32-circuit-breaker熔断器模式)、[§3.7 QuotaGuard](#37-state-machine状态机模式--quotaguard)、[§3.12 Retry](#312-retry-with-full-jitter带完全抖动的重试模式)、[§4.4 故障转移判定](#44-故障转移判定逻辑)。 - -**CircuitBreakerConfig / QuotaGuardConfig / RetryConfig / FailoverConfig — 弹性参数一览** - -| 配置类 | 字段 | 类型 | 默认值 | -| ------------ | ----------------------------------------------------------------------------------------------- | --------------------------------- | --------------------------------------- | -| **CB** | `failure_threshold` / `recovery_timeout_seconds` / `success_threshold` / `max_recovery_seconds` | int / int / int / int | `3` / `300` / `2` / `3600` | -| **QG** | `enabled` / `token_budget` / `window_hours` / `threshold_percent` / `probe_interval_seconds` | bool / int / float / float / int | `false` / `0` / `5.0` / `99.0` / `300` | -| **Retry** | `max_retries` / `initial_delay_ms` / `max_delay_ms` / `backoff_multiplier` / `jitter` | int / int / int / float / bool | `2` / `500` / `5000` / `2.0` / `true` | -| **Failover** | `status_codes` / `error_types` / `error_message_patterns` | list[int] / list[str] / list[str] | `[429,403,503,500]` / 见 §4.4 / 见 §4.4 | +--- -**ModelMappingRule 字段** +## 5. 可扩展性设计 -| 字段 | 类型 | 说明 | -| ---------- | --------- | ----------------------------------------- | -| `pattern` | str | 匹配模式(精确/通配符/正则) | -| `target` | str | 目标模型名称 | -| `is_regex` | bool | 是否为正则表达式(默认 `false`) | -| `vendors` | list[str] | 规则作用域(留空仅作用于 fallback/zhipu) | +### 5.1 添加新供应商 -**ModelPricingEntry 字段** +根据供应商端点协议,有两条路径: -| 字段 | 类型 | 说明 | -| --------------------------- | ----- | ---------------------------------------------------- | -| `vendor` | str | 供应商名称 | -| `model` | str | 实际模型名 | -| `input_cost_per_mtok` | float | 输入 Token 单价($/百万 token,支持 `$`/`yen` 前缀) | -| `output_cost_per_mtok` | float | 输出 Token 单价 | -| `cache_write_cost_per_mtok` | float | 缓存创建 Token 单价 | -| `cache_read_cost_per_mtok` | float | 缓存读取 Token 单价 | +#### 路径 A:原生 Anthropic 兼容供应商(推荐,适用于端点已支持 Anthropic Messages API 的场景) -**tiers -- 显式优先级** +1. 在 `vendors/` 下创建新模块,继承 [`NativeAnthropicVendor`](../src/coding/proxy/vendors/native_anthropic.py) +2. 设置 `_vendor_name` 和 `_display_name` 类属性 +3. 在 [`config/routing.py`](../src/coding/proxy/config/routing.py) 的 `VendorType` 中添加新类型 +4. 在 [`config/vendors.py`](../src/coding/proxy/config/vendors.py) 中添加专属 Config(或复用 `api_key` 字段) +5. 在 [`server/factory.py`](../src/coding/proxy/server/factory.py) 的工厂函数中添加新分支 +6. 在配置文件的 `vendors` 列表中添加新条目 -| 字段 | 类型 | 说明 | -| ------- | ------------------------ | ------------------------------------------ | -| `tiers` | list[VendorType] \| None | 降级链路优先级(None 时回退 vendors 顺序) | +#### 路径 B:自定义协议供应商(适用于需要格式转换的场景) ---- +1. 在 `vendors/` 下创建新模块,继承 [`BaseVendor`](../src/coding/proxy/vendors/base.py) +2. 实现必需的抽象方法:`get_name()` 和 `_prepare_request()` +3. 按需覆写钩子方法:`map_model()`、`get_capabilities()`、`check_health()`、`_on_error_status()` 等 +4. 同路径 A 的步骤 3-6 -## 7. 可扩展性设计 - -### 7.1 添加新供应商 - -1. 在 `vendors/` 下创建新模块,继承 `BaseVendor` -2. 实现必需的抽象方法: - - `get_name()` -- 返回供应商标识字符串 - - `_prepare_request()` -- 转换请求体和请求头(异步,支持 token 刷新等异步操作) -3. 按需覆写钩子方法: - - `map_model()` -- 模型名称映射 - - `get_capabilities()` -- 能力声明 - - `check_health()` -- 健康探测 - - `_on_error_status()` -- 错误时标记 token 失效 - - `_get_endpoint()` -- 自定义端点 - - `send_message()` / `send_message_stream()` -- 自定义端点或格式转换 - - `close()` -- 释放额外资源 -4. 在 [`config/routing.py`](../src/coding/proxy/config/routing.py) 的 `VendorType` 中添加新类型 -5. 在 [`config/routing.py`](../src/coding/proxy/config/routing.py) 的 `VendorConfig` 中添加专属字段 -6. 在 [`server/factory.py`](../src/coding/proxy/server/factory.py) 的 `_create_vendor_from_config()` 中添加新分支 -7. 在配置文件的 `vendors` 列表中添加新条目 - -### 7.2 添加新映射规则 +### 5.2 添加新映射规则 在配置文件的 `model_mapping` 节添加规则即可,无需修改代码: @@ -1479,153 +465,26 @@ model_mapping: 匹配优先级确保精确匹配不会被通配符覆盖。 -### 7.3 自定义故障转移策略 - -通过配置文件调整 `failover` 节的三个字段即可自定义触发条件: +### 5.3 自定义故障转移策略 -- 增减 `status_codes` 控制哪些 HTTP 状态码触发切换 -- 增减 `error_types` 控制哪些 Anthropic 错误类型触发切换 -- 增减 `error_message_patterns` 控制哪些错误消息关键词触发切换 +通过配置文件调整 `failover` 节的三个字段即可自定义触发条件。参见 [配置参考 -- FailoverConfig](./arch/config-reference.md#elastic-params)。 -如需更复杂的策略,可覆写子类的 `should_trigger_failover()` 方法。 +### 5.4 自定义配额管理策略 -### 7.4 自定义配额管理策略 - -通过配置文件分别调整 `quota_guard` / `weekly_quota_guard` 的参数,可为每个供应商独立配置多层级配额管理策略: - -```yaml -vendors: - - vendor: anthropic - quota_guard: # 日度配额 - enabled: true - token_budget: 5000000 - window_hours: 5.0 - threshold_percent: 95.0 - weekly_quota_guard: # 周度配额 - enabled: true - token_budget: 20000000 - window_hours: 168.0 # 7 天 - threshold_percent: 90.0 -``` +通过配置文件分别调整 `quota_guard` / `weekly_quota_guard` 的参数,可为每个供应商独立配置多层级配额管理策略。参见 [配置参考 -- QuotaGuardConfig](./arch/config-reference.md#elastic-params)。 --- -## 8. convert/ 模块设计 - -### 8.1 请求转换(Anthropic -> Gemini) - -**应用位置**:[`convert/anthropic_to_gemini.py`](../src/coding/proxy/convert/anthropic_to_gemini.py) -- `convert_request()` - -**转换映射**: - -| Anthropic 字段 | Gemini 字段 | 说明 | -| --------------------------------- | ---------------------------------- | -------------------------------------------------- | -| `system`(str \| list) | `systemInstruction.parts[].text` | 支持字符串和文本块列表两种格式 | -| `messages[]` | `contents[]` | 角色映射:`assistant` -> `model`,`user` -> `user` | -| `content`(text) | `parts[].text` | 文本内容块 | -| `content`(image) | `parts[].inlineData` | Base64 数据 + MIME 类型 | -| `content`(tool_use) | `parts[].functionCall` | `name` + `input` -> `args` | -| `content`(tool_result) | `parts[].functionResponse` | `tool_use_id` -> `name`,`content` -> `result` | -| `max_tokens` | `generationConfig.maxOutputTokens` | | -| `temperature` / `top_p` / `top_k` | `generationConfig.*` | 参数名驼峰转换 | -| `stop_sequences` | `generationConfig.stopSequences` | | - -**不支持的字段**(静默剥离并记录 WARNING):`tools`、`tool_choice`、`metadata`、`extended_thinking`、`thinking` - -### 8.2 响应转换(Gemini -> Anthropic) - -**应用位置**:[`convert/gemini_to_anthropic.py`](../src/coding/proxy/convert/gemini_to_anthropic.py) -- `convert_response()` / `extract_usage()` - -**finishReason 映射**: - -| Gemini | Anthropic | -| --------------------------------- | ------------ | -| `STOP` | `end_turn` | -| `MAX_TOKENS` | `max_tokens` | -| `SAFETY` / `RECITATION` / `OTHER` | `end_turn` | - -**Parts 转换**: -- `text` -> `{"type": "text", "text": "..."}` -- `functionCall` -> `{"type": "tool_use", "id": "toolu_...", "name": "...", "input": {...}}` - -**Usage 提取**: -- `usageMetadata.promptTokenCount` -> `input_tokens` -- `usageMetadata.candidatesTokenCount` -> `output_tokens` -- 缓存字段填 0(Gemini 不直接暴露缓存信息) - -### 8.3 SSE 流适配 - -**应用位置**:[`convert/gemini_sse_adapter.py`](../src/coding/proxy/convert/gemini_sse_adapter.py) -- `adapt_sse_stream()` - -将 Gemini SSE 流重构为 Anthropic 消息生命周期事件序列: - -```mermaid -flowchart LR - Input["Gemini SSE chunks"] --> MS["message_start
← 首次收到内容时发出"] - MS --> CBS["content_block_start
← 内容块开始"] - CBS --> CBD["content_block_delta*
← 增量文本"] - CBD --> CBS2["content_block_stop
← 内容块结束"] - CBS2 --> MD["message_delta
← stop_reason + output_tokens"] - MD --> MSP["message_stop
← 消息结束"] - - style Input fill:#1a5276,color:#fff - style MSP fill:#196f3d,color:#fff -``` - -**边界情况处理**: -- 空 parts 后跟有 text 的 chunk -> 延迟发出 `message_start` + `content_block_start` -- 流结束但未收到 `finishReason` -> 补发默认 `message_delta`(`stop_reason: "end_turn"`)+ `message_stop` - -### 8.4 OpenAI 格式转换 - -**应用位置**: -- [`convert/anthropic_to_openai.py`](../src/coding/proxy/convert/anthropic_to_openai.py) -- Anthropic -> OpenAI Chat Completions 请求格式 -- [`convert/openai_to_anthropic.py`](../src/coding/proxy/convert/openai_to_anthropic.py) -- OpenAI Chat Completions -> Anthropic 响应格式 - -专为 CopilotVendor 适配,处理 Anthropic Messages API 与 OpenAI Chat Completions API 之间的双向格式差异(角色映射、工具格式、usage 字段名等)。 - ---- +## 6. 文档索引 -## 9. 测试策略 - -### 9.1 单元测试覆盖 - -| 测试文件 | 覆盖范围 | -| ---------------------------- | ---------------------------------------------------------------------------------- | -| `test_circuit_breaker.py` | 状态转换(CLOSED->OPEN->HALF_OPEN->CLOSED)、恢复超时、指数退避、手动重置 | -| `test_quota_guard.py` | 配额守卫状态机、预算追踪、探测机制、基线加载 | -| `test_model_mapper.py` | 精确匹配、正则匹配、Glob 匹配、默认回退、空规则集 | -| `test_vendor_tier.py` | VendorTier 可执行判断、成功/失败记录、终端判定、三层门控、Rate Limit deadline | -| `test_vendors.py` | 请求头过滤、模型映射、故障转移判断、数据类默认值 | -| `test_copilot_vendor.py` | CopilotTokenManager 交换/缓存/过期/失效、CopilotVendor 请求准备 | -| `test_antigravity_vendor.py` | GoogleOAuthTokenManager 刷新/缓存/过期/失效、AntigravityVendor 格式转换+token 注入 | -| `test_convert_request.py` | Anthropic->Gemini 请求转换(文本、多轮、system、图片、工具、参数映射) | -| `test_convert_response.py` | Gemini->Anthropic 响应转换(文本、多部件、usage 提取、finishReason 映射) | -| `test_convert_sse.py` | Gemini SSE->Anthropic SSE 流适配(单/多 chunk、各 finishReason、边界情况) | -| `test_convert_openai.py` | Anthropic<->OpenAI 双向格式转换 | -| `test_router_chain.py` | N-tier 链式路由(2/3/4-tier 降级、CB/QG 跳过、流式/非流式、连接异常) | -| `test_executor.py` | _RouteExecutor 门控逻辑、能力匹配、兼容性决策、语义拒绝、错误分类 | -| `test_error_classifier.py` | 请求能力画像提取、语义拒绝判定、错误 payload 解析 | -| `test_rate_limit.py` | Rate limit header 解析、deadline 计算、cap error 检测 | -| `test_retry.py` | RetryConfig 参数、delay 计算、可重试异常判定 | -| `test_usage_recorder.py` | UsageRecorder 用量构建、定价日志、evidence 记录 | -| `test_session_manager.py` | RouteSessionManager 会话创建/上下文应用/持久化 | -| `test_compat_canonical.py` | CanonicalRequest 构建、session_key 派生、thinking 提取、消息部分解析 | -| `test_config_loader.py` | 配置文件搜索优先级、环境变量展开、缺失文件处理、vendors 格式解析 | -| `test_config_schema.py` | ProxyConfig 校验、legacy 迁移、tiers 引用校验、vendor 专属字段 warning | -| `test_token_logger.py` | 用量记录、窗口查询、按供应商/模型过滤、evidence 记录 | -| `test_auth_runtime.py` | RuntimeReauthCoordinator 状态机、幂等触发、锁保护 | -| `test_auth_store.py` | TokenStoreManager 存取、load/save、TTL | -| `test_request_normalizer.py` | 请求标准化:私有块清洗、tool_use_id 重写、fatal_reasons | -| `test_pricing.py` | PricingTable 加载、单价查询(精确+规范化)、费用计算、币种一致性 | - -### 9.2 测试工具 - -- **pytest** (>=9.0) -- 测试框架 -- **pytest-asyncio** (>=1.3) -- 异步测试支持 -- **monkeypatch** -- 环境变量和工作目录隔离 -- **tmp_path** -- 临时文件测试 -- **respx** -- httpx Mock(用于 vendor 集成测试) +| 文档 | 内容 | +| ----------------------------------------- | ------------------------------------------------------------------------------- | +| [设计模式详解](./arch/design-patterns.md) | 13 种设计模式(Template Method / Circuit Breaker / State Machine 等)与工程模式 | +| [供应商模块](./arch/vendors.md) | 供应商分类体系、9 个具体供应商 + 2 个抽象基类、数据类型 | +| [路由模块](./arch/routing.md) | 12 个路由子模块详解(VendorTier / Executor / CB / QG / Retry 等) | +| [配置参考](./arch/config-reference.md) | 完整配置字段参考、弹性参数规范来源(SSOT) | +| [格式转换](./arch/convert.md) | Anthropic ↔ Gemini ↔ OpenAI 三向格式转换详解 | +| [测试策略](./arch/testing.md) | 48 个测试文件按子系统分组的覆盖范围与工具链 | --- diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md new file mode 100644 index 0000000..6b950cf --- /dev/null +++ b/docs/guide/api-reference.md @@ -0,0 +1,362 @@ +# HTTP API 端点 + +
+📑 目录(点击展开) + +- [HTTP API 端点](#http-api-端点) + - [1. HEAD / 和 GET /](#1-head--和-get-) + - [2. POST /v1/messages](#2-post-v1messages) + - [2.1 请求格式](#21-请求格式) + - [2.2 非流式响应](#22-非流式响应) + - [2.3 流式响应(SSE)](#23-流式响应sse) + - [2.4 错误响应](#24-错误响应) + - [2.5 请求规范化行为](#25-请求规范化行为) + - [3. POST /v1/messages/count\_tokens](#3-post-v1messagescount_tokens) + - [4. GET /health](#4-get-health) + - [5. GET /api/status](#5-get-apistatus) + - [6. POST /api/reset](#6-post-apireset) + - [7. GET /api/copilot/diagnostics](#7-get-apicopilotdiagnostics) + - [8. GET /api/copilot/models](#8-get-apicopilotmodels) + - [9. GET /api/reauth/status](#9-get-apireauthstatus) + - [10. POST /api/reauth/{provider}](#10-post-apireauthprovider) + - [11. Dashboard 端点](#11-dashboard-端点) + - [11.1 GET /dashboard](#111-get-dashboard) + - [11.2 GET /api/dashboard/summary](#112-get-apidashboardsummary) + - [11.3 GET /api/dashboard/timeline](#113-get-apidashboardtimeline) + +
+ +## 1. HEAD / 和 GET / + +根路径连通性探针。Claude Code 在建立连接前发送 `HEAD /` 作为健康检查,代理返回 HTTP 200 空响应。 + +```bash +curl -I http://127.0.0.1:8046/ +# HTTP/1.1 200 OK +``` + +## 2. POST /v1/messages + +代理 Anthropic Messages API,支持流式(SSE)与非流式请求。请求经过规范化处理后,路由至配置的 vendor tier 链;若当前 tier 不可用或返回可恢复错误,自动故障转移至下一 tier。 + +### 2.1 请求格式 + +**请求头** + +| 请求头 | 必填 | 说明 | +| ------------------- | ---- | --------------------------------------------------------------------------------- | +| `Content-Type` | ✓ | 固定为 `application/json` | +| `Authorization` | ✗ | 格式 `Bearer `;对 Anthropic vendor 透传,对其他 vendor 由代理内部凭证管理 | +| `anthropic-version` | ✗ | Anthropic API 版本,建议传 `2023-06-01`;透传至上游 | +| `anthropic-beta` | ✗ | Beta 功能标识,透传至上游 | + +> `hop-by-hop` 头(如 `Connection`、`Transfer-Encoding`)会在转发前自动过滤。 + +**请求体参数** + +| 字段 | 类型 | 必填 | 约束 | 说明 | +| ---------------- | --------------- | ---- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `model` | string | ✓ | 非空 | 目标模型标识。经 [`model_mapping`](./vendors.md#5-model_mapping--模型映射规则) 规则映射后路由至实际 vendor 模型 | +| `messages` | array | ✓ | 至少 1 条;`user`/`assistant` 交替;末尾必须为 `user` | 对话历史 | +| `max_tokens` | integer | ✗ | > 0 | 最大输出 token 数 | +| `stream` | boolean | ✗ | 默认 `false` | 是否以 SSE 流式返回 | +| `temperature` | number | ✗ | `[0, 2]` | 采样温度 | +| `top_p` | number | ✗ | `(0, 1]` | Top-p 采样 | +| `top_k` | integer | ✗ | ≥ 1 | Top-k 采样 | +| `stop_sequences` | array[string] | ✗ | | 提前停止的字符串序列 | +| `system` | string \| array | ✗ | | 系统提示词 | +| `tools` | array | ✗ | | 工具定义 | +| `tool_choice` | object | ✗ | | 工具选择策略 | +| `thinking` | object | ✗ | 需 `budget_tokens`;部分 vendor 不支持 | Extended Thinking 配置 | +| `metadata` | object | ✗ | | 用户元数据,透传至上游 | + +**消息 content block 类型** + +| 类型 | 适用角色 | 必填字段 | 说明 | +| ------------- | ------------------ | -------------------------------------- | --------------------------------------- | +| `text` | `user`/`assistant` | `text` | 纯文本 | +| `image` | `user` | `source` | 图片;部分 vendor 不支持 | +| `tool_use` | `assistant` | `id`(`toolu_` 前缀)、`name`、`input` | 模型发起工具调用 | +| `tool_result` | `user` | `tool_use_id`、`content` | 工具调用结果 | +| `thinking` | `assistant` | `thinking`、`signature` | Extended Thinking;跨 vendor 时自动剥离 | + +### 2.2 非流式响应 + +**成功响应(HTTP 200)** + +```json +{ + "id": "msg_01XFDUDYJgAACzvnptvVoYEL", + "type": "message", + "role": "assistant", + "content": [ + { "type": "text", "text": "你好!我是 Claude。" } + ], + "model": "claude-sonnet-4-6", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 14, + "output_tokens": 32, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0 + } +} +``` + +**`stop_reason` 枚举** + +| 值 | 含义 | +| --------------- | ---------------------- | +| `end_turn` | 模型自然输出完毕 | +| `tool_use` | 模型发起工具调用 | +| `stop_sequence` | 触发了停止序列 | +| `max_tokens` | 达到 `max_tokens` 上限 | + +### 2.3 流式响应(SSE) + +设置 `"stream": true`,响应以 `text/event-stream` 格式逐块下发。 + +**SSE 事件类型** + +| 事件类型 | 说明 | +| --------------------- | ---------------------------------------------- | +| `message_start` | 消息开始,包含初始元数据 | +| `content_block_start` | 新的 content block 开始 | +| `content_block_delta` | 增量数据(`text_delta` 或 `input_json_delta`) | +| `content_block_stop` | 当前 content block 结束 | +| `message_delta` | 消息级增量(`stop_reason` + `usage`) | +| `message_stop` | 消息结束 | +| `ping` | 心跳 | +| `error` | 流式错误 | + +> 流式模式下,一旦 SSE 流开始发送,代理不再进行 tier 级别的故障转移。若中途出现错误,以 `event: error` 事件通知客户端。 + +### 2.4 错误响应 + +**错误结构** + +```json +{ + "error": { + "type": "invalid_request_error", + "message": "详细错误描述", + "details": ["原因1", "原因2"] + } +} +``` + +**HTTP 状态码对照** + +| HTTP 状态码 | `error.type` | 触发场景 | 可重试 | +| ----------- | ----------------------- | ----------------------------- | ------ | +| `400` | `invalid_request_error` | 请求格式不合规、无兼容 vendor | ✗ | +| `401` | `authentication_error` | 无有效认证凭证 | ✗ | +| `403` | `permission_error` | 权限不足 | ✗ | +| `429` | `rate_limit_error` | 所有 vendor 均触发速率限制 | ✓ | +| `500` | `api_error` | 代理内部异常 | ✓ | +| `502` | `api_error` | 所有 vendor 均不可达 | ✓ | +| `503` | `authentication_error` | Token 获取失败 | ✓ | +| `501` | `not_implemented` | 请求的端点无可用 vendor 处理 | ✗ | + +### 2.5 请求规范化行为 + +代理在转发前自动进行规范化,对调用方透明。 + +**自动修复(静默处理)** + +| 问题 | 处理方式 | +| ------------------------------------------ | --------------------------------------------------------------------------------------------------- | +| `tool_use_id` 格式不符(非 `toolu_` 前缀) | 自动重写为合规格式 | +| `tool_result` 出现在 `assistant` 消息中 | 收集该 block;转发至 Anthropic tier 时执行重定位,其他 vendor 保留原位不变(首次触发 WARNING 日志) | +| `tool_use` 缺少合法 ID | 自动生成新 ID | + +**致命验证错误(返回 HTTP 400)** + +| 场景 | 错误示例 | +| ------------------------------------------- | ------------------------------------------------------ | +| `tool_use` block 缺少 `id` 字段 | `"tool_use block is missing 'id' field"` | +| `tool_use` block 缺少 `name` 字段 | `"tool_use block missing name for id rewrite"` | +| `tool_result` block 缺少 `tool_use_id` 字段 | `"tool_result block is missing 'tool_use_id' field"` | +| `tool_result` 引用不存在的 `tool_use_id` | `"tool_result references unknown tool_use_id"` | +| 消息角色不交替 | `"messages must alternate between user and assistant"` | +| `messages` 末尾不是 `user` 消息 | `"last message must be from user"` | + +**Thinking Block 跨 Vendor 处理**:请求路由至非 Anthropic vendor 时,assistant 历史消息中的 `thinking` block 会被自动剥离(包含仅 Anthropic 可验证的 `signature`)。不影响当前轮次的 `thinking` 功能配置。 + +## 3. POST /v1/messages/count_tokens + +Token 计数 API 透传。支持所有提供 Anthropic 兼容端点的供应商。 + +```bash +curl -X POST http://127.0.0.1:8046/v1/messages/count_tokens \ + -H "Content-Type: application/json" \ + -H "anthropic-version: 2023-06-01" \ + -d '{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"Hello"}]}' +``` + +| 响应码 | 说明 | +| ------ | ------------------------- | +| `200` | 成功,返回 token 计数结果 | +| `501` | 无可用 vendor 处理此端点 | +| `502` | 上游 API 不可达 | + +## 4. GET /health + +健康检查。 + +```bash +curl http://127.0.0.1:8046/health +# {"status":"ok"} +``` + +## 5. GET /api/status + +查询所有层级的熔断器、配额守卫、周级配额守卫、Rate Limit 及诊断信息。 + +```bash +curl http://127.0.0.1:8046/api/status +``` + +**返回示例**: + +```json +{ + "tiers": [ + { + "name": "zhipu", + "circuit_breaker": { + "state": "closed", + "failure_count": 0, + "current_recovery_seconds": 30 + }, + "quota_guard": { + "state": "within_quota", + "window_usage_tokens": 452000000, + "budget_tokens": 1000000000, + "usage_percent": 45.2 + }, + "rate_limit": { + "is_rate_limited": false, + "remaining_seconds": 0 + } + }, + { + "name": "anthropic", + "circuit_breaker": { + "state": "closed", + "failure_count": 0 + } + } + ] +} +``` + +## 6. POST /api/reset + +重置所有层级的弹性设施。支持可选 JSON body 进行运行时 tier 重排序。 + +```bash +# 仅重置(向后兼容) +curl -X POST http://127.0.0.1:8046/api/reset + +# 重置 + 提升 anthropic 到最高优先级 +curl -X POST http://127.0.0.1:8046/api/reset \ + -H "Content-Type: application/json" \ + -d '{"vendors": ["anthropic"]}' + +# 重置 + 替换整条链路顺序 +curl -X POST http://127.0.0.1:8046/api/reset \ + -H "Content-Type: application/json" \ + -d '{"vendors": ["zhipu", "anthropic", "copilot"]}' +``` + +**重排序语义**: +- 单个 vendor:提升到最高优先级,其余保持相对顺序 +- 多个 vendor:替换整个 N-tier 链路顺序 + +**重置范围**:circuit_breaker(→ CLOSED)、quota_guard(→ WITHIN_QUOTA)、weekly_quota_guard(→ WITHIN_QUOTA)、rate_limit deadline(→ 清除)。 + +## 7. GET /api/copilot/diagnostics + +返回 Copilot 认证与交换链路的脱敏诊断信息。 + +```bash +curl http://127.0.0.1:8046/api/copilot/diagnostics +``` + +若 Copilot 供应商未启用,返回 404。 + +## 8. GET /api/copilot/models + +按需探测当前 Copilot 会话可见的模型列表。 + +```bash +curl http://127.0.0.1:8046/api/copilot/models +``` + +需要有效的 Copilot 凭证;凭证无效时返回 503。 + +## 9. GET /api/reauth/status + +查询运行时重认证状态。 + +```bash +curl http://127.0.0.1:8046/api/reauth/status +``` + +## 10. POST /api/reauth/{provider} + +手动触发指定 provider 的运行时重认证。 + +```bash +curl -X POST http://127.0.0.1:8046/api/reauth/github +# HTTP/1.1 202 Accepted +# {"status":"reauth requested"} +``` + +| 参数 | 说明 | +| ------------ | ---------------------------------- | +| `{provider}` | provider 名称:`github` / `google` | + +返回 202 表示重认证请求已接收,用户需在浏览器中完成授权。 + +## 11. Dashboard 端点 + +为 [Dashboard 看板](./dashboard.md)提供数据的 API 端点。 + +### 11.1 GET /dashboard + +返回 Dashboard HTML 页面。 + +```bash +curl http://127.0.0.1:8046/dashboard +``` + +### 11.2 GET /api/dashboard/summary + +返回 Dashboard 汇总数据(今日 + 所选区间)。 + +```bash +curl "http://127.0.0.1:8046/api/dashboard/summary?days=7" +``` + +| 参数 | 类型 | 默认值 | 说明 | +| ------ | ---- | ------ | ---------------- | +| `days` | int | `7` | 统计天数(1~90) | + +**返回结构**:`version`(版本号)、`today`(今日 KPI)、`range`(区间 KPI)、`failover_stats`(故障转移统计)。 + +### 11.3 GET /api/dashboard/timeline + +返回按天分组的时序数据(用于图表绘制)。 + +```bash +curl "http://127.0.0.1:8046/api/dashboard/timeline?days=30" +``` + +| 参数 | 类型 | 默认值 | 说明 | +| ------ | ---- | ------ | ---------------- | +| `days` | int | `7` | 统计天数(1~90) | + +**返回结构**:`period`(`"day"`)、`count`(天数)、`rows`(按天分组的用量数组)。 diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md new file mode 100644 index 0000000..052d237 --- /dev/null +++ b/docs/guide/cli-reference.md @@ -0,0 +1,238 @@ +# CLI 命令参考 + +
+📑 目录(点击展开) + +- [CLI 命令参考](#cli-命令参考) + - [1. coding-proxy start](#1-coding-proxy-start) + - [2. coding-proxy status](#2-coding-proxy-status) + - [3. coding-proxy usage](#3-coding-proxy-usage) + - [4. coding-proxy reset](#4-coding-proxy-reset) + - [5. coding-proxy auth login](#5-coding-proxy-auth-login) + - [6. coding-proxy auth status](#6-coding-proxy-auth-status) + - [7. coding-proxy auth reauth](#7-coding-proxy-auth-reauth) + - [8. coding-proxy auth logout](#8-coding-proxy-auth-logout) + +
+ +## 1. coding-proxy start + +启动代理服务。 + +```bash +coding-proxy start [OPTIONS] +``` + +| 参数 | 缩写 | 说明 | +| ---------- | ---- | ------------------------ | +| `--config` | `-c` | 配置文件路径 | +| `--port` | `-p` | 监听端口(覆盖配置文件) | +| `--host` | `-h` | 监听地址(覆盖配置文件) | + +**示例**: + +```bash +# 默认配置启动 +coding-proxy start + +# 自定义端口和配置 +coding-proxy start -p 9000 -c ~/my-config.yaml + +# 自定义监听地址和端口 +coding-proxy start --host 0.0.0.0 --port 8046 +``` + +> 若启用了 Copilot 或 Antigravity 供应商但未配置凭证,启动时会自动触发 OAuth 浏览器登录流程。 + +## 2. coding-proxy status + +查看当前代理状态和各供应商层级信息。 + +```bash +coding-proxy status [OPTIONS] +``` + +| 参数 | 缩写 | 说明 | +| -------- | ---- | ------------------------- | +| `--port` | `-p` | 代理服务端口(默认 8046) | + +**输出示例**: + +``` +zhipu + 熔断器: closed 失败=0 + 配额: within_quota 45.2% (452000000/1000000000) + +anthropic + 熔断器: closed 失败=0 +``` + +每个 tier 独立展示名称、熔断器状态和配额守卫状态。 + +**熔断器状态说明**: + +| 状态 | 含义 | +| ----------- | ---------------------------- | +| `closed` | 正常运行 | +| `open` | 熔断中,跳过该层降级到下一层 | +| `half_open` | 恢复测试中 | + +## 3. coding-proxy usage + +查看 Token 使用统计与费用估算。 + +```bash +coding-proxy usage [OPTIONS] +``` + +| 参数 | 缩写 | 说明 | +| ---------- | ---- | --------------------------------------------------------------- | +| `--days` | `-d` | 统计天数(默认 7) | +| `--week` | `-w` | 最近第 N 周统计(按周聚合,默认 1) | +| `--month` | `-m` | 最近第 N 月统计(按月聚合,默认 1) | +| `--total` | `-t` | 统计全部历史记录(按供应商+模型聚合) | +| `--vendor` | `-v` | 过滤供应商(支持逗号分隔多选,如 `anthropic,zhipu`) | +| `--model` | — | 过滤实际服务模型(支持逗号分隔多选,如 `glm-5v-turbo,glm-5.1`) | +| `--db` | — | 数据库文件路径 | + +> **时间维度互斥**,优先级:`-t > -m > -w > -d`。 + +**示例**: + +```bash +# 查看最近 7 天统计(默认) +coding-proxy usage + +# 本周统计(第 1 周) +coding-proxy usage -w 1 + +# 本月统计(第 1 月) +coding-proxy usage -m 1 + +# 全部历史,按供应商+模型聚合 +coding-proxy usage -t + +# 最近 30 天,仅 Anthropic 和智谱供应商 +coding-proxy usage -d 30 -v anthropic,zhipu + +# 按实际服务模型过滤 +coding-proxy usage --model glm-5v-turbo,claude-sonnet-4-6 +``` + +**输出字段说明**: + +| 字段 | 说明 | +| --------------------------------- | -------------------------------------------- | +| 日期 | 统计日期 | +| 供应商 | 处理请求的供应商名称 | +| 请求模型 | 客户端请求的原始模型名称 | +| 实际模型 | 供应商实际使用的模型名称(经映射后) | +| 请求数 | 总请求数 | +| 输入/输出/缓存创建/缓存读取 Token | 各维度 Token 消耗 | +| 总 Token | 所有维度之和 | +| Cost | 基于定价配置计算的费用;未配置定价时显示 `-` | +| 平均耗时(ms) | 平均响应时间 | + +## 4. coding-proxy reset + +手动重置所有层级的熔断器和配额守卫,恢复使用最高优先级供应商。支持运行时重排序 N-tier 链路。 + +```bash +coding-proxy reset [OPTIONS] +``` + +| 参数 | 缩写 | 说明 | +| ---------- | ---- | ----------------------------------------------- | +| `--port` | `-p` | 代理服务端口(默认 8046) | +| `--vendor` | `-v` | 提升/重排序 vendor 优先级(单个或逗号分隔多个) | + +**重排序语义**: +- 单个 vendor:提升该 vendor 到最高优先级,其余保持相对顺序 + ```bash + # 提升 anthropic 到最高优先级 + coding-proxy reset -v anthropic + ``` +- 多个 vendor:替换整个 N-tier 链路顺序 + ```bash + # 替换整条链路 + coding-proxy reset -v anthropic,zhipu,copilot + ``` + +**重置范围**:所有层级的熔断器状态(→ CLOSED)、配额守卫状态(→ WITHIN_QUOTA)、周级配额守卫状态(→ WITHIN_QUOTA)、Rate Limit 截止时间(→ 清除)。 + +## 5. coding-proxy auth login + +执行 OAuth 浏览器登录,获取供应商访问凭证。 + +```bash +coding-proxy auth login [OPTIONS] +``` + +| 参数 | 缩写 | 说明 | +| ------------ | ---- | -------------------------------------------------------- | +| `--provider` | `-p` | 指定 provider(`github` / `google`);省略则依次登录两者 | + +**示例**: + +```bash +# 仅登录 GitHub(Copilot) +coding-proxy auth login -p github + +# 仅登录 Google(Antigravity) +coding-proxy auth login -p google + +# 依次登录两者 +coding-proxy auth login +``` + +## 6. coding-proxy auth status + +查看已登录的 OAuth 凭证状态。 + +```bash +coding-proxy auth status +``` + +**输出示例**: + +``` +github: 有效 有 refresh_token +google: 已过期 无 refresh_token +``` + +## 7. coding-proxy auth reauth + +触发运行中代理的 OAuth 重认证。 + +```bash +coding-proxy auth reauth PROVIDER [OPTIONS] +``` + +| 参数 | 缩写 | 说明 | +| ---------- | ---- | ------------------------------------------ | +| `PROVIDER` | — | provider 名称(必填):`github` / `google` | +| `--port` | `-p` | 代理服务端口(默认 8046) | + +**示例**: + +```bash +# 触发 GitHub 重认证 +coding-proxy auth reauth github + +# 指定端口 +coding-proxy auth reauth google -p 9090 +``` + +> 此命令通过 [`POST /api/reauth/{provider}`](./api-reference.md#510-post-apireauthprovider) 向运行中的代理发送重认证请求,无需重启服务。 + +## 8. coding-proxy auth logout + +清除已存储的 OAuth 凭证。 + +```bash +coding-proxy auth logout [OPTIONS] +``` + +| 参数 | 缩写 | 说明 | +| ------------ | ---- | -------------------------------------- | +| `--provider` | `-p` | 指定 provider;省略则登出所有 provider | diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md new file mode 100644 index 0000000..8872b20 --- /dev/null +++ b/docs/guide/dashboard.md @@ -0,0 +1,63 @@ +# Dashboard 看板 + +coding-proxy 内置 Web 可视化看板,提供流量、用量、故障转移等关键指标的实时监控。 + +## 访问 + +启动代理后,浏览器访问: + +``` +http://127.0.0.1:8046/dashboard +``` + +## 功能概览 + +### KPI 卡片 + +顶部展示核心指标: + +| 卡片 | 说明 | +| ---------------- | ------------------------------------------ | +| 今日请求数 | 当日总请求数(子行显示本周累计) | +| 今日 Token 总量 | 当日总 Token 消耗(子行显示本周累计) | +| 今日输出 Token | 当日输出 Token 数(子行显示本周累计) | +| 今日费用估算 | 基于定价配置的费用估算(子行显示本周累计) | +| 故障转移(今日) | 当日故障转移事件数(子行显示本周累计) | +| 平均延迟(今日) | 当日请求平均响应时间(子行显示本周累计) | + +### 图表 + +| 图表 | 说明 | +| ----------------------------- | ------------------------------------ | +| 供应商状态 | 各供应商实时状态(熔断器、配额守卫) | +| 请求时间线 | 按天的请求量趋势图 | +| 供应商分布 | 各供应商请求占比甜甜圈图 | +| Token 时间线(按供应商) | 各供应商按天的 Token 消耗堆叠图 | +| Token 时间线(按供应商+模型) | 细粒度的模型级 Token 消耗堆叠图 | + +### 时间范围选择器 + +页面右上角提供时间范围选择: + +- **7 天**(默认) +- **30 天** +- **自定义天数**(1~90 天) + +### 自动刷新 + +看板每 **10 分钟**自动刷新数据。也可点击右上角「刷新」按钮手动刷新。 + +### 图表交互 + +- **单击**图例项:隔离显示该系列 +- **Ctrl+单击**图例项:多选显示 +- **Shift+单击**图例项:排除该系列 + +## 数据源 + +看板数据来自以下 API 端点,详见 [API 参考 — Dashboard 端点](./api-reference.md#11-dashboard-端点): + +| 端点 | 说明 | +| ----------------------------- | -------------------- | +| `GET /api/dashboard/summary` | KPI 汇总数据 | +| `GET /api/dashboard/timeline` | 按天分组的时间线数据 | diff --git a/docs/guide/monitoring.md b/docs/guide/monitoring.md new file mode 100644 index 0000000..69c32d2 --- /dev/null +++ b/docs/guide/monitoring.md @@ -0,0 +1,207 @@ +# 监控·运维·排查 + +
+📑 目录(点击展开) + +- [1. 日志查看](#1-日志查看) +- [2. 用量统计](#2-用量统计) +- [3. 健康检查](#3-健康检查) +- [4. Dashboard 监控](#4-dashboard-监控) +- [5. 数据库维护](#5-数据库维护) +- [6. 性能调优参考](#6-性能调优参考) +- [7. 常见使用场景](#7-常见使用场景) +- [8. 故障排查](#8-故障排查) + +
+ +## 1. 日志查看 + +代理日志默认输出到控制台,包含以下关键事件: + +| 事件 | 日志级别 | 示例 | +| --------------- | ------------ | --------------------------------------------------------- | +| 熔断器状态转换 | INFO/WARNING | `Circuit breaker: CLOSED → OPEN (3 consecutive failures)` | +| 故障转移触发 | WARNING | `Primary error 429, failing over` | +| 恢复成功 | INFO | `Circuit breaker: HALF_OPEN → CLOSED (recovered)` | +| Rate Limit 生效 | INFO | `Tier zhipu: rate limit deadline active, 30.0s remaining` | +| 自动登录 | INFO | `Copilot 层缺少有效凭证,启动 GitHub OAuth 登录...` | + +配置调整: + +```yaml +logging: + level: "DEBUG" # 查看详细的模型映射和路由决策 + file: "coding-proxy.log" # 输出到文件 + max_bytes: 5242880 # 单文件 5 MB,触发轮转 + backup_count: 5 # 保留 5 个 gzip 压缩备份 +``` + +## 2. 用量统计 + +```bash +# 最近 7 天(默认) +coding-proxy usage + +# 本周统计(第 1 周) +coding-proxy usage -w 1 + +# 本月统计(第 1 月) +coding-proxy usage -m 1 + +# 全部历史 +coding-proxy usage -t + +# 过滤供应商(逗号分隔多选) +coding-proxy usage -v anthropic,zhipu + +# 过滤模型 +coding-proxy usage --model glm-5v-turbo + +# 指定数据库 +coding-proxy usage --db /path/to/usage.db +``` + +> 完整 CLI 选项参见 [CLI 参考 — usage](./cli-reference.md#3-coding-proxy-usage)。 + +## 3. 健康检查 + +```bash +# 基础检查 +curl http://127.0.0.1:8046/health + +# 详细状态(所有层级的熔断器、配额守卫、Rate Limit、诊断信息) +curl http://127.0.0.1:8046/api/status +``` + +> 端点详情参见 [API 参考](./api-reference.md)。 + +## 4. Dashboard 监控 + +浏览器访问 `http://127.0.0.1:8046/dashboard` 查看 Web 可视化看板。详见 [Dashboard 文档](./dashboard.md)。 + +## 5. 数据库维护 + +用量数据库位于 `~/.coding-proxy/usage.db`(可通过配置修改)。 + +- 采用 SQLite WAL 模式,支持读写并发 +- 当前版本不自动清理历史数据 +- 如需清理,可直接删除数据库文件(重启后自动重建) + +## 6. 性能调优参考 + +| 参数 | 默认值 | 稳定优先 | 敏感快速 | 说明 | +| ---------------------------- | ------ | ---------- | -------- | ---------------------------------------- | +| `timeout_ms` | 300000 | `300000` | `120000` | 长对话保持 5 分钟;短查询可缩短至 2 分钟 | +| `failure_threshold` | 3 | `5` | `2` | 网络稳定环境降低以更快触发降级 | +| `recovery_timeout_seconds` | 300 | `600` | `120` | 给供应商更多恢复时间 vs 更快尝试恢复 | +| `token_budget` | 按计划 | 按计划设定 | — | 设为订阅额度的 95%~99% | +| `window_hours` (quota_guard) | 5.0 | `8.0` | `3.0` | 长窗口更平滑,短窗口更灵敏 | +| `max_retries` | 2 | `3` | `1` | 网络不稳定时增加重试 | + +> 完整弹性参数表参见 [配置字段参考 — 弹性字段](../arch/config-reference.md#5-vendorconfig-弹性字段)。 + +## 7. 常见使用场景 + +### 7.1 上游 API 限流自动降级 + +**现象**:Claude Code 响应变慢或提示 "rate limited" + +**代理行为**:检测到上游返回 `429` → 解析 `retry-after` → 设置 Rate Limit 截止时间 → 熔断器记录失败 → 自动降级到下一供应商 → 恢复后自动切回。 + +**用户操作**:无需干预。通过 [`GET /api/status`](./api-reference.md#5-get-apistatus) 中的 `rate_limit` 字段查看限速状态。 + +### 7.2 配额耗尽后自动降级 + +**现象**:上游供应商返回 `403` 错误,消息含 "quota"、"usage cap"、"limit exceeded"、"capacity" 等关键词 + +**代理行为**:识别关键词 → 配额守卫标记 QUOTA_EXCEEDED → 后续请求自动路由到下一层级。 + +**用户操作**:无需干预。通过 `coding-proxy usage` 查看各供应商请求分布。 + +### 7.3 手动恢复使用主供应商 + +```bash +coding-proxy reset + +# 同时提升指定供应商优先级 +coding-proxy reset -v anthropic +``` + +### 7.4 禁用特定供应商 + +在 `vendors` 列表中设置 `enabled: false`,或通过 [`tiers`](./vendors.md#4-tiers--降级链路优先级) 从降级链中排除。 + +### 7.5 运行时 OAuth 重认证 + +```bash +# CLI +coding-proxy auth reauth github + +# API +curl -X POST http://127.0.0.1:8046/api/reauth/github +``` + +重认证请求发出后(HTTP 202),在浏览器中完成授权即可,无需重启。 + +## 8. 故障排查 + +### 8.1 代理服务无法启动 + +**端口占用**: + +```bash +lsof -i :8046 +coding-proxy start --port 8080 +``` + +**配置文件语法错误**:检查 YAML 缩进、冒号后空格。 + +**Python 版本**:`python --version`(需要 >= 3.12) + +### 8.2 Claude Code 无法连接代理 + +1. 确认代理正在运行:`coding-proxy status` +2. 确认环境变量:`echo $ANTHROPIC_BASE_URL`(应为 `http://127.0.0.1:8046`) +3. 确认端口一致 + +### 8.3 频繁触发故障转移 + +1. 检查上游 API 状态 +2. 查看 `GET /api/status` 中的 `rate_limit` 信息 +3. 适当调高 `failure_threshold`(如 3 → 5) +4. 适当调高 `recovery_timeout_seconds` + +### 8.4 供应商返回错误(通用) + +适用于所有原生 Anthropic 兼容供应商(zhipu、minimax、alibaba、xiaomi、kimi、doubao): + +1. **API Key 错误**:确认对应环境变量已正确设置 +2. **模型不存在**:检查 [`model_mapping`](./vendors.md#5-model_mapping--模型映射规则) 中的 `target` 是否有效 +3. **网络问题**:确认可以访问对应供应商的 `base_url` + +### 8.5 Copilot 认证问题 + +**现象**:GitHub Device Flow 显示 "Congratulations, you're all set!" 但请求仍失败。 + +```bash +# 查看诊断信息 +curl http://127.0.0.1:8046/api/copilot/diagnostics + +# 探查可用模型 +curl http://127.0.0.1:8046/api/copilot/models + +# 重认证 +coding-proxy auth reauth github +``` + +### 8.6 Token 用量不记录 + +1. 确认数据库目录可写:`ls -la ~/.coding-proxy/` +2. 目录不存在时代理会自动创建 +3. 流式请求的用量提取依赖 SSE 事件格式,非标准格式可能无法正确解析 + +### 8.7 count_tokens 请求失败 + +**返回 501**:无可用 vendor 处理此端点。确认至少有一个 Anthropic 兼容供应商已启用。 + +**返回 502**:上游 API 不可达。此端点直接透传,不经过故障转移链。 diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md new file mode 100644 index 0000000..1acf989 --- /dev/null +++ b/docs/guide/quickstart.md @@ -0,0 +1,103 @@ +# 快速开始 + +## 1. 环境要求 + +- **Python** >= 3.12 +- **UV** 包管理器(推荐)或 pip +- **API Key**:至少一个供应商的 API Key(如 [智谱](https://open.bigmodel.cn) 的 `ZHIPU_API_KEY`) +- **Claude Code** 已安装并可用 + +## 2. 安装 + +```bash +# 方式一:使用 UV(推荐) +uv sync + +# 方式二:使用 pip +pip install -e . +``` + +安装完成后,`coding-proxy` 命令即可使用。 + +## 3. 最小配置 + +```bash +# 复制配置模板到项目根目录(模板已内置完整默认值,仅需覆盖密钥) +cp config.default.yaml config.yaml +``` + +设置智谱 API Key(默认 Tier 0 供应商): + +```bash +export ZHIPU_API_KEY="your-api-key-here" +``` + +配置文件中使用 `${ZHIPU_API_KEY}` 引用,代理启动时自动替换。 + +> **安全最佳实践**: +> - API Key 优先使用 `${ENV_VAR}` 环境变量引用,避免明文写入配置文件 +> - `config.yaml` 已在 `.gitignore` 中,不会被提交到版本库 +> - OAuth Token 存储于 `~/.coding-proxy/tokens.json`,建议 `chmod 600` 限制访问 +> - 若设置 `server.host: "0.0.0.0"` 接受外部连接,确保在可信网络环境中运行 + +## 4. 启动服务 + +```bash +# 使用默认配置启动 +coding-proxy start + +# 指定端口 +coding-proxy start --port 8080 + +# 指定配置文件 +coding-proxy start --config /path/to/config.yaml +``` + +启动成功后输出: + +``` +INFO: Started server process [75773] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://127.0.0.1:8046 (Press CTRL+C to quit) +``` + +> 若启用了 Copilot 或 Antigravity 供应商但未配置凭证,启动时会自动触发 OAuth 浏览器登录。 + +## 5. 验证服务 + +```bash +# 健康检查 +curl http://127.0.0.1:8046/health +# 期望返回: {"status":"ok"} + +# 查看代理状态 +coding-proxy status +``` + +## 6. 配置 Claude Code + +将 Claude Code 的 API 端点指向 coding-proxy: + +```bash +export ANTHROPIC_BASE_URL=http://127.0.0.1:8046 +``` + +Claude Code 使用的 OAuth token 会被代理透传到 Anthropic API,无需额外配置认证信息。 + +### 验证集成 + +1. 确保 coding-proxy 正在运行:`coding-proxy status` +2. 使用 Claude Code 发送一条消息 +3. 查看 coding-proxy 的终端日志,确认请求经过代理 +4. 使用 `coding-proxy usage` 查看是否有新的用量记录 + +### 日常使用流程 + +1. **启动代理**:`coding-proxy start`(可使用 `tmux` 后台运行) +2. **OAuth 认证**(如启用 Copilot/Antigravity):启动时自动检查凭证,缺失则触发浏览器登录 +3. **正常使用 Claude Code**:代理在后台透明工作 +4. **定期查看用量**:[`coding-proxy usage`](./cli-reference.md#3-coding-proxy-usage) +5. **按需手动干预**:[`coding-proxy reset`](./cli-reference.md#4-coding-proxy-reset) 强制切回最高优先级供应商 +6. **运行时重认证**:[`coding-proxy auth reauth`](./cli-reference.md#7-coding-proxy-auth-reauth) 无需重启即可刷新凭证 +7. **查看可视化看板**:浏览器访问 `http://127.0.0.1:8046/dashboard` diff --git a/docs/guide/vendors.md b/docs/guide/vendors.md new file mode 100644 index 0000000..5c3eb05 --- /dev/null +++ b/docs/guide/vendors.md @@ -0,0 +1,247 @@ +# 供应商配置 + +
+📑 目录(点击展开) + +- [供应商配置](#供应商配置) + - [1. 供应商分类](#1-供应商分类) + - [2. 通用字段](#2-通用字段) + - [3. 供应商配置详情](#3-供应商配置详情) + - [3.1 anthropic — Anthropic Claude](#31-anthropic--anthropic-claude) + - [3.2 copilot — GitHub Copilot](#32-copilot--github-copilot) + - [3.3 antigravity — Google Antigravity](#33-antigravity--google-antigravity) + - [3.4 zhipu — 智谱 GLM](#34-zhipu--智谱-glm) + - [3.5 minimax — MiniMax](#35-minimax--minimax) + - [3.6 alibaba — 阿里 Qwen](#36-alibaba--阿里-qwen) + - [3.7 xiaomi — 小米 MiMo](#37-xiaomi--小米-mimo) + - [3.8 kimi — Kimi](#38-kimi--kimi) + - [3.9 doubao — 豆包 Doubao](#39-doubao--豆包-doubao) + - [4. tiers — 降级链路优先级](#4-tiers--降级链路优先级) + - [5. model\_mapping — 模型映射规则](#5-model_mapping--模型映射规则) + - [6. pricing — 模型定价](#6-pricing--模型定价) + +
+ +## 1. 供应商分类 + +coding-proxy 支持三类供应商,共 9 种: + +| 类别 | 供应商 | 说明 | +| ----------------------- | --------------------------------------------------------- | -------------------------------------------------------- | +| **直连 Anthropic** | `anthropic` | 直接透传请求到 Anthropic API | +| **协议转换** | `copilot`、`antigravity` | 内部完成认证交换与格式转换(Anthropic ↔ Gemini) | +| **原生 Anthropic 兼容** | `zhipu`、`minimax`、`alibaba`、`xiaomi`、`kimi`、`doubao` | 提供原生 Anthropic 兼容端点,仅需 `api_key` + `base_url` | + +> 类层次结构与设计细节参见 [架构文档 — 供应商模块](../arch/vendors.md)。 + +## 2. 通用字段 + +所有供应商共享以下字段。完整参数表参见 [配置字段参考 — 通用字段](../arch/config-reference.md#4-vendorconfig-通用字段)。 + +| 字段 | 类型 | 默认值 | 说明 | +| ------------ | ------ | ------------------ | ------------------------------------------------------------------------------------------------------------ | +| `vendor` | string | — | 供应商类型标识 | +| `enabled` | bool | `true` | 是否启用 | +| `base_url` | string | `""` | API 基础 URL;留空使用供应商默认值 | +| `timeout_ms` | int | `300000`/`3000000` | 请求超时(毫秒);直连/协议转换供应商默认 300000(5 分钟),原生 Anthropic 兼容供应商默认 3000000(50 分钟) | + +弹性设施字段(`circuit_breaker`、`quota_guard`、`weekly_quota_guard`、`retry`)参见 [配置字段参考 — 弹性字段](../arch/config-reference.md#5-vendorconfig-弹性字段)。 + +## 3. 供应商配置详情 + +### 3.1 anthropic — Anthropic Claude + +直连 Anthropic API,无额外配置字段。OAuth token 由 Claude Code 透传。 + +```yaml +- vendor: anthropic + base_url: "https://api.anthropic.com" + timeout_ms: 300000 + circuit_breaker: + failure_threshold: 3 + recovery_timeout_seconds: 300 + success_threshold: 2 + quota_guard: + enabled: false + token_budget: 65000000 + window_hours: 5.0 +``` + +### 3.2 copilot — GitHub Copilot + +通过 GitHub Copilot 内部 API 调用 Claude 模型。需 OAuth 登录或手动配置 token。 + +| 字段 | 类型 | 默认值 | 说明 | +| -------------------------- | ------ | ---------------------------------------------------- | -------------------------------------------------- | +| `github_token` | string | `""` | GitHub OAuth token / PAT,支持 `${ENV_VAR}` | +| `account_type` | string | `"individual"` | 账号类型:`individual` / `business` / `enterprise` | +| `token_url` | string | `"https://api.github.com/copilot_internal/v2/token"` | Token 交换端点 | +| `base_url` | string | `""` | 留空时按 `account_type` 自动解析 | +| `models_cache_ttl_seconds` | int | `300` | 模型列表缓存 TTL(秒) | + +> 默认已启用(`enabled: true`)。首次启动时若缺少有效凭证,自动触发 GitHub Device Flow 浏览器登录。 +> 可通过 [`GET /api/copilot/diagnostics`](./api-reference.md#7-get-apicopilotdiagnostics) 和 [`GET /api/copilot/models`](./api-reference.md#8-get-apicopilotmodels) 排查认证状态。 + +### 3.3 antigravity — Google Antigravity + +通过 Google Generative Language API 调用 Claude / Gemini 模型。代理自动处理 Anthropic ↔ Gemini 格式双向转换。 + +| 字段 | 类型 | 默认值 | 说明 | +| ---------------- | ------ | ---------------------------------------------------- | ---------------------------------------------- | +| `client_id` | string | `""` | Google OAuth2 Client ID,支持 `${ENV_VAR}` | +| `client_secret` | string | `""` | Google OAuth2 Client Secret,支持 `${ENV_VAR}` | +| `refresh_token` | string | `""` | Google OAuth2 Refresh Token,支持 `${ENV_VAR}` | +| `base_url` | string | `"https://generativelanguage.googleapis.com/v1beta"` | Gemini API 基础地址 | +| `model_endpoint` | string | `"models/claude-sonnet-4-20250514"` | 模型端点路径(仅作为未命中映射时的默认模型) | + +> 默认禁用(`enabled: false`)。启用需配置 OAuth 凭据,启动时自动触发 Google OAuth 登录。access_token 过期时优先静默刷新,无需重新登录。 + +### 3.4 zhipu — 智谱 GLM + +通过智谱 GLM 官方 Anthropic 兼容端点调用模型。 + +| 字段 | 类型 | 默认值 | 说明 | +| ---------- | ------ | ------------------------------------------ | ------------------------------- | +| `api_key` | string | `""` | 智谱 API Key,支持 `${ENV_VAR}` | +| `base_url` | string | `"https://open.bigmodel.cn/api/anthropic"` | API 基础地址 | + +> 默认已启用(`enabled: true`),且配置了 `circuit_breaker`(`recovery_timeout_seconds: 30`)和 `quota_guard`(`enabled: true`),属于中间层参与故障转移。默认作为 `tiers` 链路中的最高优先级(Tier 0)。 + +### 3.5 minimax — MiniMax + +通过 MiniMax 原生 Anthropic 兼容端点调用模型。 + +| 字段 | 类型 | 默认值 | 说明 | +| ---------- | ------ | -------------------------------------- | ---------------------------------------------------------- | +| `api_key` | string | `""` | MiniMax API Key,支持 `${ENV_VAR}`(`${MINIMAX_API_KEY}`) | +| `base_url` | string | `"https://api.minimaxi.com/anthropic"` | API 基础地址 | + +> 默认禁用(`enabled: false`)。默认模型映射:`minimax-m2.7`。 + +### 3.6 alibaba — 阿里 Qwen + +通过阿里云 DashScope Anthropic 兼容端点调用 Qwen 模型。 + +| 字段 | 类型 | 默认值 | 说明 | +| ---------- | ------ | ------------------------------------------------------------- | ------------------------------------------------------- | +| `api_key` | string | `""` | 阿里 API Key,支持 `${ENV_VAR}`(`${ALIBABA_API_KEY}`) | +| `base_url` | string | `"https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"` | API 基础地址 | + +> 默认禁用(`enabled: false`)。默认模型映射:`qwen3.6-plus`。 + +### 3.7 xiaomi — 小米 MiMo + +通过小米 Anthropic 兼容端点调用 MiMo 模型。 + +| 字段 | 类型 | 默认值 | 说明 | +| ---------- | ------ | -------------------------------------------------- | ------------------------------------------------------ | +| `api_key` | string | `""` | 小米 API Key,支持 `${ENV_VAR}`(`${XIAOMI_API_KEY}`) | +| `base_url` | string | `"https://token-plan-cn.xiaomimimo.com/anthropic"` | API 基础地址 | + +> 默认禁用(`enabled: false`)。默认模型映射:`mimo-v2-pro`。 + +### 3.8 kimi — Kimi + +通过 Kimi Anthropic 兼容端点调用模型。 + +| 字段 | 类型 | 默认值 | 说明 | +| ---------- | ------ | -------------------------------- | ---------------------------------------------------- | +| `api_key` | string | `""` | Kimi API Key,支持 `${ENV_VAR}`(`${KIMI_API_KEY}`) | +| `base_url` | string | `"https://api.kimi.com/coding/"` | API 基础地址 | + +> 默认禁用(`enabled: false`)。默认模型映射:`kimi-k2.5`。 + +### 3.9 doubao — 豆包 Doubao + +通过字节跳动 Volcengine Anthropic 兼容端点调用模型。 + +| 字段 | 类型 | 默认值 | 说明 | +| ---------- | ------ | ------------------------------------------------ | --------------------------------------------------------- | +| `api_key` | string | `""` | Volcengine API Key,支持 `${ENV_VAR}`(`${ARK_API_KEY}`) | +| `base_url` | string | `"https://ark.cn-beijing.volces.com/api/coding"` | API 基础地址 | + +> 默认禁用(`enabled: false`)。三档模型映射:`claude-opus-*` → `doubao-seed-2.0-code`,`claude-sonnet-*` → `doubao-seed-2.0-pro`,`claude-haiku-*` → `doubao-seed-2.0-lite`。 + +## 4. tiers — 降级链路优先级 + +可选字段,显式指定故障转移时的供应商尝试顺序。 + +```yaml +tiers: ["zhipu", "anthropic", "copilot", "antigravity"] +``` + +**规则**: +- 未配置时回退到 `vendors` 列表原始顺序 +- 引用的 vendor 名称必须在 `vendors` 中定义且 `enabled=true` +- 可用于在不改变 `vendors` 定义顺序的情况下灵活调整降级策略 + +**示例**: + +| 场景 | 配置 | 效果 | +| -------------- | ------------------------------------------------------------- | -------------------------- | +| 默认链路 | `["zhipu", "anthropic", "copilot", "antigravity"]` | 智谱首选,Anthropic 作后备 | +| Anthropic 首选 | `["anthropic", "zhipu"]` | 仅保留首尾两级 | +| 全部启用 | `["zhipu", "anthropic", "copilot", "minimax", "antigravity"]` | 五层降级 | + +> **终端层**:未配置 `circuit_breaker` 的 vendor 自动成为终端层(始终接受请求,不触发向下故障转移)。`config.default.yaml` 中所有已启用 vendor 均配置了 `circuit_breaker`,因此默认无终端层;若需设置终端层,移除对应 vendor 的 `circuit_breaker` 配置即可。 +> +> **故障转移触发条件**:详见 [配置字段参考 — FailoverConfig](../arch/config-reference.md#54-failoverconfig--故障转移参数)。 + +## 5. model_mapping — 模型映射规则 + +将 Claude 模型名自动转换为各供应商对应的实际模型名。完整规则列表参见项目内置的 `config.default.yaml`(`src/coding/proxy/config/config.default.yaml`)。 + +| 字段 | 类型 | 说明 | +| ---------- | --------- | -------------------------------- | +| `pattern` | string | 匹配模式(精确匹配或正则) | +| `vendors` | list[str] | 规则作用的供应商范围 | +| `target` | string | 目标模型名称 | +| `is_regex` | bool | 是否为正则表达式(默认 `false`) | + +**匹配优先级**:同一供应商内精确匹配 > 正则匹配(按规则顺序) > 供应商默认值。 + +**典型配置**: + +```yaml +model_mapping: + # GitHub Copilot + - pattern: "claude-sonnet-.*" + vendors: ["copilot"] + target: "claude-sonnet-4.6" + is_regex: true + # 智谱 GLM + - pattern: "claude-sonnet-.*" + vendors: ["zhipu"] + target: "glm-5v-turbo" + is_regex: true + # 豆包 Doubao(三档模型) + - pattern: "claude-opus-.*" + vendors: ["doubao"] + target: "doubao-seed-2.0-code" + is_regex: true + - pattern: "claude-sonnet-.*" + vendors: ["doubao"] + target: "doubao-seed-2.0-pro" + is_regex: true + - pattern: "claude-haiku-.*" + vendors: ["doubao"] + target: "doubao-seed-2.0-lite" + is_regex: true +``` + +> **兼容规则**:未设置 `vendors` 的历史规则默认只作用于原生 Anthropic 兼容供应商,避免映射误套。 + +## 6. pricing — 模型定价 + +按 `(vendor, model)` 配置四维定价,用于 [`coding-proxy usage`](./cli-reference.md#3-coding-proxy-usage) 的费用统计展示。完整定价表参见项目内置的 `config.default.yaml`(`src/coding/proxy/config/config.default.yaml`)。 + +| 字段 | 类型 | 说明 | +| --------------------------- | ------ | ----------------------------------------- | +| `vendor` | string | 供应商名称 | +| `model` | string | 实际模型名 | +| `input_cost_per_mtok` | price | 输入 Token 单价(支持 `$` USD / `¥` CNY) | +| `output_cost_per_mtok` | price | 输出 Token 单价 | +| `cache_write_cost_per_mtok` | price | 缓存创建 Token 单价 | +| `cache_read_cost_per_mtok` | price | 缓存读取 Token 单价 | + +> **币种规则**:同一模型的所有价格必须使用相同币种。未配置定价的模型在 `usage` 统计中 Cost 列显示 `-`。 diff --git a/docs/user-guide.md b/docs/user-guide.md index fa2836f..9f62902 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -1,38 +1,24 @@ -# coding-proxy 用户操作指引 +# coding-proxy 用户指引
📑 目录(点击展开) -- [1. 简介](#1-简介) - - [1.1 什么是 coding-proxy](#11-什么是-coding-proxy) - - [1.2 工作原理](#12-工作原理) - - [1.3 请求生命周期](#13-请求生命周期) -- [2. 快速开始](#2-快速开始) - - [2.1 环境要求](#21-环境要求) - - [2.2 安装](#22-安装) - - [2.3 最小配置](#23-最小配置) - - [2.4 启动服务](#24-启动服务) - - [2.5 验证服务](#25-验证服务) - - [2.6 配置 Claude Code](#26-配置-claude-code) -- [3. 配置详解](#3-配置详解) - - [3.1 配置文件位置与加载优先级](#31-配置文件位置与加载优先级) - - [3.2 vendors — 供应商定义](#32-vendors-供应商定义) - - [3.3 tiers — 降级链路优先级](#33-tiers-降级链路优先级) - - [3.4 failover — 故障转移触发条件](#34-failover-故障转移触发条件) - - [3.5 model_mapping — 模型映射规则](#35-model_mapping-模型映射规则) - - [3.6 pricing — 模型定价](#36-pricing-模型定价) - - [3.7 auth — OAuth 登录配置](#37-auth-oauth-登录配置) - - [3.8 server / database / logging](#38-server-database-logging-基础设施配置) - - [3.9 环境变量引用](#39-环境变量引用) - - [3.10 旧 flat 格式兼容说明(已废弃)](#310-旧-flat-格式兼容说明已废弃) -- [4. CLI 命令参考](#4-cli-命令参考) -- [5. HTTP API 端点](#5-http-api-端点) -- [6. Claude Code 集成指南](#6-claude-code-集成指南) -- [7. 监控与运维](#7-监控与运维) -- [8. 常见使用场景](#8-常见使用场景) -- [9. 故障排查](#9-故障排查) -- [附录 A:术语对照表](#附录-a术语对照表) -- [附录 B:完整配置参考](#附录-b完整配置参考) +- [coding-proxy 用户指引](#coding-proxy-用户指引) + - [1. 简介](#1-简介) + - [1.1 什么是 coding-proxy](#11-什么是-coding-proxy) + - [1.2 工作原理](#12-工作原理) + - [1.3 请求生命周期](#13-请求生命周期) + - [2. 文档导航](#2-文档导航) + - [3. 快速上手入门](#3-快速上手入门) + - [4. 配置概览](#4-配置概览) + - [4.1 配置文件位置与加载优先级](#41-配置文件位置与加载优先级) + - [4.2 vendors — 供应商列表](#42-vendors--供应商列表) + - [4.3 tiers — 降级链路优先级](#43-tiers--降级链路优先级) + - [4.4 环境变量引用](#44-环境变量引用) + - [4.5 auth — OAuth 登录配置](#45-auth--oauth-登录配置) + - [4.6 server / database / logging](#46-server--database--logging) + - [5. 日常操作速查](#5-日常操作速查) + - [附录:术语对照表](#附录术语对照表)
@@ -42,12 +28,14 @@ coding-proxy 是一个面向 Claude Code 的多**供应商(vendor)**智能代理服务。它在 Claude Code 和 API 供应商之间充当透明代理,具备以下核心能力: -- **N-tier 自动故障转移(failover)**:支持多层供应商链式降级(Anthropic → Copilot → Antigravity → 智谱 GLM),恢复后自动切回 +- **N-tier 自动故障转移(failover)**:支持多层供应商链式降级(默认活跃链路:智谱 → Anthropic → Copilot;Antigravity 默认禁用),恢复后自动切回 +- **9 种供应商支持**:Anthropic Claude、GitHub Copilot、Google Antigravity、智谱 GLM、MiniMax、阿里 Qwen、小米 MiMo、Kimi、豆包 Doubao - **模型名称映射**:自动将 Claude 模型名转换为各供应商对应的实际模型名 - **格式双向转换**:自动转换 Anthropic ↔ Gemini 格式,支持非 Anthropic 兼容供应商 -- **Token 用量追踪与定价统计**:记录每次请求的 Token 消耗(含缓存 Token)、供应商选择、响应时间等指标;支持按 (vendor, model) 配置四维定价($ / ¥) -- **弹性设施保护**:每层供应商独立配备熔断器(Circuit Breaker)、滑动窗口配额守卫(Quota Guard)(支持小时级与周级双窗口)、Rate Limit 精确截止控制 +- **Token 用量追踪与定价统计**:记录每次请求的 Token 消耗、供应商选择、响应时间等指标;支持按 (vendor, model) 配置四维定价($ / ¥) +- **弹性设施保护**:每层供应商独立配备熔断器(Circuit Breaker)、滑动窗口配额守卫(Quota Guard)(支持日级与周级双窗口)、Rate Limit 精确截止控制 - **OAuth 认证管理**:内置 GitHub Device Flow 与 Google OAuth 登录流程,支持运行时重认证与凭证自动刷新 +- **Web 可视化看板**:内置 Dashboard 提供流量、用量、故障转移等指标的实时监控 ### 1.2 工作原理 @@ -64,10 +52,10 @@ graph TD subgraph Tiers["N-tier 降级链路"] direction LR - T0["Tier 0: Anthropic
中间层"] - T1["Tier 1: Copilot
中间层"] - T2["Tier 2: Antigravity
中间层"] - T3["Tier 3: 智谱 GLM
终端层"] + T0["Tier 0: 智谱 GLM
中间层"] + T1["Tier 1: Anthropic
中间层"] + T2["Tier 2: Copilot
中间层"] + T3["Tier 3: Antigravity
中间层"] end CC -->|"POST /v1/messages"| Router @@ -82,1690 +70,176 @@ graph TD style T0 fill:#1a5276,color:#fff style T1 fill:#1a5276,color:#fff style T2 fill:#1a5276,color:#fff - style T3 fill:#7b241c,color:#fff + style T3 fill:#1a5276,color:#fff ``` -正常情况下,coding-proxy 将请求透传到 **Tier 0(Anthropic API)**。当检测到限流、配额耗尽或服务过载等错误时,按优先级链自动降级到下一层供应商。每层独立配备熔断器和配额守卫——**未配置 `circuit_breaker` 的 vendor 自动成为终端层**(如智谱 GLM),始终接受请求且不触发进一步故障转移。供应商恢复后,代理会自动尝试切回更高优先级的层级。整个过程对用户透明,无需手动干预。 +正常情况下,coding-proxy 将请求透传到 **Tier 0(默认为智谱 GLM)**。当检测到限流、配额耗尽或服务过载等错误时,按优先级链自动降级到下一层供应商。每层独立配备熔断器和配额守卫——**未配置 `circuit_breaker` 的 vendor 自动成为终端层**,始终接受请求且不触发进一步故障转移。供应商恢复后,代理会自动尝试切回更高优先级的层级。整个过程对用户透明。 -### 1.3 请求生命周期 +> `tiers` 链路可通过配置自定义,不限于上图所示的 4 层。详见 [供应商配置 — tiers](./guide/vendors.md#4-tiers--降级链路优先级)。 -```mermaid -flowchart TD - A["接收请求
POST /v1/messages"] --> B["normalize_anthropic_request()
请求规范化"] - B --> C["_RouteExecutor.execute()"] - C --> D{"遍历 Tier 链"} - D --> E{"能力门控
can_serve_model()"} - E -->|"能力不兼容"| F["跳过 → 下一 Tier"] - F --> D - E -->|"通过能力检查"| G{"三层恢复门控
can_execute_with_health_check()"} - G -->|"Rate Limit 截止中"| H["跳过 → 下一 Tier"] - H --> D - G -->|"CB/QG/WQG 拒绝"| I["跳过 → 下一 Tier"] - I --> D - G -->|"通过所有门控"| J["vendor.send_message()
发送请求"] - J -->|"成功"| K["record_success()
记录用量 & 定价"] - K --> L["返回响应给 Claude Code"] - J -->|"失败"| M{"错误分类"} - M -->|"语义拒绝 400"| N["静默降级 → 下一 Tier"] - N --> D - M -->|"可故障转移"| O["record_failure()
+ Rate Limit 解析"] - O --> G - M -->|"最后一层 / 不可转移"| P["返回原始错误"] +### 1.3 请求生命周期 - style A fill:#2c3e50,color:#fff - style L fill:#27ae60,color:#fff - style P fill:#e74c3c,color:#fff - style D fill:#f39c12,color:#000 -``` +每个请求经过 **规范化 → 遍历 Tier 链 → 能力门控 → 三层恢复门控(Rate Limit / 熔断器 / 配额守卫)→ 发送请求 → 错误分类** 的完整链路。 -每个请求经过 **规范化 → 遍历 Tier 链 → 能力门控 → 三层恢复门控(Rate Limit / 熔断器 / 配额守卫)→ 发送请求 → 错误分类** 的完整链路。三层恢复门控确保在供应商不稳定时快速跳过,避免无效等待;错误分类决定是触发故障转移、静默降级还是直接返回错误给客户端。 +> 详细的请求生命周期流程图参见 [架构文档 — 请求路由](./arch/routing.md)。 --- -## 2. 快速开始 +## 2. 文档导航 + +| 文档 | 说明 | +| --------------------------------------------- | --------------------------------------------------------------- | +| **[快速开始](./guide/quickstart.md)** | 环境要求、安装、最小配置、启动服务、Claude Code 集成 | +| **[供应商配置](./guide/vendors.md)** | 全部 9 种供应商配置详情、模型映射、定价 | +| **[CLI 命令参考](./guide/cli-reference.md)** | start / status / usage / reset / auth 全部命令 | +| **[HTTP API 端点](./guide/api-reference.md)** | /v1/messages、health、status、reset、copilot、reauth、dashboard | +| **[Dashboard 看板](./guide/dashboard.md)** | Web 可视化看板功能与操作 | +| **[监控·运维·排查](./guide/monitoring.md)** | 日志、用量统计、性能调优、常见场景、故障排查 | +| [配置字段参考](./arch/config-reference.md) | 所有配置参数的完整字段定义(Single Source of Truth) | +| [架构设计文档](./arch/design-patterns.md) | 设计模式、熔断器状态机、配额守卫等架构细节 | +| [供应商模块文档](./arch/vendors.md) | Vendor 类层次结构与实现细节 | +| [请求路由文档](./arch/routing.md) | 路由引擎工作原理 | -### 2.1 环境要求 +--- -- **Python** >= 3.12 -- **UV** 包管理器(推荐)或 pip -- **智谱 API Key**:从 [open.bigmodel.cn](https://open.bigmodel.cn) 获取 -- **Claude Code** 已安装并可用 +## 3. 快速上手入门 -### 2.2 安装 +> 详细步骤参见 [快速开始](./guide/quickstart.md)。 ```bash -# 方式一:使用 UV(推荐) +# 1. 安装 uv sync -# 方式二:使用 pip -pip install -e . -``` - -安装完成后,`coding-proxy` 命令即可使用。 - -### 2.3 最小配置 - -```bash -# 复制配置模板到项目根目录(模板已内置完整默认值,仅需覆盖密钥) -cp config.default.yaml config.yaml -``` - -设置智谱 API Key(二选一): - -**方式一:环境变量(推荐)** - -```bash +# 2. 配置(仅需设置 API Key) export ZHIPU_API_KEY="your-api-key-here" -``` - -配置文件中使用 `${ZHIPU_API_KEY}` 引用,代理启动时自动替换。 - -**方式二:直接写入配置文件** - -编辑 `config.yaml`,在 `vendors` 列表中找到 `vendor: zhipu` 条目,将 `api_key` 设为实际的 API Key: - -```yaml -vendors: - # ... 其他供应商 ... - - vendor: zhipu - api_key: "your-api-key-here" -``` - -> **安全提示**:`config.yaml` 已在 `.gitignore` 中,不会被提交到版本库。推荐使用环境变量方式避免密钥泄露。 -> -> **安全最佳实践**: -> - **API Key 存储**:优先使用 `${ENV_VAR}` 环境变量引用,避免将明文密钥写入配置文件 -> - **OAuth Token 保护**:`~/.coding-proxy/tokens.json` 存储 OAuth 凭证,建议设置文件权限 `chmod 600` 限制访问 -> - **网络暴露**:若设置 `server.host: "0.0.0.0"` 接受外部连接,确保在可信网络环境中运行或配合防火墙使用 -> - **密钥透传**:代理不存储 `ANTHROPIC_API_KEY`,仅透传至上游 API;建议定期轮换密钥 - -### 2.4 启动服务 +cp config.default.yaml config.yaml -```bash -# 使用默认配置启动 +# 3. 启动 coding-proxy start -# 指定端口 -coding-proxy start --port 8080 - -# 指定配置文件 -coding-proxy start --config /path/to/config.yaml - -# 自定义监听地址和端口 -coding-proxy start --host 0.0.0.0 --port 8046 -``` - -启动成功后会看到类似输出: - -```bash -INFO: Started server process [75773] -INFO: Waiting for application startup. -INFO: Application startup complete. -INFO: Uvicorn running on http://127.0.0.1:8046 (Press CTRL+C to quit) -``` - -> **注意**:若启用了 Copilot 或 Antigravity 供应商但未配置凭证,启动时会自动触发 OAuth 浏览器登录流程(参见 [§4.5](#45-coding-proxy-auth-login))。 - -### 2.5 验证服务 +# 4. 配置 Claude Code +export ANTHROPIC_BASE_URL=http://127.0.0.1:8046 -```bash -# 健康检查 +# 5. 验证 curl http://127.0.0.1:8046/health -# 期望返回: {"status":"ok"} - -# 查看代理状态 -coding-proxy status -``` - -### 2.6 配置 Claude Code - -将 Claude Code 的 API 端点指向 coding-proxy: - -```bash -export ANTHROPIC_BASE_URL=http://127.0.0.1:8046 ``` -设置后,Claude Code 发出的所有 API 请求将经过 coding-proxy 代理。 - --- -## 3. 配置详解 +## 4. 配置概览 -### 3.1 配置文件位置与加载优先级 +### 4.1 配置文件位置与加载优先级 -配置文件按以下优先级加载(找到第一个即停止): +加载器先按以下顺序查找用户配置文件(找到第一个即停止): 1. `--config` 参数指定的路径(最高优先级) 2. `./config.yaml`(项目根目录) 3. `~/.coding-proxy/config.yaml`(用户主目录) -4. 内置默认值(无需配置文件也可启动) - -加载器会以 `config.default.yaml` 为基础模板进行深度合并,用户配置中的字段覆盖模板默认值。 - -### 3.2 vendors — 供应商定义 - -`vendors` 是配置的核心部分,以列表形式定义所有供应商及其弹性设施。每个 vendor 通过 `vendor` 字段指定类型:`anthropic` | `copilot` | `antigravity` | `zhipu`。 - -**优先级规则**: -- 若配置了 [`tiers`](#33-tiers--降级链路优先级),以其指定的顺序为准 -- 否则 `vendors` 列表顺序即为优先级(index 越小越优先) -- 无 `circuit_breaker` 的 vendor 自动成为终端层(不再向下故障转移) - -#### 完整配置示例 - -```yaml -# === 供应商定义(vendors 列表) === -vendors: - # Vendor 0: Anthropic Claude(最高优先级) - - vendor: anthropic - base_url: "https://api.anthropic.com" - timeout_ms: 300000 # 5 分钟 - circuit_breaker: # 有此字段 = 中间层(参与故障转移) - failure_threshold: 3 - recovery_timeout_seconds: 300 - success_threshold: 2 - quota_guard: # 小时级滑动窗口配额守卫 - enabled: true - token_budget: 45000000 - window_hours: 5.0 - threshold_percent: 99.0 - probe_interval_seconds: 300 - weekly_quota_guard: # 周级滑动窗口配额守卫 - enabled: true - token_budget: 250000000 - window_hours: 168.0 # 7 天滑动窗口 - threshold_percent: 99.0 - probe_interval_seconds: 1800 - - # Vendor 1: GitHub Copilot(中间层,默认禁用) - - vendor: copilot - enabled: false # 启用需 OAuth 登录或手动配置 github_token - github_token: "${GITHUB_TOKEN}" - account_type: "individual" # individual / business / enterprise - token_url: "https://api.github.com/copilot_internal/v2/token" - base_url: "" # 留空时按 account_type 自动解析 - timeout_ms: 300000 - models_cache_ttl_seconds: 300 # 模型列表缓存 TTL(秒) - circuit_breaker: - failure_threshold: 3 - recovery_timeout_seconds: 300 - success_threshold: 2 - quota_guard: - enabled: false - token_budget: 0 - window_hours: 24.0 - threshold_percent: 95.0 - probe_interval_seconds: 300 - - # Vendor 2: Google Antigravity(中间层,默认禁用) - - vendor: antigravity - enabled: false # 启用需配置 OAuth 凭据 - client_id: "${GOOG_CLIENT_ID}" - client_secret: "${GOOG_CLIENT_SECRET}" - refresh_token: "${GOOG_REFRESH_TOKEN}" - base_url: "https://generativelanguage.googleapis.com/v1beta" - model_endpoint: "models/claude-sonnet-4-20250514" - timeout_ms: 300000 - circuit_breaker: - failure_threshold: 3 - recovery_timeout_seconds: 300 - success_threshold: 2 - quota_guard: - enabled: false - token_budget: 0 - window_hours: 24.0 - threshold_percent: 95.0 - probe_interval_seconds: 300 - - # Vendor 3: 智谱 GLM(终端兜底,无 circuit_breaker) - - vendor: zhipu - base_url: "https://open.bigmodel.cn/api/anthropic" - api_key: "${ZHIPU_API_KEY}" - timeout_ms: 3000000 # 50 分钟 - # 不配置 circuit_breaker → 自动成为终端层,不触发向下故障转移 -``` - -#### 3.2.1 通用字段(所有供应商共用) - -| 字段 | 类型 | 默认值 | 说明 | -| ------------ | ------ | -------- | ----------------------------------------------------------------- | -| `vendor` | string | — | 供应商类型标识:`anthropic` / `copilot` / `antigravity` / `zhipu` | -| `enabled` | bool | `true` | 是否启用此供应商 | -| `base_url` | string | `""` | 后端 API 基础 URL;留空时使用各供应商默认值 | -| `timeout_ms` | int | `300000` | 请求超时时间(毫秒),适用于所有供应商 | - -#### 3.2.2 anthropic — Anthropic 供应商 - -| 字段 | 类型 | 默认值 | 说明 | -| -------------- | ---- | ------ | ---------------------------------- | -| (无专属字段) | — | — | 使用通用字段即可连接 Anthropic API | - -#### 3.2.3 copilot — GitHub Copilot 供应商 - -| 字段 | 类型 | 默认值 | 说明 | -| -------------------------- | ------ | ---------------------------------------------------- | -------------------------------------------------------- | -| `github_token` | string | `""` | GitHub OAuth token / PAT,支持 `${ENV_VAR}` | -| `account_type` | string | `"individual"` | 账号类型:`individual` / `business` / `enterprise` | -| `token_url` | string | `"https://api.github.com/copilot_internal/v2/token"` | Token 交换端点 | -| `base_url` | string | `""` | 留空时按 `account_type` 自动解析;企业网络场景可显式覆盖 | -| `models_cache_ttl_seconds` | int | `300` | 模型列表缓存 TTL(秒) | - -> **说明**:浏览器显示 `Congratulations, you're all set!` 仅表示 GitHub Device Flow 完成,不代表当前会话已经成功交换 Copilot chat token,也不代表 Claude Opus 4.6 已对该账号开放。可通过 [`GET /api/copilot/diagnostics`](#57-get-apicopilotdiagnostics) 与 [`GET /api/copilot/models`](#58-get-apicopilotmodels) 做按需排查。 -#### 3.2.4 antigravity — Google Antigravity 供应商 +找到用户配置后,以内置 `config.default.yaml` 为基础模板进行**深度合并**,用户配置覆盖模板默认值。未找到用户配置文件时,直接使用模板默认值。 -| 字段 | 类型 | 默认值 | 说明 | -| ----------------- | ------------ | ---------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| `client_id` | string | `""` | Google OAuth2 Client ID,支持 `${ENV_VAR}` | -| `client_secret` | string | `""` | Google OAuth2 Client Secret,支持 `${ENV_VAR}` | -| `refresh_token` | string | `""` | Google OAuth2 Refresh Token,支持 `${ENV_VAR}` | -| `base_url` | string | `"https://generativelanguage.googleapis.com/v1beta"` | Gemini API 基础地址 | -| `model_endpoint` | string | `"models/claude-sonnet-4-20250514"` | 模型端点路径(仅作为未命中映射时的默认模型) | -| `safety_settings` | dict or null | `null` | Gemini API 安全设置键值对(如 `{"HARASSMENT": "block_none"}`);留空使用 Google 默认策略 | +### 4.2 vendors — 供应商列表 -> **Antigravity 说明**:Antigravity 供应商通过 Google Generative Language API 调用 Claude / Gemini 模型。代理自动处理 Anthropic ↔ Gemini 格式的双向转换,并对 `thinking`、通用 `tools`、搜索类工具与 `metadata` 做兼容适配;无法安全透传的字段会记录到 diagnostics。 +`vendors` 以列表形式定义所有供应商及其弹性设施。支持 9 种供应商类型: -#### 3.2.5 zhipu — 智谱 GLM 供应商(终端层) +| 类型 | 类别 | 默认状态 | +| ------------- | ------------------- | ---------------- | +| `anthropic` | 直连 Anthropic | 启用 | +| `copilot` | 协议转换(GitHub) | 启用(需 OAuth) | +| `antigravity` | 协议转换(Google) | 禁用 | +| `zhipu` | 原生 Anthropic 兼容 | 启用(Tier 0) | +| `minimax` | 原生 Anthropic 兼容 | 禁用 | +| `alibaba` | 原生 Anthropic 兼容 | 禁用 | +| `xiaomi` | 原生 Anthropic 兼容 | 禁用 | +| `kimi` | 原生 Anthropic 兼容 | 禁用 | +| `doubao` | 原生 Anthropic 兼容 | 禁用 | -| 字段 | 类型 | 默认值 | 说明 | -| --------- | ------ | ------ | ------------------------------------ | -| `api_key` | string | `""` | 智谱 API Key,支持 `${ENV_VAR}` 引用 | +> 各供应商的专属字段、弹性配置和模型映射详见 [供应商配置](./guide/vendors.md)。所有字段的完整定义参见 [配置字段参考](./arch/config-reference.md)。 -> **终端层特性**:zhipu 作为终端兜底供应商,不配置 `circuit_breaker`,因此不参与熔断机制,始终接受请求。当所有上游中间层均不可用时,请求直接路由到此层。 +### 4.3 tiers — 降级链路优先级 -#### 3.2.6 circuit_breaker — 熔断器 - -| 字段 | 类型 | 默认值 | 说明 | -| -------------------------- | ---- | ------ | -------------------------------------------- | -| `failure_threshold` | int | `3` | 连续失败多少次后触发熔断(切换到下一供应商) | -| `recovery_timeout_seconds` | int | `300` | 熔断后等待多久尝试恢复(秒) | -| `success_threshold` | int | `2` | 恢复测试阶段需要连续成功多少次才关闭熔断 | -| `max_recovery_seconds` | int | `3600` | 指数退避的最大等待时间(秒) | - -**指数退避机制**:如果恢复测试失败,等待时间会翻倍(300s → 600s → 1200s → ...),直到达到 `max_recovery_seconds` 上限。 - -```mermaid -stateDiagram-v2 - [*] --> CLOSED - - CLOSED --> OPEN : 连续 failure_threshold 次失败
(如 3 次) - - OPEN --> HALF_OPEN : recovery_timeout 后
(指数退避,上限 max_recovery_seconds) - - HALF_OPEN --> CLOSED : 连续 success_threshold 次成功
(如 2 次) - HALF_OPEN --> OPEN : 任意一次失败
(退避时间翻倍) - - note right of CLOSED - **正常运行** - 接受所有请求到该供应商 - end note - - note right of OPEN - **熔断中** - 快速拒绝请求,跳过该层 - 等待恢复超时后进入探测 - end note - - note right of HALF_OPEN - **探测中** - 允许少量请求作为探针 - 成功则关闭熔断,失败则重新打开 - end note -``` - -#### 3.2.7 quota_guard — 滑动窗口配额守卫(小时级) - -| 字段 | 类型 | 默认值 | 说明 | -| ------------------------ | ----- | ------- | --------------------------------------------- | -| `enabled` | bool | `false` | 是否启用配额守卫 | -| `token_budget` | int | `0` | 滑动窗口内的 Token 预算上限 | -| `window_hours` | float | `5.0` | 滑动窗口大小(小时) | -| `threshold_percent` | float | `99.0` | 触发 QUOTA_EXCEEDED 的用量百分比阈值 | -| `probe_interval_seconds` | int | `300` | QUOTA_EXCEEDED 状态下允许探测请求的间隔(秒) | - -**配额状态说明**: - -| 状态 | 含义 | -| ---------------- | --------------------------------------------- | -| `within_quota` | 用量在预算内,正常使用该层供应商 | -| `quota_exceeded` | 用量超限或检测到 cap 错误,跳过该层降到下一层 | - -**工作机制**: -- 启动时从数据库加载窗口内的历史用量基线 -- 窗口用量达到 `token_budget × threshold_percent%` 时自动触发 QUOTA_EXCEEDED -- 检测到上游 cap 错误(429/403 + "usage cap"/"quota" 等关键词)时立即触发 QUOTA_EXCEEDED -- QUOTA_EXCEEDED 状态下每隔 `probe_interval_seconds` 秒允许一次探测请求,成功则恢复 - -#### 3.2.8 weekly_quota_guard — 周级配额守卫 - -字段结构与 [quota_guard](#327-quota_guard--滑动窗口配额守卫小时级) 完全相同,用于独立管理更长周期的 Token 预算。 - -典型配置(7 天滑动窗口): - -```yaml -weekly_quota_guard: - enabled: true - token_budget: 250000000 # 一周 token 预算(根据订阅计划调整) - window_hours: 168.0 # 7 天 = 168 小时 - threshold_percent: 99.0 - probe_interval_seconds: 1800 # 每 30 分钟探测一次 -``` - -> **与 hour-level quota_guard 的关系**:两者独立运行、独立判定。任一守卫进入 EXCEEDED 状态均会导致该层被跳过。适用于同时管理小时级突发限制与周级总量上限的场景。 - -#### 3.2.9 retry — 传输层重试策略 - -| 字段 | 类型 | 默认值 | 说明 | -| -------------------- | ----- | ------ | -------------------------------- | -| `max_retries` | int | `2` | 最大重试次数 | -| `initial_delay_ms` | int | `500` | 首次重试延迟(毫秒) | -| `max_delay_ms` | int | `5000` | 最大重试延迟(毫秒) | -| `backoff_multiplier` | float | `2.0` | 退避倍率(每次重试延迟乘以此值) | -| `jitter` | bool | `true` | 是否添加随机抖动(避免惊群效应) | - -### 3.3 tiers — 降级链路优先级 - -可选字段,显式指定故障转移时的供应商尝试顺序。 - -```yaml -tiers: ["anthropic", "copilot", "antigravity", "zhipu"] -``` - -**规则**: -- 未配置时回退到 `vendors` 列表原始顺序 -- 引用的 vendor 名称必须在上方 `vendors` 中定义且 `enabled=true` -- 可用于在不改变 `vendors` 定义顺序的情况下灵活调整降级策略 - -**示例**: - -| 场景 | 配置 | 效果 | -| ------------ | -------------------------------------------------- | ---------------------------- | -| 完整四层链路 | `["anthropic", "copilot", "antigravity", "zhipu"]` | 默认行为等效 | -| 跳过中间层 | `["anthropic", "zhipu"]` | 仅保留首尾两级 | -| 反转优先级 | `["zhipu", "anthropic"]` | 智谱首选,Anthropic 作为后备 | - -### 3.4 failover — 故障转移触发条件 - -| 字段 | 类型 | 默认值 | 说明 | -| ------------------------ | --------- | ------------------------------------------------------- | -------------------------------------------- | -| `status_codes` | list[int] | `[429, 403, 503, 500]` | 触发故障转移的 HTTP 状态码 | -| `error_types` | list[str] | `["rate_limit_error", "overloaded_error", "api_error"]` | 触发故障转移的 Anthropic 错误类型 | -| `error_message_patterns` | list[str] | `["quota", "limit exceeded", "usage cap", "capacity"]` | 触发故障转移的错误消息关键词(不区分大小写) | - -```mermaid -flowchart TD - REQ["收到上游响应"] --> SC{"HTTP 状态码
in status_codes?"} - SC -->|"是 429/403/503/500"| ET{"error_type
in error_types?"} - SC -->|"否"| OK["正常处理响应"] - ET -->|"是"| MSG{"error_message 匹配
error_message_patterns?"} - ET -->|"否"| OK - MSG -->|"是"| FO["触发故障转移
→ 下一 Tier
record_failure()"] - MSG -->|"否"| SEM{"语义拒绝?
400 + invalid_request_error"} - SEM -->|"是"| SFO["静默降级 → 下一 Tier
(不计入失败数)"] - SEM -->|"否"| OK - - style FO fill:#e74c3c,color:#fff - style SFO fill:#f39c12,color:#000 - style OK fill:#27ae60,color:#fff -``` - -### 3.5 model_mapping — 模型映射规则 - -每条规则包含: - -| 字段 | 类型 | 说明 | -| ---------- | --------- | -------------------------------------------------------------------------------------------------- | -| `pattern` | string | 匹配模式 | -| `vendors` | list[str] | 规则作用的供应商范围,支持 `antigravity`、`copilot`、`zhipu`;留空时为兼容旧配置,仅作用于 `zhipu` | -| `target` | string | 目标模型名称 | -| `is_regex` | bool | 是否为正则表达式(默认 `false`) | - -**匹配优先级**:同一供应商内精确匹配 > 正则匹配(按规则顺序) > 供应商默认值 - -**典型配置**(基于 `config.default.yaml`): +可选字段,显式指定故障转移时的供应商尝试顺序。默认值: ```yaml -model_mapping: - # GitHub Copilot 可用模型(通过 /api/copilot/models 诊断获取) - - pattern: "claude-sonnet-.*" - vendors: ["copilot"] - target: "claude-sonnet-4.6" - is_regex: true - - pattern: "claude-opus-.*" - vendors: ["copilot"] - target: "claude-opus-4.6" - is_regex: true - - pattern: "claude-haiku-.*" - vendors: ["copilot"] - target: "claude-haiku-4.5" - is_regex: true - # Google Antigravity 模型映射 - - pattern: "claude-sonnet-.*" - vendors: ["antigravity"] - target: "claude-sonnet-4-6-thinking" - is_regex: true - - pattern: "claude-opus-.*" - vendors: ["antigravity"] - target: "claude-opus-4-6-thinking" - is_regex: true - # 智谱 GLM 映射(官方原生 Anthropic 兼容端点) - - pattern: "claude-sonnet-.*" - vendors: ["zhipu"] - target: "glm-5v-turbo" - is_regex: true - - pattern: "claude-opus-.*" - vendors: ["zhipu"] - target: "glm-5v-turbo" - is_regex: true - - pattern: "claude-haiku-.*" - vendors: ["zhipu"] - target: "glm-4.5-air" - is_regex: true +tiers: ["zhipu", "anthropic", "copilot", "antigravity"] ``` -> **兼容规则**:未设置 `vendors` 的历史规则默认只作用于 `zhipu`,避免旧的 `glm-*` 映射误套到 Antigravity 或 Copilot。 - -#### 内置默认映射 - -若未配置任何 `model_mapping` 规则,系统使用以下内置默认映射(作用于 zhipu 供应商): - -| pattern | target | 说明 | -| ------------------ | ------------- | ------------------- | -| `claude-sonnet-.*` | `glm-5.1` | Sonnet 系列默认映射 | -| `claude-opus-.*` | `glm-5.1` | Opus 系列默认映射 | -| `claude-haiku-.*` | `glm-4.5-air` | Haiku 系列默认映射 | -| `claude-.*` | `glm-5.1` | 兜底通配规则 | - -> 这些默认规则仅在用户未显式配置 `model_mapping` 时生效。一旦配置了自定义规则,默认规则被完全覆盖。 +> 详见 [供应商配置 — tiers](./guide/vendors.md#4-tiers--降级链路优先级)。 -### 3.6 pricing — 模型定价 +### 4.4 环境变量引用 -按 `(vendor, model)` 配置四维定价,用于 `usage` 命令的费用统计展示。 +配置文件中可使用 `${VARIABLE_NAME}` 语法引用环境变量: ```yaml -pricing: - # ── Anthropic Claude(USD)── - - vendor: anthropic - model: claude-sonnet-4-6 - input_cost_per_mtok: $3.0 # 每百万输入 Token 价格 - output_cost_per_mtok: $15.0 # 每百万输出 Token 价格 - cache_write_cost_per_mtok: $3.75 # 每百万缓存创建 Token 价格 - cache_read_cost_per_mtok: $0.30 # 每百万缓存读取 Token 价格 - # ── 智谱 GLM(CNY / ¥)── - - vendor: zhipu - model: glm-5v-turbo - input_cost_per_mtok: ¥5.00 - output_cost_per_mtok: ¥22.00 - cache_read_cost_per_mtok: ¥1.20 +- vendor: zhipu + api_key: "${ZHIPU_API_KEY}" ``` -**字段说明**: - -| 字段 | 类型 | 说明 | -| --------------------------- | ------ | --------------------------------------------------- | -| `vendor` | string | 供应商名称(对应 usage 统计表的"供应商"列) | -| `model` | string | 实际模型名(对应 usage 统计表的"实际模型"列) | -| `input_cost_per_mtok` | price | 输入 Token 单价(支持 `$` 前缀 USD / `¥` 前缀 CNY) | -| `output_cost_per_mtok` | price | 输出 Token 单价 | -| `cache_write_cost_per_mtok` | price | 缓存创建 Token 单价 | -| `cache_read_cost_per_mtok` | price | 缓存读取 Token 单价 | +启动时自动替换。如果环境变量未设置,保留原始文本。 -> **币种规则**:同一模型的所有价格必须使用相同币种($ 或 ¥)。不带前缀的纯数字默认视为 USD。未配置定价的模型在 `usage` 统计中 Cost 列显示 `-`。 - -### 3.7 auth — OAuth 登录配置 +### 4.5 auth — OAuth 登录配置 ```yaml auth: token_store_path: "~/.coding-proxy/tokens.json" - # github_client_id: "Iv1.b507a08c87ecfe98" # 可选,默认使用 Copilot VS Code 扩展公开 ID - # google_client_id: "" # 可选,默认使用 Antigravity Enterprise 公开凭据 + # github_client_id: "..." # 可选,默认使用公开 ID + # google_client_id: "" # 可选,默认使用公开凭据 # google_client_secret: "" ``` -| 字段 | 类型 | 默认值 | 说明 | -| ---------------------- | ------ | ------------------------------- | ---------------------------------------------- | -| `token_store_path` | string | `"~/.coding-proxy/tokens.json"` | OAuth 凭证存储路径,支持 `~` 展开 | -| `github_client_id` | string | — | GitHub Device Flow Client ID(可选,有默认值) | -| `google_client_id` | string | — | Google OAuth2 Client ID(可选,有默认值) | -| `google_client_secret` | string | — | Google OAuth2 Client Secret(可选,有默认值) | - -### 3.8 server / database / logging — 基础设施配置 - -#### server — 服务器 - -| 字段 | 类型 | 默认值 | 说明 | -| ------ | ------ | ------------- | ----------------------------------------- | -| `host` | string | `"127.0.0.1"` | 监听地址。设为 `"0.0.0.0"` 可接受外部连接 | -| `port` | int | `8046` | 监听端口 | +> 完整字段定义参见 [配置字段参考 — AuthConfig](./arch/config-reference.md#34-authconfig)。 -#### database — 数据库 - -| 字段 | 类型 | 默认值 | 说明 | -| -------------------------- | ------ | ----------------------------- | -------------------------------------------------- | -| `path` | string | `"~/.coding-proxy/usage.db"` | SQLite 数据库文件路径,支持 `~` 展开 | -| `compat_state_path` | string | `"~/.coding-proxy/compat.db"` | 兼容性会话状态数据库路径(内部使用,通常无需修改) | -| `compat_state_ttl_seconds` | int | `86400` | 兼容性状态 TTL(秒)(内部使用) | - -#### logging — 日志 - -| 字段 | 类型 | 默认值 | 说明 | -| ------- | -------------- | -------- | ------------------------------------------ | -| `level` | string | `"INFO"` | 日志级别(DEBUG / INFO / WARNING / ERROR) | -| `file` | string or null | `null` | 日志文件路径,`null` 表示输出到控制台 | - -### 3.9 环境变量引用 - -配置文件中可使用 `${VARIABLE_NAME}` 语法引用环境变量: +### 4.6 server / database / logging ```yaml -# vendors 列表中引用 -- vendor: zhipu - api_key: "${ZHIPU_API_KEY}" - -# auth 节中引用 -auth: - # ... -``` - -启动时,`${ZHIPU_API_KEY}` 会被替换为环境变量 `ZHIPU_API_KEY` 的值。如果环境变量未设置,保留原始文本 `${ZHIPU_API_KEY}`。 - -### 3.10 旧 flat 格式兼容说明(已废弃) - -以下字段为 **legacy flat 格式**(已废弃,保留仅用于向后兼容迁移): - -`primary`, `copilot`, `antigravity`, `fallback`, `circuit_breaker`, `copilot_circuit_breaker`, `antigravity_circuit_breaker`, `quota_guard`, `copilot_quota_guard`, `antigravity_quota_guard` - -如果配置文件中使用上述旧格式字段(而非 `vendors` 列表),系统会在启动时**自动迁移**至 vendors 格式并输出日志提示: - -``` -检测到旧 flat 格式配置字段,已自动迁移至 vendors 列表格式。建议迁移至 config.default.yaml 中的 vendors 新格式。 -``` - -**建议**:尽快迁移至 `config.default.yaml` 中的 vendors 新格式,以获得最完整的配置能力(如 `weekly_quota_guard`、`retry`、`pricing` 等新功能仅在 vendors 格式中可用)。 - -> **迁移时间线**:旧 flat 格式自动迁移代码将在后续版本中移除。建议在新部署中直接使用 vendors 格式,避免依赖迁移逻辑。 - ---- - -## 4. CLI 命令参考 - -### 4.1 coding-proxy start - -启动代理服务。 - -```bash -coding-proxy start [OPTIONS] -``` - -| 参数 | 缩写 | 说明 | -| ---------- | ---- | ------------------------ | -| `--config` | `-c` | 配置文件路径 | -| `--port` | `-p` | 监听端口(覆盖配置文件) | -| `--host` | `-h` | 监听地址(覆盖配置文件) | - -**示例**: - -```bash -# 默认配置启动 -coding-proxy start - -# 自定义端口和配置 -coding-proxy start -p 9000 -c ~/my-config.yaml -``` - -### 4.2 coding-proxy status - -查看当前代理状态和各供应商层级信息。 - -```bash -coding-proxy status [OPTIONS] -``` - -| 参数 | 缩写 | 说明 | -| -------- | ---- | ------------------------- | -| `--port` | `-p` | 代理服务端口(默认 8046) | - -**输出示例**: - -``` -anthropic - 熔断器: closed 失败=0 - 配额: within_quota 27.8% (12500000/45000000) - -copilot - 熔断器: closed 失败=0 - -zhipu -``` - -每个 tier 独立展示名称、熔断器状态和配额守卫状态。终端层(如 zhipu)无熔断器和配额守卫,仅显示名称。 - -**熔断器状态说明**: - -| 状态 | 含义 | -| ----------- | ------------------------------ | -| `closed` | 正常运行,使用该层供应商 | -| `open` | 熔断中,跳过该层降级到下一层 | -| `half_open` | 恢复测试中,尝试使用该层供应商 | - -### 4.3 coding-proxy usage - -查看 Token 使用统计与费用估算。 - -```bash -coding-proxy usage [OPTIONS] -``` - -| 参数 | 缩写 | 说明 | -| ---------- | ---- | ------------------------------------------------------------ | -| `--days` | `-d` | 统计天数(默认 7) | -| `--vendor` | `-v` | 过滤供应商(`anthropic`、`copilot`、`antigravity`、`zhipu`) | -| `--model` | `-m` | 过滤请求模型(如 `claude-sonnet-4-*`) | -| `--db` | — | 数据库文件路径 | - -**示例**: - -```bash -# 查看最近 7 天统计 -coding-proxy usage - -# 查看最近 30 天 Anthropic 供应商统计 -coding-proxy usage -d 30 -v anthropic - -# 按请求模型过滤 -coding-proxy usage -m claude-sonnet-4-20250514 -``` - -**输出字段说明**: - -| 字段 | 说明 | -| -------------- | -------------------------------------------------------------------------------- | -| 日期 | 统计日期 | -| 供应商 | `anthropic`、`copilot`、`antigravity`、`zhipu` | -| 请求模型 | 客户端请求的原始模型名称 | -| 实际模型 | 供应商实际使用的模型名称(经映射后可能与请求模型不同) | -| 请求数 | 当日总请求数 | -| 输入 Token | 当日总输入 Token 数 | -| 输出 Token | 当日总输出 Token 数 | -| 缓存创建 Token | 当日缓存创建 Token 数 | -| 缓存读取 Token | 当日缓存读取 Token 数 | -| 总 Token | 当日总 Token 数(输入 + 输出 + 缓存创建 + 缓存读取) | -| Cost | 基于 [`pricing`](#36-pricing--模型定价) 配置计算的费用;未配置定价的模型显示 `-` | -| 平均耗时(ms) | 当日请求平均响应时间(毫秒) | - -### 4.4 coding-proxy reset - -手动重置所有层级的熔断器和配额守卫(恢复使用最高优先级供应商)。 - -```bash -coding-proxy reset [OPTIONS] -``` - -| 参数 | 缩写 | 说明 | -| -------- | ---- | ------------------------- | -| `--port` | `-p` | 代理服务端口(默认 8046) | - -**使用场景**:确认上游 API 已恢复正常后,手动强制切回最高优先级供应商,无需等待自动恢复超时。重置范围包括:所有层级的熔断器状态(→ CLOSED)、配额守卫状态(→ WITHIN_QUOTA)、周级配额守卫状态(→ WITHIN_QUOTA)、Rate Limit 截止时间(→ 清除)。 - -### 4.5 coding-proxy auth login - -执行 OAuth 浏览器登录,获取供应商访问凭证。 - -```bash -coding-proxy auth login [OPTIONS] -``` - -| 参数 | 缩写 | 说明 | -| ------------ | ---- | -------------------------------------------------------- | -| `--provider` | `-p` | 指定 provider(`github` / `google`);省略则依次登录两者 | - -**工作流程**: -1. 根据参数启动对应 Provider 的 OAuth Device Flow(GitHub)或 OAuth 授权流程(Google) -2. 浏览器打开授权页面,用户完成登录授权 -3. 获取到的凭证自动保存至 [`auth.token_store_path`](#37-auth--oauth-登录配置) 指定的存储文件 - -**示例**: - -```bash -# 仅登录 GitHub(Copilot) -coding-proxy auth login -p github - -# 仅登录 Google(Antigravity) -coding-proxy auth login -p google - -# 依次登录两者 -coding-proxy auth login -``` - -> **自动登录**:执行 `coding-proxy start` 时,若启用的供应商缺少有效凭证,会自动触发此登录流程(无需手动执行 `auth login`)。 - -### 4.6 coding-proxy auth status - -查看已登录的 OAuth 凭证状态。 - -```bash -coding-proxy auth status -``` - -**输出示例**: - -``` -github: 有效 有 refresh_token -google: 已过期 无 refresh_token -``` - -### 4.7 coding-proxy auth reauth - -触发运行中代理的 OAuth 重认证。 - -```bash -coding-proxy auth reauth PROVIDER [OPTIONS] -``` - -| 参数 | 缩写 | 说明 | -| ---------- | ---- | ------------------------------------------ | -| `PROVIDER` | — | provider 名称(必填):`github` / `google` | -| `--port` | `-p` | 代理服务端口(默认 8046) | - -**示例**: - -```bash -# 触发 GitHub 重认证 -coding-proxy auth reauth github - -# 指定端口 -coding-proxy auth reauth google -p 9090 -``` - -> 此命令通过 [`POST /api/reauth/{provider}`](#510-post-apireauthprovider) 向运行中的代理发送重认证请求,无需重启服务。 - -### 4.8 coding-proxy auth logout +server: + host: "127.0.0.1" # 设为 "0.0.0.0" 接受外部连接 + port: 8046 -清除已存储的 OAuth 凭证。 +database: + path: "~/.coding-proxy/usage.db" -```bash -coding-proxy auth logout [OPTIONS] +logging: + level: "INFO" # DEBUG / INFO / WARNING / ERROR + # file: "coding-proxy.log" # 输出到文件 + # max_bytes: 5242880 # 单文件 5 MB + # backup_count: 5 # 保留 5 个备份 ``` -| 参数 | 缩写 | 说明 | -| ------------ | ---- | -------------------------------------- | -| `--provider` | `-p` | 指定 provider;省略则登出所有 provider | +> 完整字段定义参见 [配置字段参考](./arch/config-reference.md#3-服务器配置)。 --- -## 5. HTTP API 端点 - -### 5.1 HEAD / 和 GET / - -根路径连通性探针。Claude Code 在建立连接前发送 `HEAD /` 作为健康检查(health probe),代理返回 HTTP 200 空响应。`GET /` 行为相同。 - -```bash -curl -I http://127.0.0.1:8046/ -# HTTP/1.1 200 OK -``` - -> **背景**:Claude Code 在连接到 `ANTHROPIC_BASE_URL` 时,会先发送 `HEAD /` 探测端点可达性。如果代理未处理此路径,会返回 404,导致连接检查失败。 +## 5. 日常操作速查 -### 5.2 POST /v1/messages +| 操作 | 命令 | +| ----------- | -------------------------------------------- | +| 启动代理 | `coding-proxy start` | +| 查看状态 | `coding-proxy status` | +| 查看用量 | `coding-proxy usage` | +| 查看本周 | `coding-proxy usage -w 1` | +| 查看本月 | `coding-proxy usage -m 1` | +| 重置熔断器 | `coding-proxy reset` | +| 提升供应商 | `coding-proxy reset -v anthropic` | +| GitHub 登录 | `coding-proxy auth login -p github` | +| 重认证 | `coding-proxy auth reauth github` | +| 查看凭证 | `coding-proxy auth status` | +| Dashboard | 浏览器访问 `http://127.0.0.1:8046/dashboard` | -代理 Anthropic Messages API,支持流式(SSE)与非流式请求。请求经过规范化处理后,路由至配置的 vendor tier 链;若当前 tier 不可用或返回可恢复错误,自动故障转移至下一 tier。 - -#### 5.2.1 请求格式 - -**请求头** - -| 请求头 | 必填 | 说明 | -|--------|------|------| -| `Content-Type` | ✓ | 固定为 `application/json` | -| `Authorization` | ✗ | 格式 `Bearer `;对 Anthropic vendor 透传,对其他 vendor 由代理内部凭证管理 | -| `anthropic-version` | ✗ | Anthropic API 版本,建议传 `2023-06-01`;透传至上游 | -| `anthropic-beta` | ✗ | Beta 功能标识,透传至上游(如 `interleaved-thinking-2025-05-14`) | - -> **注**:`hop-by-hop` 头(如 `Connection`、`Transfer-Encoding`)会在转发前自动过滤。 - -**请求体参数** - -| 字段 | 类型 | 必填 | 约束 | 说明 | -|------|------|------|------|------| -| `model` | string | ✓ | 非空 | 目标模型标识。经 `model_mapping` 规则映射后路由至实际 vendor 模型 | -| `messages` | array | ✓ | 至少 1 条;`user`/`assistant` 交替;末尾必须为 `user` | 对话历史,详见[消息结构](#消息结构) | -| `max_tokens` | integer | ✗ | > 0 | 最大输出 token 数 | -| `stream` | boolean | ✗ | 默认 `false` | 是否以 SSE 流式返回 | -| `temperature` | number | ✗ | `[0, 2]` | 采样温度 | -| `top_p` | number | ✗ | `(0, 1]` | Top-p 采样 | -| `top_k` | integer | ✗ | ≥ 1 | Top-k 采样 | -| `stop_sequences` | array[string] | ✗ | | 提前停止的字符串序列 | -| `system` | string \| array | ✗ | | 系统提示词;可为纯字符串或 content block 数组 | -| `tools` | array | ✗ | | 工具定义;详见 Anthropic 官方文档 | -| `tool_choice` | object | ✗ | | 工具选择策略(`auto`/`any`/`tool`) | -| `thinking` | object | ✗ | 需 `budget_tokens`;部分 vendor 不支持 | Extended Thinking 配置,格式 `{"type":"enabled","budget_tokens":N}` | -| `metadata` | object | ✗ | | 用户元数据(如 `user_id`),透传至上游 | - -**消息结构** - -每条消息的 `content` 字段可为纯字符串或 content block 数组: - -```json -{ - "role": "user", - "content": [ - { "type": "text", "text": "请描述这张图片" }, - { - "type": "image", - "source": { - "type": "base64", - "media_type": "image/png", - "data": "" - } - } - ] -} -``` - -支持的 content block 类型: - -| 类型 | 适用角色 | 必填字段 | 说明 | -|------|---------|---------|------| -| `text` | `user`/`assistant` | `text` | 纯文本 | -| `image` | `user` | `source`(`type`/`media_type`/`data` 或 `url`) | 图片;部分 vendor 不支持 | -| `tool_use` | `assistant` | `id`(`toolu_` 前缀)、`name`、`input` | 模型发起工具调用 | -| `tool_result` | `user` | `tool_use_id`、`content` | 工具调用结果;**只能出现在 `user` 消息中** | -| `thinking` | `assistant` | `thinking`、`signature` | Extended Thinking 内容块;跨 vendor 时会被自动剥离 | +> 完整命令选项参见 [CLI 命令参考](./guide/cli-reference.md)。 --- -#### 5.2.2 非流式请求示例 - -```bash -curl -X POST http://127.0.0.1:8046/v1/messages \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $ANTHROPIC_API_KEY" \ - -H "anthropic-version: 2023-06-01" \ - -d '{ - "model": "claude-sonnet-4-5", - "max_tokens": 1024, - "messages": [ - {"role": "user", "content": "你好,介绍一下你自己"} - ] - }' -``` - -**成功响应(HTTP 200)** - -```json -{ - "id": "msg_01XFDUDYJgAACzvnptvVoYEL", - "type": "message", - "role": "assistant", - "content": [ - { "type": "text", "text": "你好!我是 Claude,一个由 Anthropic 开发的 AI 助手。" } - ], - "model": "claude-sonnet-4-5-20251101", - "stop_reason": "end_turn", - "stop_sequence": null, - "usage": { - "input_tokens": 14, - "output_tokens": 32, - "cache_creation_input_tokens": 0, - "cache_read_input_tokens": 0 - } -} -``` - -**响应字段说明** - -| 字段 | 类型 | 说明 | -|------|------|------| -| `id` | string | 消息唯一 ID,格式 `msg_*` | -| `type` | string | 固定为 `"message"` | -| `role` | string | 固定为 `"assistant"` | -| `content` | array | 响应内容块列表(`text`/`tool_use` 等) | -| `model` | string | 实际处理请求的模型完整 ID | -| `stop_reason` | string | 停止原因,见下表 | -| `stop_sequence` | string \| null | 触发停止的序列字符串;未命中时为 `null` | -| `usage.input_tokens` | integer | 输入消耗的 token 数 | -| `usage.output_tokens` | integer | 输出消耗的 token 数 | -| `usage.cache_creation_input_tokens` | integer | 创建缓存的 token 数(Prompt Cache) | -| `usage.cache_read_input_tokens` | integer | 从缓存命中的 token 数(Prompt Cache) | - -**`stop_reason` 枚举** - -| 值 | 含义 | -|----|------| -| `end_turn` | 模型自然输出完毕 | -| `tool_use` | 模型发起工具调用,等待结果 | -| `stop_sequence` | 触发了请求中指定的停止序列 | -| `max_tokens` | 达到 `max_tokens` 上限 | - ---- - -#### 5.2.3 流式请求示例(SSE 模式) - -在请求体中设置 `"stream": true`,响应将以 `text/event-stream` 格式逐块下发: - -```bash -curl -X POST http://127.0.0.1:8046/v1/messages \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $ANTHROPIC_API_KEY" \ - -H "anthropic-version: 2023-06-01" \ - --no-buffer \ - -d '{ - "model": "claude-sonnet-4-5", - "max_tokens": 1024, - "stream": true, - "messages": [ - {"role": "user", "content": "用一句话介绍你自己"} - ] - }' -``` - -**SSE 事件流示例** - -``` -event: message_start -data: {"type":"message_start","message":{"id":"msg_01abc","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-5-20251101","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":14,"output_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} - -event: content_block_start -data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} - -event: ping -data: {"type":"ping"} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"我是"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Claude"}} - -event: content_block_stop -data: {"type":"content_block_stop","index":0} - -event: message_delta -data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":8}} - -event: message_stop -data: {"type":"message_stop"} -``` - -**SSE 事件类型说明** - -| 事件类型 | 说明 | -|---------|------| -| `message_start` | 消息开始,包含初始元数据 | -| `content_block_start` | 新的 content block 开始(每个 block 有独立 `index`) | -| `content_block_delta` | content block 增量;`delta.type` 为 `text_delta` 或 `input_json_delta`(工具调用参数) | -| `content_block_stop` | 当前 content block 结束 | -| `message_delta` | 消息级别的增量更新,包含最终 `stop_reason` 和累计 `usage` | -| `message_stop` | 消息结束,流关闭 | -| `ping` | 心跳事件,客户端可忽略 | -| `error` | 流式处理过程中发生错误(见[错误响应](#错误响应)) | - -> **注**:流式模式下,一旦 SSE 流开始发送,代理不再进行 tier 级别的故障转移。若中途出现错误,会以 `event: error` 事件通知客户端,随后关闭流。 - ---- - -#### 5.2.4 工具调用示例 - -```bash -curl -X POST http://127.0.0.1:8046/v1/messages \ - -H "Content-Type: application/json" \ - -H "anthropic-version: 2023-06-01" \ - -d '{ - "model": "claude-sonnet-4-5", - "max_tokens": 1024, - "tools": [ - { - "name": "get_weather", - "description": "获取指定城市的当前天气", - "input_schema": { - "type": "object", - "properties": { - "city": {"type": "string", "description": "城市名称"} - }, - "required": ["city"] - } - } - ], - "messages": [ - {"role": "user", "content": "北京今天天气怎么样?"} - ] - }' -``` - ---- - - -#### 5.2.5 错误响应 - -**错误响应结构** - -```json -{ - "error": { - "type": "invalid_request_error", - "message": "详细错误描述", - "details": ["原因1", "原因2"] - } -} -``` - -> `details` 字段为可选,仅在 `NoCompatibleVendorError`(无可用 vendor)等特定场景中包含。 - -**HTTP 状态码对照** - -| HTTP 状态码 | `error.type` | 触发场景 | 是否可重试 | -|------------|-------------|---------|-----------| -| `400` | `invalid_request_error` | 请求格式/内容不合规(消息结构错误、缺少必填字段、无兼容 vendor 等) | ✗ | -| `401` | `authentication_error` | 无有效认证凭证 | ✗ | -| `403` | `permission_error` | 权限不足 | ✗ | -| `429` | `rate_limit_error` | 所有 vendor 均触发速率限制 | ✓(等待后重试) | -| `500` | `api_error` | 代理内部异常 | ✓(视情况) | -| `502` | `api_error` | 所有 vendor 均不可达(超时/连接失败) | ✓ | -| `503` | `authentication_error` | Token 获取失败(如 OAuth 凭证失效) | ✓(重新认证后) | - -**流式错误事件** - -流式响应中途发生错误时,以 SSE 事件形式通知: - -``` -event: error -data: {"type":"error","error":{"type":"api_error","message":"上游连接超时"}} -``` - ---- - -#### 5.2.6 请求规范化行为 - -代理在将请求转发至 vendor 前,会自动进行规范化处理。**以下行为对调用方透明,无需手动处理**: - -**自动修复(静默处理)** - -| 问题 | 处理方式 | -|------|---------| -| `tool_use_id` 格式不符(非 `toolu_` 前缀,如 `srvtoolu_*`) | 自动重写为合规格式,并维护映射关系 | -| `tool_result` 出现在 `assistant` 消息中 | 将该 block 从 assistant 消息中剥离(首次触发时记录 WARNING 日志) | -| `tool_use` 缺少合法 ID | 自动生成新 ID 并建立映射 | - -**致命验证错误(返回 HTTP 400)** - -| 场景 | 错误示例 | -|------|---------| -| `tool_use` block 缺少 `id` 字段 | `"tool_use block is missing 'id' field"` | -| `tool_result` block 缺少 `tool_use_id` 字段 | `"tool_result block is missing 'tool_use_id' field"` | -| 消息角色不交替(连续相同角色) | `"messages must alternate between user and assistant"` | -| `messages` 末尾不是 `user` 消息 | `"last message must be from user"` | - -**Thinking Block 跨 Vendor 处理** - -当请求被路由至非 Anthropic vendor(如 Copilot、智谱等)时,assistant 历史消息中的 `thinking` block 会被自动剥离,因为这些 block 包含仅 Anthropic 可验证的签名(`signature` 字段)。此行为不影响当前轮次的 `thinking` 功能配置(由目标 vendor 的能力决定)。 - -> **注**:示例中使用 `claude-sonnet-4-5` 作为模型 ID 示例。实际可用的模型 ID 取决于配置的 vendor 与 `model_mapping` 规则,以 [Anthropic 官方文档](https://docs.anthropic.com/en/docs/about-claude/models) 和本地配置为准。 - -### 5.3 POST /v1/messages/count_tokens - -Token 计数 API 透传。旁路直通 Anthropic 供应商,不经过路由链。仅当 Anthropic 主供应商启用时可用。 - -```bash -curl -X POST http://127.0.0.1:8046/v1/messages/count_tokens \ - -H "Content-Type: application/json" \ - -H "anthropic-version: 2023-06-01" \ - -d '{"model":"claude-sonnet-4-*","messages":[{"role":"user","content":"Hello"}]}' -``` - -> **限制**:此端点直接透传到 Anthropic API,不经过故障转移链。如果 Anthropic 主供应商未启用,返回 404;如果上游不可达,返回 502。 - -### 5.4 GET /health - -健康检查。 - -```bash -curl http://127.0.0.1:8046/health -# {"status":"ok"} -``` - -### 5.5 GET /api/status - -查询所有层级的熔断器、配额守卫、周级配额守卫、Rate Limit 及诊断信息。 - -```bash -curl http://127.0.0.1:8046/api/status -``` - -**返回示例**: - -```json -{ - "tiers": [ - { - "name": "anthropic", - "circuit_breaker": { - "state": "closed", - "failure_count": 0, - "success_count": 0, - "current_recovery_seconds": 300, - "last_failure_time": null - }, - "quota_guard": { - "state": "within_quota", - "window_usage_tokens": 12500000, - "budget_tokens": 45000000, - "usage_percent": 27.8, - "threshold_percent": 99.0 - }, - "weekly_quota_guard": { - "state": "within_quota", - "window_usage_tokens": 85000000, - "budget_tokens": 250000000, - "usage_percent": 34.0, - "threshold_percent": 99.0 - }, - "rate_limit": { - "is_rate_limited": false, - "remaining_seconds": 0 - }, - "diagnostics": {} - }, - { - "name": "zhipu" - } - ] -} -``` - -> 每个 tier 对象包含 `name` 字段。`circuit_breaker`、`quota_guard`、`weekly_quota_guard`、`rate_limit` 仅在该层配置了对应组件时出现。`diagnostics` 为供应商提供的附加诊断信息(如 Copilot 认证链路状态)。终端层(如 zhipu)通常仅含 `name`。 - -### 5.6 POST /api/reset - -手动重置所有层级的弹性设施。 - -```bash -curl -X POST http://127.0.0.1:8046/api/reset -# {"status":"ok"} -``` - -**重置范围**:circuit_breaker(→ CLOSED)、quota_guard(→ WITHIN_QUOTA)、weekly_quota_guard(→ WITHIN_QUOTA)、rate_limit deadline(→ 清除)。等同于 CLI 命令 [`coding-proxy reset`](#44-coding-proxy-reset)。 - -### 5.7 GET /api/copilot/diagnostics - -返回 Copilot 认证与交换链路的脱敏诊断信息。 - -```bash -curl http://127.0.0.1:8046/api/copilot/diagnostics -``` - -**返回示例**: - -```json -{ - "has_token": true, - "token_source": "oauth_device_flow", - "last_exchange_status": "ok", - "last_exchange_time": "2025-01-15T10:30:00Z", - "models_cached": true -} -``` - -> 若 Copilot 供应商未启用,返回 404。可用于排查 Device Flow 完成但 token exchange 失败的场景。 - -### 5.8 GET /api/copilot/models - -按需探测当前 Copilot 会话可见的模型列表。 - -```bash -curl http://127.0.0.1:8046/api/copilot/models -``` - -**返回示例**: - -```json -{ - "probe_status": "ok", - "models": [ - "claude-opus-4.6", - "Claude Sonnet 4.6", - "claude-sonnet-4.6", - "claude-haiku-4.5" - ], - "probed_at": "2025-01-15T10:30:00Z" -} -``` - -> 需要有效的 Copilot 凭证;凭证无效时返回 503。返回的模型列表可用于更新 [`model_mapping`](#35-model_mapping--模型映射规则) 中的 `target` 值。 - -### 5.9 GET /api/reauth/status - -查询运行时重认证状态。 - -```bash -curl http://127.0.0.1:8046/api/reauth/status -``` - -**返回示例**: - -```json -{ - "providers": { - "github": { "status": "idle", "last_triggered": null }, - "google": { "status": "in_progress", "last_triggered": "2025-01-15T10:30:00Z" } - } -} -``` - -### 5.10 POST /api/reauth/{provider} - -手动触发指定 provider 的运行时重认证。 - -```bash -curl -X POST http://127.0.0.1:8046/api/reauth/github -# HTTP/1.1 202 Accepted -# {"status":"reauth requested"} -``` - -| 参数 | 说明 | -| ------------ | ---------------------------------- | -| `{provider}` | provider 名称:`github` / `google` | - -> 返回 202 Accepted 表示重认证请求已接收,用户需在浏览器中完成授权流程。若代理未启用对应供应商的重认证协调器,返回 404。 - ---- - -## 6. Claude Code 集成指南 - -### 6.1 配置 Claude Code 使用代理 - -启动 coding-proxy 后,设置环境变量让 Claude Code 通过代理发送请求: - -```bash -export ANTHROPIC_BASE_URL=http://127.0.0.1:8046 -``` - -Claude Code 使用的 OAuth token 会被代理透传到 Anthropic API,无需额外配置认证信息。 - -### 6.2 验证集成 - -1. 确保 coding-proxy 正在运行:`coding-proxy status` -2. 使用 Claude Code 发送一条消息 -3. 查看 coding-proxy 的终端日志,确认请求经过代理 -4. 使用 `coding-proxy usage` 查看是否有新的用量记录 - -### 6.3 日常使用流程 - -1. **启动代理**:`coding-proxy start`(可使用 `nohup` 或 `tmux` 后台运行) -2. **OAuth 认证**(如启用 Copilot/Antigravity):启动时自动检查凭证,缺失则触发浏览器登录;也可通过 [`auth login`](#45-coding-proxy-auth-login) 手动登录 -3. **正常使用 Claude Code**:代理在后台透明工作 -4. **定期查看用量**:`coding-proxy usage` 了解 Token 消耗、费用估算和供应商分布 -5. **按需手动干预**:`coding-proxy reset` 在确认主供应商恢复后强制切回 -6. **运行时重认证**:凭证过期时通过 [`auth reauth`](#47-coding-proxy-auth-reauth) 无需重启即可刷新 - ---- - -## 7. 监控与运维 - -### 7.1 日志查看 - -代理服务日志默认输出到控制台,包含以下关键事件: - -| 事件 | 日志级别 | 示例 | -| --------------- | ------------ | ----------------------------------------------------------------------- | -| 熔断器状态转换 | INFO/WARNING | `Circuit breaker: CLOSED → OPEN (3 consecutive failures)` | -| 故障转移触发 | WARNING | `Primary error 429, failing over` | -| 恢复成功 | INFO | `Circuit breaker: HALF_OPEN → CLOSED (recovered)` | -| 连接错误 | WARNING | `Primary connection error: ConnectTimeout` | -| Rate Limit 生效 | INFO | `Tier anthropic: rate limit deadline active, 30.0s remaining, blocking` | -| 自动登录 | INFO | `Copilot 层缺少有效凭证,启动 GitHub OAuth 登录...` | - -可通过配置调整日志级别: - -```yaml -logging: - level: "DEBUG" # 查看详细的模型映射和路由决策 - file: "/var/log/coding-proxy.log" # 输出到文件 -``` - -### 7.2 用量统计 - -详细用法参见 [§4.3 coding-proxy usage](#43-coding-proxy-usage)。以下为常用查询快捷参考: - -```bash -# 查看最近 7 天统计(默认) -coding-proxy usage - -# 查看最近 30 天,仅 Anthropic 供应商 -coding-proxy usage -d 30 -v anthropic - -# 查看最近 30 天,仅智谱供应商 -coding-proxy usage -d 30 -v zhipu - -# 按模型过滤 -coding-proxy usage -m claude-sonnet-4-* -``` - -### 7.3 健康检查 - -```bash -# 基础检查 -curl http://127.0.0.1:8046/health - -# 详细状态(所有层级的熔断器、配额守卫、Rate Limit、诊断信息) -curl http://127.0.0.1:8046/api/status -``` - -> 各端点的详细说明和响应格式参见 [第 5 章 HTTP API 端点](#5-http-api-端点)。 - -### 7.4 数据库维护 - -用量数据库位于 `~/.coding-proxy/usage.db`(可通过配置修改)。 - -- 数据库采用 SQLite WAL 模式,支持读写并发 -- 当前版本不自动清理历史数据 -- 如需清理,可直接删除数据库文件(重启后自动重建) - -### 7.5 性能调优参考 - -以下参数调整建议基于典型使用场景,实际值应根据订阅计划和网络环境调校: - -| 参数 | 默认值 | 稳定优先 | 敏感快速 | 说明 | -| ---------------------------- | -------- | -------------- | -------- | -------------------------------------------------------- | -| `timeout_ms` | 300000 | `300000` | `120000` | 长对话场景建议保持 5 分钟;短查询可缩短至 2 分钟 | -| `failure_threshold` | 3 | `5` | `2` | 网络稳定环境可降低以更快触发降级;波动大则提高避免误触发 | -| `recovery_timeout_seconds` | 300 | `600` | `120` | 给主供应商更多恢复时间 vs 更快尝试恢复 | -| `token_budget` | 45000000 | 按订阅计划设定 | — | 建议设为订阅额度的 95%~99%,留余量给手动操作 | -| `window_hours` (quota_guard) | 5.0 | `8.0` | `3.0` | 滑动窗口大小:长窗口更平滑,短窗口更灵敏 | -| `max_retries` | 2 | `3` | `1` | 网络不稳定时增加重试;低延迟要求时减少重试 | -| `initial_delay_ms` | 500 | `1000` | `200` | 首次重试延迟:网络延迟高时增大 | - -**SQLite WAL 注意事项**: -- WAL 模式支持读写并发,适合单进程部署 -- 数据库文件会随时间增长(当前版本不自动清理),建议定期监控磁盘占用 -- `compat.db` 为内部兼容性状态库,通常远小于 `usage.db` - ---- - -## 8. 常见使用场景 - -### 8.1 上游 API 限流自动降级 - -**现象**:Claude Code 响应变慢或提示 "rate limited" - -**代理行为**: -1. 检测到上游供应商返回 `429 rate_limit_error` -2. 解析响应头中的 `retry-after`,设置 Rate Limit 精确截止时间 -3. 熔断器记录失败,达到阈值后切换到 OPEN 状态 -4. 后续请求按优先级链自动降级到下一可用供应商 -5. 等待恢复超时后自动尝试切回更高优先级的供应商 - -**用户操作**:无需干预,代理自动处理。可通过 [`GET /api/status`](#55-get-apistatus) 中的 `rate_limit` 字段查看当前限速状态。 - -### 8.2 配额耗尽后自动降级 - -**现象**:上游供应商返回 `403` 错误,消息含 "usage cap" 或 "quota" - -**代理行为**: -1. 识别错误消息中的关键词("quota"、"usage cap") -2. 小时级配额守卫和/或周级配额守卫同时标记该层为 QUOTA_EXCEEDED -3. 后续请求自动路由到下一可用层级 -4. Claude Code 继续正常工作 - -**用户操作**:无需干预。可通过 `coding-proxy usage` 查看各供应商的请求分布和 Cost 估算。 - -### 8.3 手动恢复使用主供应商 - -**场景**:确认上游 API 已恢复,希望立即切回而不等待自动恢复。 - -```bash -# 重置所有层级的熔断器和配额守卫 -coding-proxy reset - -# 确认状态 -coding-proxy status -``` - -### 8.4 禁用特定供应商层级 - -如果希望跳过某个供应商层级,可在 `vendors` 列表中设置 `enabled: false`: - -```yaml -vendors: - - vendor: copilot - enabled: false # 禁用 Copilot 供应商 - - - vendor: antigravity - enabled: false # 禁用 Antigravity 供应商 -``` - -禁用后,该层级将从路由链中移除,请求不会尝试路由到该供应商。也可通过 [`tiers`](#33-tiers--降级链路优先级) 从降级链中排除特定供应商。 - -### 8.5 运行时 OAuth 重认证 - -**场景**:Copilot 或 Antigravity 凭证在代理运行期间过期,需要刷新而无需重启服务。 - -```bash -# 方法一:CLI 命令 -coding-proxy auth reauth github - -# 方法二:直接调用 API -curl -X POST http://127.0.0.1:8046/api/reauth/github -``` - -重认证请求发出后(HTTP 202),用户需在浏览器中完成授权。完成后代理自动使用新凭证,无需重启。 - -> **查看进度**:通过 [`GET /api/reauth/status`](#59-get-apireauthstatus) 查询当前重认证状态。 - ---- - -## 9. 故障排查 - -### 9.1 代理服务无法启动 - -**端口占用**: - -```bash -lsof -i :8046 -# 如有进程占用,先停止或更换端口 -coding-proxy start --port 8080 -``` - -**配置文件语法错误**: - -检查 YAML 格式是否正确(缩进、冒号后的空格等)。常见错误: - -```yaml -# 错误:冒号后缺少空格 -port:8046 - -# 正确 -port: 8046 -``` - -**Python 版本不满足**: - -```bash -python --version -# 需要 Python >= 3.12 -``` - -### 9.2 Claude Code 无法连接代理 - -1. 确认代理服务正在运行: - -```bash -coding-proxy status -# 如果提示 "代理服务未运行",先启动服务 -``` - -2. 确认环境变量设置正确: - -```bash -echo $ANTHROPIC_BASE_URL -# 应输出: http://127.0.0.1:8046 -``` - -3. 确认代理端口与环境变量一致 - -### 9.3 频繁触发故障转移 - -如果发现频繁在各层供应商之间切换: - -1. 检查上游 API 状态(是否正在经历服务波动) -2. 查看 [`GET /api/status`](#55-get-apistatus) 中的 `rate_limit` 信息,确认是否受 Rate Limit 截止影响 -3. 适当调高 `failure_threshold`(如从 3 改为 5) -4. 适当调高 `recovery_timeout_seconds`(给主供应商更多恢复时间) -5. 查看日志确认触发原因(状态码、错误类型、错误消息) - -### 9.4 智谱供应商返回错误 - -1. **API Key 错误**:确认 `ZHIPU_API_KEY` 环境变量已正确设置 -2. **模型不存在**:检查 [`model_mapping`](#35-model_mapping--模型映射规则) 规则,确认目标模型名称有效 -3. **网络问题**:确认可以访问 `open.bigmodel.cn` - -### 9.5 Token 用量不记录 - -1. 确认数据库路径目录可写: - -```bash -ls -la ~/.coding-proxy/ -``` - -2. 如果目录不存在,代理会在启动时自动创建 -3. 流式请求的用量提取依赖 SSE 事件格式,如果供应商返回非标准格式可能无法正确解析 - -### 9.6 count_tokens 请求失败 - -**返回 404**:Anthropic 主供应商未启用。`count_tokens` 端点仅在 Anthropic 供应商启用时可用,请确认配置中存在 `vendor: anthropic` 且 `enabled: true`。 - -**返回 502**:Anthropic API 不可达。检查网络连通性和 Anthropic API 状态。此端点直接透传到 Anthropic,不经过故障转移链。 - -### 9.7 Copilot 认证问题 - -**现象**:GitHub Device Flow 显示 "Congratulations, you're all set!" 但请求仍失败。 - -**排查步骤**: - -1. 查看 Copilot 诊断信息: - -```bash -curl http://127.0.0.1:8046/api/copilot/diagnostics -``` - -2. 确认 token exchange 是否成功(`last_exchange_status` 字段) -3. 探查当前可见模型列表: - -```bash -curl http://127.0.0.1:8046/api/copilot/models -``` - -4. 如果凭证已过期,执行重认证: - -```bash -coding-proxy auth reauth github -``` - ---- - -## 附录 A:术语对照表 - -| 术语 | 说明 | -| ----------------------------- | ------------------------------------------------------------------------------------------ | -| **Vendor(供应商)** | API 后端提供方,即 `vendors` 列表中的一个条目 | -| **Tier(层级)** | 一个 Vendor + 其关联弹性设施(熔断器/配额守卫等)的路由单元 | -| **Terminal Tier(终端层)** | 未配置 `circuit_breaker` 的 Vendor,始终接受请求,不触发向下故障转移 | -| **主供应商 / Tier 0** | 降级链中最高优先级的供应商(默认为 Anthropic) | -| **故障转移(Failover)** | 当前供应商不可用时自动切换到下一优先级供应商的过程 | -| **熔断器(Circuit Breaker)** | 连续失败达到阈值后暂时切断对某供应商的请求,避免雪崩 | -| **配额守卫(Quota Guard)** | 基于滑动窗口的 Token 用量预算管理,超限时跳过该供应商 | -| **Rate Limit** | 基于上游响应头的精确速率限制控制,三层恢复门控(Deadline → Health Check → Cautious Probe) | - ---- - -## 附录 B:完整配置参考 - -> **说明**:本附录为 [`config.default.yaml`](../config.default.yaml) 的精简摘要。完整注释版(含每行注释说明)请直接参阅项目根目录下的 `config.default.yaml`,它是唯一权威配置模板。 -> -> 以下省略的字段在代码中均有合理默认值(参见各配置表格中的「默认值」列),无需显式配置即可生效。 - -```yaml -server: - host: "127.0.0.1" - port: 8046 - -vendors: - - vendor: anthropic # 主供应商(中间层) - base_url: "https://api.anthropic.com" - timeout_ms: 300000 - circuit_breaker: # 中间层标识(有此字段 = 参与故障转移) - failure_threshold: 3 - recovery_timeout_seconds: 300 - success_threshold: 2 - # max_recovery_seconds: 3600 # 可选,默认 3600(指数退避上限) - quota_guard: - enabled: true - token_budget: 45000000 - window_hours: 5.0 - threshold_percent: 99.0 - probe_interval_seconds: 300 - weekly_quota_guard: # 可选:周级预算 - enabled: true - token_budget: 250000000 - window_hours: 168.0 # 7 天滑动窗口 - threshold_percent: 99.0 - probe_interval_seconds: 1800 - # retry: # 可选,传输层重试(有默认值) - - - vendor: copilot # 中间层(默认禁用) - enabled: false - github_token: "${GITHUB_TOKEN}" - account_type: "individual" - # circuit_breaker / quota_guard 结构同 anthropic - - - vendor: antigravity # 中间层(默认禁用) - enabled: false - client_id: "${GOOG_CLIENT_ID}" - client_secret: "${GOOG_CLIENT_SECRET}" - refresh_token: "${GOOG_REFRESH_TOKEN}" - base_url: "https://generativelanguage.googleapis.com/v1beta" - model_endpoint: "models/claude-sonnet-4-20250514" - # safety_settings: {} # 可选,Gemini API 安全设置 - # circuit_breaker / quota_guard 结构同 anthropic - - - vendor: zhipu # 终端层(无 circuit_breaker) - base_url: "https://open.bigmodel.cn/api/anthropic" - api_key: "${ZHIPU_API_KEY}" - timeout_ms: 3000000 - -# tiers: ["anthropic", "copilot", "antigravity", "zhipu"] # 可选,降级链路优先级 - -failover: - status_codes: [429, 403, 503, 500] - error_types: ["rate_limit_error", "overloaded_error", "api_error"] - error_message_patterns: ["quota", "usage cap"] - # 注:FailoverConfig 代码默认值额外包含 "limit exceeded" 和 "capacity" - -model_mapping: - # 完整列表参见 config.default.yaml 及 §3.5 - - pattern: "claude-sonnet-.*" - vendors: ["copilot"] - target: "claude-sonnet-4.6" - is_regex: true - # ... 更多映射规则(按供应商分组) ... - -pricing: # 可选:费用统计(四维定价 $/¥) - # 完整列表参见 config.default.yaml(~20 条,覆盖全部供应商与模型) - - vendor: anthropic - model: claude-sonnet-4-6 - input_cost_per_mtok: $3.0 - output_cost_per_mtok: $15.0 - cache_write_cost_per_mtok: $3.75 - cache_read_cost_per_mtok: $0.30 - -auth: # OAuth 配置 - token_store_path: "~/.coding-proxy/tokens.json" - -database: - path: "~/.coding-proxy/usage.db" - # compat_state_path: ~/.coding-proxy/compat.db # 内部使用 - # compat_state_ttl_seconds: 86400 # 内部使用 - -logging: - level: "INFO" - # file: null # null=控制台输出 -``` +## 附录:术语对照表 + +| 术语 | 说明 | +| ------------------------------------------------------ | -------------------------------------------------------------------- | +| **Vendor(供应商)** | API 后端提供方,即 `vendors` 列表中的一个条目 | +| **Tier(层级)** | 一个 Vendor + 其关联弹性设施的路由单元 | +| **Terminal Tier(终端层)** | 未配置 `circuit_breaker` 的 Vendor,始终接受请求,不触发向下故障转移 | +| **故障转移(Failover)** | 当前供应商不可用时自动切换到下一优先级供应商 | +| **熔断器(Circuit Breaker)** | 连续失败达到阈值后暂时切断对该供应商的请求 | +| **配额守卫(Quota Guard)** | 基于滑动窗口的 Token 用量预算管理,超限时跳过该供应商 | +| **Rate Limit** | 基于上游响应头的精确速率限制控制 | +| **模型映射(Model Mapping)** | 将 Claude 模型名自动转换为各供应商实际模型名 | +| **原生 Anthropic 兼容(Native Anthropic Compatible)** | 提供 Anthropic 兼容端点的供应商,仅需 `api_key` + `base_url` | diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index 7a1a7a0..cc492ba 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -33,10 +33,10 @@ -- **⛓️ N-tier 链式故障转移 (Failover)**:自主降序序列,支持 Claude 官方 Plans,以及 GitHub Copilot、智谱、MiniMax、阿里千问、小米、Kimi、豆包等的 Coding Plan。 +- **⛓️ N-tier 链式故障转移 (Failover)**:自主降序序列,支持 Claude 官方 Plans,以及 GitHub Copilot、Google Antigravity、智谱、MiniMax、阿里千问、小米、Kimi、豆包等的 Coding Plan。 - **🛡️ 智能弹性与容灾守卫**:每个供应商节点独立配备 **熔断器 (Circuit Breaker)** 与 **配额守卫 (Quota Guard)**,防雪崩、主动避险。 - **👻 透明无感代理机制**:对客户端 **100% 透明**!无需修改任何代码,仅需一行配置覆盖 `ANTHROPIC_BASE_URL` 即可接入。 -- **🔄 跨模型与全格式转换**:原生支持 Anthropic ←→ Gemini 的请求与流式响应(SSE)双向转换,并支持自动/自助映射模型名称(如 `claude-*` 至 `glm-*`)。 +- **🔄 跨模型与全格式转换**:原生支持 Anthropic ←→ Gemini、Anthropic ←→ OpenAI 的请求与流式响应(SSE)双向转换,并支持自动/自助映射模型名称(如 `claude-*` 至 `glm-*`)。 - **📊 极致可观测性 (Observability)**:内置基于 `SQLite WAL` 的本地监控追踪,CLI 提供一键输出详细的 Token 用量统计面板(`coding-proxy usage`)。 - **⚡ 超轻量单机部署**:全异步架构 (`FastAPI` + `httpx`),无需依赖 Redis、消息队列等外部组件,对开发者机器无额外负担。 @@ -95,6 +95,7 @@ claude | 指令 | 说明 | 示例用法 | | :------- | :-------------------------------------------------------------------------------- | :-------------------------------------------- | | `start` | **启动代理服务器**。支持自定义端口与配置路径。 | `coding-proxy start -p 8080 -c ~/config.yaml` | +| `auth` | **管理 OAuth 登录凭证**。子命令:`login`(浏览器 OAuth 登录)、`status`(令牌状态)、`reauth`(重认证)、`logout`(清除令牌)。 | `coding-proxy auth login -p github` | | `status` | **查看代理健康状态**。展示各层级熔断器(OPEN/CLOSED)与配额状态。 | `coding-proxy status` | | `usage` | **Token 统计看板**。按天/供应商/模型维度追踪每一次的 Token 消耗、故障转移及耗时。 | `coding-proxy usage -d 7 -v anthropic` | | `reset` | **强制一键重置**。人工确认主供应商恢复可用后,立刻初始化所有熔断器和配额状态。 | `coding-proxy reset` | @@ -118,7 +119,7 @@ graph RL subgraph CodingProxy["⚡ coding-proxy"] direction RL - + Router["RequestRouter
routing/router.py"]:::router Router -->NTier @@ -126,32 +127,32 @@ graph RL subgraph NTier["N-tier"] direction TB - subgraph Tier0 ["Tier 0: Anthropic"] + subgraph Tier0 ["Tier 0: Zhipu"] direction RL - G0{"CB / Quota"}:::gateway -- "✅ Pass" --> API0(("Anthropic API")):::api + G0{"CB / Quota"}:::gateway -- "✅ Pass" --> API0(("GLM API")):::api end - subgraph Tier1 ["Tier 1: GitHub Copilot"] + subgraph Tier1 ["Tier 1: Anthropic"] direction RL - G1{"CB / Quota"}:::gateway -- "✅ Pass" --> API1(("Copilot API")):::api + G1{"CB / Quota"}:::gateway -- "✅ Pass" --> API1(("Anthropic API")):::api end - subgraph Tier2 ["Tier 2: Google Antigravity"] + subgraph Tier2 ["Tier 2: GitHub Copilot"] direction RL - G2{"CB / Quota"}:::gateway -- "✅ Pass" --> API2(("Gemini API")):::api + G2{"CB / Quota"}:::gateway -- "✅ Pass" --> API2(("Copilot API")):::api end - subgraph TierN ["Tier N: Zhipu"] + subgraph Tier3 ["Tier 3: Google Antigravity"] direction RL - APIN(("GLM API")):::fallback + API3(("Gemini API")):::fallback end Tier0 -. "❌ Blocked / API Error" .-> Tier1 Tier1 -. "❌ Blocked / API Error" .-> Tier2 - Tier2 -. "🆘 Safety Net Downgrade" .-> TierN + Tier2 -. "🆘 Safety Net Downgrade" .-> Tier3 end - end + end Client -->|"POST /v1/messages"| CodingProxy ``` diff --git a/pyproject.toml b/pyproject.toml index b71efa6..5229ee5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "coding-proxy" -version = "0.2.3" +version = "0.2.4a5" description = "A High-Availability, Transparent, and Smart Multi-Vendor Proxy for Claude Code. Support Claude Plans, GitHub Copilot, Google Antigravity, ZAI/GLM, MiniMax, Qwen, Xiaomi, Kimi, Doubao..." readme = "README.md" requires-python = ">=3.12" @@ -47,6 +47,7 @@ dev = [ "pytest-asyncio>=1.3", "pytest-cov>=6.0", "ruff>=0.9.0", + "pre-commit>=4.0", ] # ── Ruff: Linter & Formatter(替代 flake8 + isort + Black + pyupgrade 等 6+ 工具) ── diff --git a/src/coding/proxy/config/config.default.yaml b/src/coding/proxy/config/config.default.yaml index 2f8284b..be5cc83 100644 --- a/src/coding/proxy/config/config.default.yaml +++ b/src/coding/proxy/config/config.default.yaml @@ -51,13 +51,13 @@ vendors: recovery_timeout_seconds: 300 success_threshold: 2 quota_guard: - enabled: true - token_budget: 50000000 # 5 小时 token 预算(根据订阅计划调整) + enabled: false + token_budget: 65000000 # 5 小时 token 预算(根据订阅计划调整) window_hours: 5.0 threshold_percent: 99.0 probe_interval_seconds: 300 weekly_quota_guard: - enabled: true + enabled: false token_budget: 800000000 # 一周 token 预算(根据订阅计划调整) window_hours: 168.0 # 7 天滑动窗口 threshold_percent: 99.0 @@ -84,7 +84,7 @@ vendors: # Vendor 2: Google Antigravity Plans(中间层,默认禁用) - vendor: antigravity - enabled: true # 启用需配置 OAuth 凭据 + enabled: false # 启用需配置 OAuth 凭据 client_id: "${GOOG_CLIENT_ID}" # Google OAuth2 Client ID client_secret: "${GOOG_CLIENT_SECRET}" # Google OAuth2 Client Secret refresh_token: "${GOOG_REFRESH_TOKEN}" # Google OAuth2 Refresh Token @@ -96,7 +96,7 @@ vendors: recovery_timeout_seconds: 300 success_threshold: 2 quota_guard: - enabled: true # 启用后按 Premium Requests 配额管理 + enabled: false # 启用后按 Premium Requests 配额管理 token_budget: 0 window_hours: 24.0 threshold_percent: 95.0 diff --git a/src/coding/proxy/convert/vendor_channels.py b/src/coding/proxy/convert/vendor_channels.py new file mode 100644 index 0000000..0c9146c --- /dev/null +++ b/src/coding/proxy/convert/vendor_channels.py @@ -0,0 +1,378 @@ +"""供应商跨供应商转换通道 — 源→目标绑定的请求体预处理. + +每个通道函数表示一个具体的「源 vendor → 目标 vendor」绑定转换关系, +接收标准化的 Anthropic 格式请求体,返回清理跨供应商产物后的请求体(深拷贝)。 + +通道注册表 ``VENDOR_TRANSITIONS`` 提供统一的 (source, target) → channel_fn 映射, +executor 层通过 ``get_transition_channel()`` 查表分发,无需感知具体供应商逻辑。 + +转换矩阵(仅注册需要转换的源→目标对,未注册的不触发任何通道): + zhipu → anthropic : prepare_zhipu_to_anthropic (剥离 thinking + tool pairing) + zhipu → copilot : prepare_zhipu_to_copilot (剥离 thinking + cache_control + tool pairing) + copilot → zhipu : prepare_copilot_to_zhipu (剥离 thinking + cache_control + 移除 thinking 参数 + tool pairing) +""" + +from __future__ import annotations + +import copy +import logging +from collections.abc import Callable +from typing import Any + +logger = logging.getLogger(__name__) + +_THINKING_BLOCK_TYPES = {"thinking", "redacted_thinking"} + +# ── 转换通道注册表 ───────────────────────────────────────────── +# (source_vendor, target_vendor) → (body) → (prepared_body, adaptations) +VENDOR_TRANSITIONS: dict[ + tuple[str, str], Callable[[dict[str, Any]], tuple[dict[str, Any], list[str]]] +] = {} + + +def get_transition_channel( + source: str, target: str +) -> Callable[[dict[str, Any]], tuple[dict[str, Any], list[str]]] | None: + """查找源→目标绑定转换通道,不存在时返回 None.""" + return VENDOR_TRANSITIONS.get((source, target)) + + +# ── 共享辅助函数 ────────────────────────────────────────────── + + +def strip_thinking_blocks(body: dict[str, Any]) -> int: + """从 assistant 消息中移除 thinking/redacted_thinking 块(就地). + + Anthropic API 要求 thinking blocks 的 signature 必须是其签发的有效签名。 + 跨供应商迁移(如 Zhipu → Anthropic)后,conversation history 中可能包含 + 非 Anthropic 签发的 signature,导致 400 invalid_request_error。 + 根据 Anthropic 官方文档,thinking blocks 可以被安全省略,不影响模型行为。 + + 剥离后 content 为空时插入最小占位 text block 以保持消息结构合法性。 + + Returns: + 被移除的 thinking block 数量。 + """ + stripped = 0 + for message in body.get("messages", []): + if not isinstance(message, dict) or message.get("role") != "assistant": + continue + content = message.get("content") + if not isinstance(content, list): + continue + original_len = len(content) + new_content = [ + block + for block in content + if not ( + isinstance(block, dict) and block.get("type") in _THINKING_BLOCK_TYPES + ) + ] + removed = original_len - len(new_content) + if removed and not new_content: + new_content = [{"type": "text", "text": "[thinking]"}] + logger.info( + "Inserted placeholder text block after stripping " + "%d thinking block(s) to avoid empty assistant content", + removed, + ) + message["content"] = new_content + stripped += removed + return stripped + + +def enforce_anthropic_tool_pairing( + messages_list: list[dict[str, Any]], +) -> list[str]: + """为跨供应商场景强制保证 Anthropic tool_use/tool_result 配对约束. + + 单次正向遍历所有消息,对每个 assistant 消息执行: + + 1. 剥离所有 tool_result 块(跨供应商产物,如 GLM-5 内联的 tool_result) + 2. 收集所有 tool_use ID + 3. 确保紧邻的下一条消息是 user 消息且包含所有必需的 tool_result + 4. 将剥离的 tool_result 重定位到正确的 user 消息 + 5. 为仍缺失的 tool_result 合成 ``is_error=True`` 的占位块 + + 此函数是一个**自包含的单遍处理**,不依赖 Phase 1 收集的 misplaced 信息。 + + Args: + messages_list: 消息列表(就地修改)。 + + Returns: + 新增的 adaptation 标签列表。 + """ + adaptations: list[str] = [] + relocated_count = 0 + synthesized_ids: list[str] = [] + + i = 0 + while i < len(messages_list): + msg = messages_list[i] + if not isinstance(msg, dict) or msg.get("role") != "assistant": + i += 1 + continue + + content = msg.get("content") + if not isinstance(content, list): + i += 1 + continue + + # A. 从 assistant 消息中剥离所有 tool_result 块 + extracted_tool_results: dict[str, dict[str, Any]] = {} + retained_content: list[Any] = [] + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_result": + tid = block.get("tool_use_id") + if tid: + extracted_tool_results[tid] = block + relocated_count += 1 + else: + retained_content.append(block) + + if extracted_tool_results: + msg["content"] = retained_content + + # B. 收集所有 tool_use ID + tool_use_ids: list[str] = [ + b["id"] + for b in ( + msg.get("content") if isinstance(msg.get("content"), list) else [] + ) + if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id") + ] + if not tool_use_ids: + current_content = msg.get("content") + if isinstance(current_content, list) and not current_content: + msg["content"] = [{"type": "text", "text": ""}] + i += 1 + continue + + # C. 确保 messages[i+1] 是 user 消息 + next_idx = i + 1 + if ( + next_idx < len(messages_list) + and isinstance(messages_list[next_idx], dict) + and messages_list[next_idx].get("role") == "user" + ): + user_msg = messages_list[next_idx] + else: + user_msg: dict[str, Any] = {"role": "user", "content": []} + messages_list.insert(next_idx, user_msg) + + # D. 确保 user_msg.content 是 list + user_content = user_msg.get("content") + if isinstance(user_content, str): + user_msg["content"] = [{"type": "text", "text": user_content}] + elif not isinstance(user_content, list): + user_msg["content"] = [] + + # E. 收集 user 消息中已有的 tool_result IDs + existing_result_ids: set[str] = { + b["tool_use_id"] + for b in user_msg["content"] + if isinstance(b, dict) + and b.get("type") == "tool_result" + and b.get("tool_use_id") + } + + # F. 为每个 tool_use_id 确保 tool_result 存在 + for uid in tool_use_ids: + if uid in existing_result_ids: + continue + if uid in extracted_tool_results: + user_msg["content"].append(extracted_tool_results[uid]) + else: + user_msg["content"].append( + { + "type": "tool_result", + "tool_use_id": uid, + "content": "", + "is_error": True, + } + ) + synthesized_ids.append(uid) + + i += 1 + + if relocated_count: + adaptations.append("misplaced_tool_result_relocated") + if synthesized_ids: + adaptations.append("orphaned_tool_use_repaired") + logger.warning( + "Vendor degradation adaptation: synthesized %d tool_result block(s) " + "for orphaned tool_use to satisfy Anthropic pairing constraint. " + "Affected tool_use_ids: %s", + len(synthesized_ids), + ", ".join(synthesized_ids), + ) + + return adaptations + + +def _strip_cache_control(body: dict[str, Any]) -> int: + """从 system/messages/tools 中移除 cache_control 字段(就地). + + 部分供应商(GLM-5、OpenAI)不支持 Anthropic 的 cache_control 扩展, + 保留该字段可能导致请求被拒绝或产生意外行为。 + + Returns: + 被移除的 cache_control 字段数量。 + """ + removed = 0 + + # System prompt blocks + system = body.get("system") + if isinstance(system, list): + for block in system: + if isinstance(block, dict) and "cache_control" in block: + del block["cache_control"] + removed += 1 + + # Message content blocks + for message in body.get("messages", []): + if not isinstance(message, dict): + continue + content = message.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and "cache_control" in block: + del block["cache_control"] + removed += 1 + + # Tools + for tool in body.get("tools", []): + if isinstance(tool, dict) and "cache_control" in tool: + del tool["cache_control"] + removed += 1 + + return removed + + +# ── copilot → zhipu 转换通道 ───────────────────────────────────── + + +def prepare_copilot_to_zhipu( + body: dict[str, Any], +) -> tuple[dict[str, Any], list[str]]: + """copilot → zhipu 转换: 清理 copilot 产物以适配 GLM-5. + + GLM-5 的 Anthropic 兼容端点对以下特性支持不完整: + - thinking / redacted_thinking 块 (signature 由非 Anthropic 签发) + - cache_control 字段 + - 跨供应商产物 (misplaced tool_result, 非标准 tool_use ID) + - 顶层 thinking / extended_thinking 参数 + + Returns: + (prepared_body, adaptations) — adaptations 为应用的变换描述列表。 + """ + prepared = copy.deepcopy(body) + adaptations: list[str] = [] + + # Step 1: 剥离 thinking/redacted_thinking 块 + stripped = strip_thinking_blocks(prepared) + if stripped: + adaptations.append(f"stripped_{stripped}_thinking_blocks") + + # Step 2: 移除 cache_control 字段 + removed_cc = _strip_cache_control(prepared) + if removed_cc: + adaptations.append(f"removed_{removed_cc}_cache_control_fields") + + # Step 3: 移除顶层 thinking/extended_thinking 参数(GLM-5 不支持) + for param in ("thinking", "extended_thinking"): + if param in prepared: + del prepared[param] + adaptations.append(f"removed_{param}_param") + + # Step 4: 强制 tool_use/tool_result 配对 + pairing_fixes = enforce_anthropic_tool_pairing(prepared.get("messages", [])) + if pairing_fixes: + adaptations.extend(pairing_fixes) + + return prepared, adaptations + + +# ── zhipu → copilot 转换通道 ───────────────────────────────────── + + +def prepare_zhipu_to_copilot( + body: dict[str, Any], +) -> tuple[dict[str, Any], list[str]]: + """zhipu → copilot 转换: 清理 zhipu 产物以确保 OpenAI 转换器稳定. + + Copilot 内部的 convert_openai_request() 处理 Anthropic→OpenAI 格式转换, + 但 zhipu 的跨供应商产物可能导致转换器输出异常: + - 非 Anthropic 签发的 thinking signature + - cache_control 字段(OpenAI 协议不支持) + - 错位的 tool_result blocks + + 注意: 不移除顶层 thinking 参数,由 copilot converter 自行映射。 + + Returns: + (prepared_body, adaptations) — adaptations 为应用的变换描述列表。 + """ + prepared = copy.deepcopy(body) + adaptations: list[str] = [] + + # Step 1: 剥离 thinking/redacted_thinking 块 + stripped = strip_thinking_blocks(prepared) + if stripped: + adaptations.append(f"stripped_{stripped}_thinking_blocks") + + # Step 2: 移除 cache_control 字段 + removed_cc = _strip_cache_control(prepared) + if removed_cc: + adaptations.append(f"removed_{removed_cc}_cache_control_fields") + + # Step 3: 强制 tool_use/tool_result 配对 + pairing_fixes = enforce_anthropic_tool_pairing(prepared.get("messages", [])) + if pairing_fixes: + adaptations.extend(pairing_fixes) + + return prepared, adaptations + + +# ── zhipu → anthropic 转换通道 ──────────────────────────────────── + + +def prepare_zhipu_to_anthropic( + body: dict[str, Any], +) -> tuple[dict[str, Any], list[str]]: + """zhipu → anthropic 转换: 清理 zhipu 产物以适配 Anthropic API. + + Anthropic API 要求: + - 每个 tool_use 必须在紧随的 user 消息中有对应 tool_result + - thinking blocks 的 signature 必须是 Anthropic 签发(zhipu 签发的无效) + + 此通道执行两项变换: + 1. enforce_anthropic_tool_pairing: 单遍正向扫描修复配对 + 2. strip_thinking_blocks: 移除非 Anthropic 签发的 thinking 块 + + 两项变换均为幂等操作,安全地在已清理的请求体上重复执行。 + + Returns: + (prepared_body, adaptations) — adaptations 为应用的变换描述列表。 + """ + prepared = copy.deepcopy(body) + adaptations: list[str] = [] + + # Step 1: 强制 tool_use/tool_result 配对 + pairing_fixes = enforce_anthropic_tool_pairing(prepared.get("messages", [])) + if pairing_fixes: + adaptations.extend(pairing_fixes) + + # Step 2: 剥离 thinking blocks(zhipu signature 无效) + stripped = strip_thinking_blocks(prepared) + if stripped: + adaptations.append(f"stripped_{stripped}_thinking_blocks") + + return prepared, adaptations + + +# ── 注册所有转换通道 ────────────────────────────────────────────── + +VENDOR_TRANSITIONS[("zhipu", "anthropic")] = prepare_zhipu_to_anthropic +VENDOR_TRANSITIONS[("zhipu", "copilot")] = prepare_zhipu_to_copilot +VENDOR_TRANSITIONS[("copilot", "zhipu")] = prepare_copilot_to_zhipu diff --git a/src/coding/proxy/logging/stats.py b/src/coding/proxy/logging/stats.py index bb1bf49..cbfe86a 100644 --- a/src/coding/proxy/logging/stats.py +++ b/src/coding/proxy/logging/stats.py @@ -198,7 +198,7 @@ async def show_usage( if cost_totals: total_cost_str = " + ".join( - f"{cur.symbol}{amt:.4f}" for cur, amt in cost_totals.items() + f"{cur.symbol}{amt:.2f}" for cur, amt in cost_totals.items() ) else: total_cost_str = "-" diff --git a/src/coding/proxy/model/pricing.py b/src/coding/proxy/model/pricing.py index 72ed35b..94fce72 100644 --- a/src/coding/proxy/model/pricing.py +++ b/src/coding/proxy/model/pricing.py @@ -50,8 +50,8 @@ class CostValue: amount: float currency: Currency = Currency.default() - def format(self, precision: int = 4) -> str: - """格式化为 ``$0.1234`` 或 ``¥0.1234``.""" + def format(self, precision: int = 2) -> str: + """格式化为 ``$0.12`` 或 ``¥0.12``.""" return f"{self.currency.symbol}{self.amount:.{precision}f}" @property diff --git a/src/coding/proxy/routing/executor.py b/src/coding/proxy/routing/executor.py index 1f30c2d..d9f84b4 100644 --- a/src/coding/proxy/routing/executor.py +++ b/src/coding/proxy/routing/executor.py @@ -6,7 +6,6 @@ from __future__ import annotations -import copy import logging import time from collections.abc import AsyncIterator @@ -227,39 +226,63 @@ def _prepare_body_for_tier( self, body: dict[str, Any], tier: VendorTier, - normalization: Any = None, + source_vendor: str | None = None, ) -> dict[str, Any]: - """为指定 tier 准备请求体,必要时应用 Anthropic 专属修复(Phase 2). + """为指定 tier 准备请求体,应用源→目标绑定转换通道. - 仅当 tier 为 Anthropic 且 NormalizationResult 标记需要修复时, - 才执行 deep copy + Phase 2 修复,确保 Zhipu 等其他 vendor 不受影响。 + 通过 VENDOR_TRANSITIONS 注册表查表分发,executor 不感知具体供应商逻辑。 + 未注册转换的源→目标对或 source_vendor 为 None 时原样返回请求体。 """ - if normalization is None or not normalization.has_anthropic_fixes: - return body - if tier.name != "anthropic": + from ..convert.vendor_channels import get_transition_channel + + if source_vendor is None: return body - from ..server.request_normalizer import apply_anthropic_specific_fixes + channel_fn = get_transition_channel(source_vendor, tier.name) + if channel_fn is None: + return body - body_for_vendor = copy.deepcopy(body) - fixes = apply_anthropic_specific_fixes( - body_for_vendor.get("messages", []), - normalization.misplaced_tool_results, - normalization.misplaced_log_info, - ) - if fixes: + prepared, adaptations = channel_fn(body) + if adaptations: logger.debug( - "Applied Anthropic-specific fixes for tier %s: %s", + "Applied transition channel %s → %s: %s", + source_vendor, tier.name, - ", ".join(fixes), + ", ".join(adaptations), ) - return body_for_vendor + return prepared + + @staticmethod + def _determine_source_vendor( + target_name: str, + failed_tier_name: str | None, + session_record: Any, + ) -> str | None: + """确定跨供应商转换的源 vendor. + + Priority 1: failed_tier_name(请求内故障转移,最可靠)。 + Priority 2: session_record.provider_state 中有已注册转换的 vendor(跨请求)。 + """ + # 请求内:刚失败的 tier 就是源 + if failed_tier_name and failed_tier_name != target_name: + return failed_tier_name + + # 跨请求:从会话历史找有注册转换的源 + if session_record is not None and session_record.provider_state: + from ..convert.vendor_channels import get_transition_channel + + for source in session_record.provider_state: + if source != target_name and get_transition_channel( + source, target_name + ): + return source + + return None async def execute_stream( self, body: dict[str, Any], headers: dict[str, str], - normalization: Any = None, ) -> AsyncIterator[tuple[bytes, str]]: """路由流式请求,按优先级尝试各层级.""" last_idx = len(self._tiers) - 1 @@ -291,7 +314,10 @@ async def execute_stream( usage: dict[str, Any] = {} try: - body_for_tier = self._prepare_body_for_tier(body, tier, normalization) + source_vendor = _RouteExecutor._determine_source_vendor( + tier.name, failed_tier_name, session_record + ) + body_for_tier = self._prepare_body_for_tier(body, tier, source_vendor) async for chunk in tier.vendor.send_message_stream( body_for_tier, headers ): @@ -426,7 +452,6 @@ async def execute_message( self, body: dict[str, Any], headers: dict[str, str], - normalization: Any = None, ) -> VendorResponse: """路由非流式请求,按优先级尝试各层级.""" last_idx = len(self._tiers) - 1 @@ -455,7 +480,10 @@ async def execute_message( continue try: - body_for_tier = self._prepare_body_for_tier(body, tier, normalization) + source_vendor = _RouteExecutor._determine_source_vendor( + tier.name, failed_tier_name, session_record + ) + body_for_tier = self._prepare_body_for_tier(body, tier, source_vendor) resp = await tier.vendor.send_message(body_for_tier, headers) if resp.status_code < 400: diff --git a/src/coding/proxy/routing/router.py b/src/coding/proxy/routing/router.py index 355a91f..3a65cd6 100644 --- a/src/coding/proxy/routing/router.py +++ b/src/coding/proxy/routing/router.py @@ -134,24 +134,18 @@ async def route_stream( self, body: dict[str, Any], headers: dict[str, str], - normalization: Any = None, ) -> AsyncIterator[tuple[bytes, str]]: """路由流式请求,按优先级尝试各层级.""" - async for chunk, vendor_name in self._executor.execute_stream( - body, headers, normalization=normalization - ): + async for chunk, vendor_name in self._executor.execute_stream(body, headers): yield chunk, vendor_name async def route_message( self, body: dict[str, Any], headers: dict[str, str], - normalization: Any = None, ) -> Any: """路由非流式请求,按优先级尝试各层级.""" - return await self._executor.execute_message( - body, headers, normalization=normalization - ) + return await self._executor.execute_message(body, headers) # ── 生命周期 ─────────────────────────────────────────── diff --git a/src/coding/proxy/server/dashboard.py b/src/coding/proxy/server/dashboard.py index ef8595e..8221125 100644 --- a/src/coding/proxy/server/dashboard.py +++ b/src/coding/proxy/server/dashboard.py @@ -201,6 +201,7 @@ def _build_favicon() -> bytes: font-family: 'JetBrains Mono', monospace; letter-spacing: -1px; } + #kpi-cost-today { font-size: 20px; white-space: nowrap; } .kpi-sub { font-size: 13px; color: var(--text-tertiary); margin-top: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; } .color-blue { color: var(--accent-blue); } .color-green { color: var(--accent-green); } @@ -910,8 +911,21 @@ def _build_favicon() -> bytes: }).join(''); } +// ── 按 tiers 顺序排序 vendor 列表 ───────────────────────── +function sortByTierOrder(vendors, tierOrder) { + if (!tierOrder || !tierOrder.length) return vendors.sort(); + const orderMap = {}; + tierOrder.forEach((name, i) => { orderMap[name] = i; }); + const maxIdx = tierOrder.length; + return vendors.sort((a, b) => { + const ia = orderMap[a] ?? maxIdx; + const ib = orderMap[b] ?? maxIdx; + return ia !== ib ? ia - ib : a.localeCompare(b); + }); +} + // ── 时序折线图(请求量,按 vendor)──────────────────────── -function buildTimeline(rows) { +function buildTimeline(rows, tierOrder) { const vendorDateMap = {}; const allDates = new Set(); for (const r of rows) { @@ -922,7 +936,7 @@ def _build_favicon() -> bytes: allDates.add(d); } const dates = [...allDates].sort(); - const vendors = Object.keys(vendorDateMap).sort(); + const vendors = sortByTierOrder(Object.keys(vendorDateMap), tierOrder); if (chartTimeline) chartTimeline.destroy(); const ctx = document.getElementById('chart-timeline').getContext('2d'); @@ -961,14 +975,14 @@ def _build_favicon() -> bytes: } // ── 供应商分布环形图 ────────────────────────────────────── -function buildVendorDist(rows) { +function buildVendorDist(rows, tierOrder) { const vendorTotals = {}; for (const r of rows) { const v = r.vendor; if (!isValidLabel(v)) continue; vendorTotals[v] = (vendorTotals[v] || 0) + (r.total_requests || 0); } - const labels = Object.keys(vendorTotals).sort((a,b) => vendorTotals[b]-vendorTotals[a]); + const labels = sortByTierOrder(Object.keys(vendorTotals), tierOrder); const data = labels.map(v => vendorTotals[v]); if (chartVendorDist) chartVendorDist.destroy(); @@ -1026,7 +1040,7 @@ def _build_favicon() -> bytes: } // ── Token 量趋势折线图(按 vendor)─────────────────────── -function buildTokenTimeline(rows) { +function buildTokenTimeline(rows, tierOrder) { const vendorDateMap = {}; const allDates = new Set(); for (const r of rows) { @@ -1039,7 +1053,7 @@ def _build_favicon() -> bytes: allDates.add(d); } const dates = [...allDates].sort(); - const vendors = Object.keys(vendorDateMap).sort(); + const vendors = sortByTierOrder(Object.keys(vendorDateMap), tierOrder); if (chartTokenTimeline) chartTokenTimeline.destroy(); const ctx = document.getElementById('chart-token-timeline').getContext('2d'); @@ -1257,9 +1271,10 @@ def _build_favicon() -> bytes: updateChartTitles(days); const rows = timeline.rows || []; - buildTimeline(rows); - buildVendorDist(rows); - buildTokenTimeline(rows); + const tierOrder = (status.tiers || []).map(t => t.name); + buildTimeline(rows, tierOrder); + buildVendorDist(rows, tierOrder); + buildTokenTimeline(rows, tierOrder); buildModelTokenTimeline(rows); document.getElementById('refresh-time').textContent = '上次刷新: ' + now(); @@ -1339,7 +1354,7 @@ def _compute_cost_str(rows: list[dict], pricing_table: Any) -> str: if not cost_totals: return "–" - return " + ".join(f"{cur.symbol}{amt:.4f}" for cur, amt in cost_totals.items()) + return " + ".join(f"{cur.symbol}{amt:.2f}" for cur, amt in cost_totals.items()) # ── 路由注册 ────────────────────────────────────────────────────────────── diff --git a/src/coding/proxy/server/request_normalizer.py b/src/coding/proxy/server/request_normalizer.py index cd366b0..cbc18f5 100644 --- a/src/coding/proxy/server/request_normalizer.py +++ b/src/coding/proxy/server/request_normalizer.py @@ -10,12 +10,6 @@ logger = logging.getLogger(__name__) -# ── 跨请求日志去重:记录已报告过的 misplaced tool_use_id ────────── -# 同一 tool_use_id 仅首次输出 WARNING(含完整因果上下文), -# 后续在同一会话中重复出现时降级为 DEBUG,避免日志噪声。 -_LOGGED_MISPLACED_TOOL_IDS: set[str] = set() -_LOGGED_MISPLACED_TOOL_IDS_MAX = 500 - _ANTHROPIC_TOOL_USE_ID_RE = re.compile(r"^toolu_[A-Za-z0-9_]+$") _ANTHROPIC_SERVER_TOOL_USE_ID_RE = re.compile(r"^srvtoolu_[A-Za-z0-9_]+$") _VENDOR_TOOL_BLOCK_TYPES = { @@ -30,36 +24,20 @@ class NormalizationResult: body: dict[str, Any] adaptations: list[str] = field(default_factory=list) fatal_reasons: list[str] = field(default_factory=list) - # Phase 2 上下文(仅 Anthropic tier 使用) - tool_id_map: dict[str, str] = field(default_factory=dict) - misplaced_tool_results: list[tuple[int, dict[str, Any]]] = field( - default_factory=list - ) - misplaced_log_info: list[tuple[str, int, int, str]] = field(default_factory=list) @property def recoverable(self) -> bool: return not self.fatal_reasons - @property - def has_anthropic_fixes(self) -> bool: - """是否需要应用 Anthropic 专属修复(重定位 + 孤儿修复).""" - return bool(self.misplaced_tool_results) or bool(self.tool_id_map) - def normalize_anthropic_request(body: dict[str, Any]) -> NormalizationResult: """清洗供应商私有块,尽量恢复为合法 Anthropic Messages 请求. - 这是 vendor-agnostic 的 Phase 1 规范化:对所有 vendor 均适用。 + vendor-agnostic 的 Phase 1 规范化:对所有 vendor 均适用。 处理策略: 1. 移除供应商私有块(如 server_tool_use_delta) 2. 重写无效/非标准的 tool_use / tool_result ID - 3. **收集**(但不应用)错位的 tool_result 块信息,供 Phase 2 使用 - - Phase 2(Anthropic 专属修复:重定位 + 孤儿修复)由 - :func:`apply_anthropic_specific_fixes` 独立执行,仅在请求实际发送给 - Anthropic tier 时调用,确保 Zhipu 等其他 vendor 不受影响。 """ normalized = copy.deepcopy(body) adaptations: list[str] = [] @@ -72,14 +50,6 @@ def next_tool_id() -> str: normalized_counter += 1 return f"toolu_normalized_{normalized_counter}" - # 收集本轮 misplaced tool_result 块(Phase 2 延迟到 Anthropic tier 执行) - collected_misplaced: list[ - tuple[int, dict[str, Any]] - ] = [] # (source_msg_idx, block) - misplaced_log_info: list[ - tuple[str, int, int, str] - ] = [] # (role, msg_idx, blk_idx, tool_use_id) - def normalize_content_block( block: Any, *, @@ -141,8 +111,6 @@ def normalize_content_block( _ANTHROPIC_TOOL_USE_ID_RE.match(tool_use_id) or _ANTHROPIC_SERVER_TOOL_USE_ID_RE.match(tool_use_id) ): - # 保持原样。对 server_tool_use_id 的用户结果,若未在当前请求体中出现, - # 交由上游决定是否接受,避免错误猜测跨轮次关联。 return normalized_block elif isinstance(tool_use_id, str) and tool_use_id: fatal_reasons.append( @@ -156,23 +124,14 @@ def normalize_content_block( return None return normalized_block - # tool_result 出现在非 user 消息中(如 assistant)—— 仅收集供 Phase 2 使用。 - # Phase 2 由 apply_anthropic_specific_fixes() 执行,仅在 Anthropic tier 时调用。 - # 对于 Zhipu 等其他 vendor,misplaced 块保留在原位不变。 + # tool_result 出现在非 user 消息中(跨供应商产物)—— 保留原样。 + # 跨供应商的 tool_use/tool_result 配对修复由 vendor_channels.py 中的 + # enforce_anthropic_tool_pairing() 在源→目标转换通道中处理。 normalized_block = dict(block) tool_use_id = normalized_block.get("tool_use_id") if isinstance(tool_use_id, str) and tool_use_id in tool_id_map: normalized_block["tool_use_id"] = tool_id_map[tool_use_id] adaptations.append("tool_result_tool_use_id_rewritten") - collected_misplaced.append((message_index, normalized_block)) - misplaced_log_info.append( - ( - message_role, - message_index, - block_index, - normalized_block.get("tool_use_id", "N/A"), - ) - ) return normalized_block return dict(block) @@ -200,263 +159,4 @@ def normalize_content_block( body=normalized, adaptations=sorted(set(adaptations)), fatal_reasons=fatal_reasons, - tool_id_map=tool_id_map, - misplaced_tool_results=collected_misplaced, - misplaced_log_info=misplaced_log_info, ) - - -def apply_anthropic_specific_fixes( - messages_list: list[dict[str, Any]], - misplaced_results: list[tuple[int, dict[str, Any]]], - misplaced_log_info: list[tuple[str, int, int, str]], -) -> list[str]: - """应用 Anthropic 专属修复(重定位 + 孤儿修复). - - 仅在请求实际发送给 Anthropic tier 时调用,确保 Zhipu 等其他 vendor 不受影响。 - Phase 1(normalize_anthropic_request)仅收集 misplaced 信息,将实际修复延迟到此函数。 - - Args: - messages_list: 消息列表(就地修改)。 - misplaced_results: Phase 1 收集的 misplaced tool_result 列表, - 每个元素为 (source_msg_idx, block)。 - misplaced_log_info: Phase 1 收集的日志信息列表, - 每个元素为 (role, msg_idx, blk_idx, tool_use_id)。 - - Returns: - 新增的 adaptation 标签列表。 - """ - adaptations: list[str] = [] - - if misplaced_results: - # ── 1. 从源消息中移除 misplaced tool_result 块 ─────────── - to_remove: dict[int, set[str]] = {} - for source_idx, block in misplaced_results: - tid = block.get("tool_use_id", "") - if tid: - to_remove.setdefault(source_idx, set()).add(tid) - - for msg_idx, tids in to_remove.items(): - if msg_idx < len(messages_list): - msg = messages_list[msg_idx] - if isinstance(msg, dict): - content = msg.get("content") - if isinstance(content, list): - msg["content"] = [ - b - for b in content - if not ( - isinstance(b, dict) - and b.get("type") == "tool_result" - and b.get("tool_use_id") in tids - ) - ] - - # ── 2. 重定位到紧邻的 user 消息 ────────────────────────── - # 按源消息索引降序处理,避免插入新消息时索引偏移。 - for source_idx, result_block in sorted( - misplaced_results, key=lambda x: x[0], reverse=True - ): - target_user_idx = None - for j in range(source_idx + 1, len(messages_list)): - if ( - isinstance(messages_list[j], dict) - and messages_list[j].get("role") == "user" - ): - target_user_idx = j - break - - if target_user_idx is not None: - target_content = messages_list[target_user_idx].get("content") - if isinstance(target_content, list): - target_content.append(result_block) - elif isinstance(target_content, str): - # string content 转为 text block 后追加,避免丢失原始文本 - messages_list[target_user_idx]["content"] = [ - {"type": "text", "text": target_content}, - result_block, - ] - else: - messages_list[target_user_idx]["content"] = [result_block] - else: - # 无后续 user 消息:插入一条合成 user 消息 - messages_list.insert( - source_idx + 1, - { - "role": "user", - "content": [result_block], - }, - ) - - adaptations.append("misplaced_tool_result_relocated") - - if misplaced_log_info: - _emit_misplaced_tool_result_summary(misplaced_log_info) - - # ── 3. 修复通道:为孤儿 tool_use 合成 tool_result ────────────── - repaired = _repair_orphaned_tool_use(messages_list) - if repaired: - adaptations.append("orphaned_tool_use_repaired") - total_synthesized = sum(repaired.values()) - logger.warning( - "Vendor degradation adaptation: synthesized %d tool_result block(s) " - "for orphaned tool_use to satisfy Anthropic pairing constraint. " - "Affected tool_use_ids: %s", - total_synthesized, - ", ".join(sorted(repaired)), - ) - - return adaptations - - -def _emit_misplaced_tool_result_summary( - stripped: list[tuple[str, int, int, str]], -) -> None: - """为被重定位的 misplaced tool_result 输出汇总日志. - - 策略: - - 将同一次请求中的多个重定位事件合并为单条日志 - - 首次出现的 tool_use_id → WARNING(含完整因果上下文) - - 同一 tool_use_id 在后续请求中再次出现 → DEBUG(避免日志噪声) - - 注意:此函数运行在 asyncio 事件循环的主线程中,set 操作无需加锁。 - - Args: - stripped: 每个元素为 (message_role, message_index, block_index, tool_use_id) - """ - # 提取去重后的 tool_use_id 集合 - unique_tool_ids = {tid for _, _, _, tid in stripped} - - # 区分首次出现 vs 已报告过的 - new_id_set = unique_tool_ids - _LOGGED_MISPLACED_TOOL_IDS - known_id_set = unique_tool_ids & _LOGGED_MISPLACED_TOOL_IDS - - # 更新已报告集合(防止无限增长:保留最近一半条目) - _LOGGED_MISPLACED_TOOL_IDS.update(unique_tool_ids) - if len(_LOGGED_MISPLACED_TOOL_IDS) > _LOGGED_MISPLACED_TOOL_IDS_MAX: - to_keep = sorted(_LOGGED_MISPLACED_TOOL_IDS)[ - _LOGGED_MISPLACED_TOOL_IDS_MAX // 2 : - ] - _LOGGED_MISPLACED_TOOL_IDS.clear() - _LOGGED_MISPLACED_TOOL_IDS.update(to_keep) - - if new_id_set: - # 首次出现:WARNING + 完整因果上下文 - positions = ", ".join( - f"messages.{mi}.content.{bi} (role={r}, tool_use_id={tid})" - for r, mi, bi, tid in stripped - if tid in new_id_set - ) - new_count = sum(1 for _, _, _, tid in stripped if tid in new_id_set) - logger.warning( - "Vendor degradation adaptation: relocated %d misplaced tool_result block(s) " - "from non-user message(s) to adjacent user message(s). Cause: cross-vendor " - "conversation history contains tool_result blocks in assistant messages " - "(typical when GLM-5 includes tool results inline in responses). Anthropic " - "API requires tool_result only in user messages, so these blocks are " - "relocated to maintain tool_use/tool_result pairing. Affected: %s. " - "Subsequent occurrences of these tool_use_ids will be logged at DEBUG level.", - new_count, - positions, - ) - - if known_id_set: - # 已报告过的:DEBUG - known_count = sum(1 for _, _, _, tid in stripped if tid in known_id_set) - logger.debug( - "Normalization: relocated %d previously reported misplaced tool_result " - "block(s) (tool_use_ids: %s)", - known_count, - ", ".join(sorted(known_id_set)), - ) - - -def _repair_orphaned_tool_use( - messages_list: list[dict[str, Any]], -) -> dict[str, int]: - """为孤儿 tool_use 块合成缺失的 tool_result. - - 遍历所有 assistant 消息,检查紧邻的 user 消息是否包含每个 tool_use - 对应的 tool_result。对于缺失的 tool_result,合成一个 ``is_error=true`` - 的占位块以满足 Anthropic API 约束。 - - Args: - messages_list: 消息列表(就地修改)。 - - Returns: - dict: 以 tool_use_id 为键、修复次数为值的映射;无修复时返回空 dict。 - """ - repaired: dict[str, int] = {} - i = 0 - while i < len(messages_list): - msg = messages_list[i] - if not isinstance(msg, dict) or msg.get("role") != "assistant": - i += 1 - continue - - content = msg.get("content") - if not isinstance(content, list): - i += 1 - continue - - # 收集当前 assistant 消息中所有 tool_use 的 id - tool_use_ids: list[str] = [ - b["id"] - for b in content - if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id") - ] - if not tool_use_ids: - i += 1 - continue - - # 检查紧邻的 user 消息中已有的 tool_result - next_msg = messages_list[i + 1] if i + 1 < len(messages_list) else None - existing_result_ids: set[str] = set() - - if isinstance(next_msg, dict) and next_msg.get("role") == "user": - next_content = next_msg.get("content") - if isinstance(next_content, list): - existing_result_ids = { - b["tool_use_id"] - for b in next_content - if isinstance(b, dict) - and b.get("type") == "tool_result" - and b.get("tool_use_id") - } - - # 找出缺失的 tool_result - orphan_ids = [uid for uid in tool_use_ids if uid not in existing_result_ids] - if not orphan_ids: - i += 1 - continue - - # 为每个孤儿 tool_use 合成 tool_result - synthetic_blocks = [ - { - "type": "tool_result", - "tool_use_id": uid, - "content": "", - "is_error": True, - } - for uid in orphan_ids - ] - - if isinstance(next_msg, dict) and next_msg.get("role") == "user": - next_content = next_msg.get("content") - if isinstance(next_content, list): - next_content.extend(synthetic_blocks) - elif isinstance(next_content, str): - next_msg["content"] = [ - {"type": "text", "text": next_content} - ] + synthetic_blocks - else: - next_msg["content"] = synthetic_blocks - else: - # 无紧邻 user 消息:插入合成 user 消息 - messages_list.insert(i + 1, {"role": "user", "content": synthetic_blocks}) - - for uid in orphan_ids: - repaired[uid] = repaired.get(uid, 0) + 1 - - i += 1 - return repaired diff --git a/src/coding/proxy/server/routes.py b/src/coding/proxy/server/routes.py index 2058a89..5ad312d 100644 --- a/src/coding/proxy/server/routes.py +++ b/src/coding/proxy/server/routes.py @@ -24,14 +24,10 @@ logger = logging.getLogger(__name__) -async def _stream_proxy( - router: Any, body: dict, headers: dict, normalization: Any = None -) -> Any: +async def _stream_proxy(router: Any, body: dict, headers: dict) -> Any: """流式代理生成器.""" try: - async for chunk, vendor_name in router.route_stream( - body, headers, normalization=normalization - ): + async for chunk, vendor_name in router.route_stream(body, headers): yield chunk except NoCompatibleVendorError as exc: yield ( @@ -82,15 +78,13 @@ async def messages(request: Request) -> Response: if is_streaming: return StreamingResponse( - _stream_proxy(router, body, headers, normalization=normalization), + _stream_proxy(router, body, headers), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, ) try: - resp = await router.route_message( - body, headers, normalization=normalization - ) + resp = await router.route_message(body, headers) except NoCompatibleVendorError as exc: return json_error_response( 400, @@ -155,6 +149,13 @@ async def count_tokens(request: Request) -> Response: body = await request.json() headers = dict(request.headers) + + # count_tokens 无会话上下文,无法判断 thinking block 来源, + # 安全剥离以防止跨供应商 signature 导致 400 错误。 + from ..convert.vendor_channels import strip_thinking_blocks + + strip_thinking_blocks(body) + prepared_body, prepared_headers = await target_vendor._prepare_request( body, headers ) diff --git a/src/coding/proxy/vendors/anthropic.py b/src/coding/proxy/vendors/anthropic.py index e663ad0..df454f1 100644 --- a/src/coding/proxy/vendors/anthropic.py +++ b/src/coding/proxy/vendors/anthropic.py @@ -11,51 +11,6 @@ logger = logging.getLogger(__name__) -# 需要从 assistant messages 中剥离的 thinking block 类型 -_THINKING_BLOCK_TYPES = {"thinking", "redacted_thinking"} - - -def _strip_thinking_blocks(body: dict[str, Any]) -> int: - """从 assistant messages 中移除 thinking / redacted_thinking blocks. - - Anthropic API 要求 thinking blocks 的 ``signature`` 必须是其签发的有效签名。 - 跨供应商迁移(如 Zhipu → Anthropic)后,conversation history 中可能包含 - 非 Anthropic 签发的 signature,导致 400 ``invalid_request_error``。 - 根据 Anthropic 官方文档,thinking blocks 可以被安全省略,不影响模型行为。 - - Returns: - 被移除的 thinking block 数量。 - """ - stripped = 0 - for message in body.get("messages", []): - if not isinstance(message, dict) or message.get("role") != "assistant": - continue - content = message.get("content") - if not isinstance(content, list): - continue - original_len = len(content) - new_content = [ - block - for block in content - if not ( - isinstance(block, dict) and block.get("type") in _THINKING_BLOCK_TYPES - ) - ] - removed = original_len - len(new_content) - if removed and not new_content: - # 剥离所有 thinking blocks 后 content 为空 —— - # Anthropic API 要求非末尾 assistant message 的 content 必须非空。 - # 插入最小占位 text block 以保持消息结构合法性。 - new_content = [{"type": "text", "text": "[thinking]"}] - logger.info( - "anthropic: inserted placeholder text block after stripping " - "%d thinking block(s) to avoid empty assistant content", - removed, - ) - message["content"] = new_content - stripped += removed - return stripped - def _strip_misplaced_tool_results(body: dict[str, Any]) -> int: """从非 user 角色的消息中剥离 tool_result blocks(纵深防御). @@ -131,19 +86,13 @@ async def _prepare_request( request_body: dict[str, Any], headers: dict[str, str], ) -> tuple[dict[str, Any], dict[str, str]]: - """深拷贝请求体、剥离历史 thinking blocks 和错位的 tool_result blocks,过滤无关请求头. + """深拷贝请求体、剥离错位的 tool_result blocks,过滤无关请求头. 深拷贝确保 Anthropic 的请求体修改不会污染后续 tier 的输入。 - 剥离 thinking blocks 防止跨供应商 signature 不兼容导致 400 错误。 + thinking block 剥离已提升至 executor 层条件执行(仅跨供应商场景)。 剥离错位的 tool_result blocks 防止跨供应商 tool_result 放置位置不合规导致 400 错误。 """ body = copy.deepcopy(request_body) - stripped_thinking = _strip_thinking_blocks(body) - if stripped_thinking: - logger.debug( - "anthropic: stripped %d thinking block(s) from conversation history", - stripped_thinking, - ) stripped_tool_results = _strip_misplaced_tool_results(body) if stripped_tool_results: diff --git a/tests/test_currency.py b/tests/test_currency.py index 86c07f2..c50eb09 100644 --- a/tests/test_currency.py +++ b/tests/test_currency.py @@ -45,11 +45,11 @@ class TestCostValue: def test_format_usd(self): cv = CostValue(amount=0.3421, currency=Currency.USD) - assert cv.format() == "$0.3421" + assert cv.format() == "$0.34" def test_format_cny(self): cv = CostValue(amount=0.0953, currency=Currency.CNY) - assert cv.format() == "\u00a50.0953" + assert cv.format() == "\u00a50.10" def test_format_precision(self): cv = CostValue(amount=0.12345678, currency=Currency.USD) @@ -58,7 +58,7 @@ def test_format_precision(self): def test_format_zero(self): cv = CostValue(amount=0.0, currency=Currency.USD) - assert cv.format() == "$0.0000" + assert cv.format() == "$0.00" def test_equality_same_currency(self): a = CostValue(1.0, Currency.USD) diff --git a/tests/test_request_normalizer.py b/tests/test_request_normalizer.py index 8fc31de..bf653a9 100644 --- a/tests/test_request_normalizer.py +++ b/tests/test_request_normalizer.py @@ -4,21 +4,11 @@ import copy -from coding.proxy.server.request_normalizer import ( - apply_anthropic_specific_fixes, - normalize_anthropic_request, +from coding.proxy.convert.vendor_channels import ( + enforce_anthropic_tool_pairing, + strip_thinking_blocks, ) - - -def _apply_phase2(result): - """在 deep copy 上执行 Phase 2(Anthropic 专属修复),返回 (body, fixes).""" - body_copy = copy.deepcopy(result.body) - fixes = apply_anthropic_specific_fixes( - body_copy.get("messages", []), - result.misplaced_tool_results, - result.misplaced_log_info, - ) - return body_copy, fixes +from coding.proxy.server.request_normalizer import normalize_anthropic_request def test_rewrites_server_tool_use_to_standard_tool_use(): @@ -111,10 +101,10 @@ def test_unknown_tool_result_id_marks_fatal_reason(): class TestPhase1OnlyNormalization: - """验证 Phase 1(vendor-agnostic)不执行 Anthropic 专属修复. + """验证 Phase 1(vendor-agnostic)仅执行 ID 重写和 vendor block 移除. - Phase 1 仅执行 ID 重写、vendor block 移除、misplaced 信息收集。 - 重定位和孤儿修复由 Phase 2(apply_anthropic_specific_fixes)延迟执行。 + 跨供应商的 tool_use/tool_result 配对修复由 vendor_channels.py 中的 + enforce_anthropic_tool_pairing() 在源→目标转换通道中处理。 """ def test_phase1_keeps_misplaced_tool_result_in_place(self): @@ -145,12 +135,10 @@ def test_phase1_keeps_misplaced_tool_result_in_place(self): assert result.recoverable is True assert "misplaced_tool_result_relocated" not in result.adaptations assert "orphaned_tool_use_repaired" not in result.adaptations - # misplaced block 应仍在 assistant 消息中 + # misplaced block 应仍在 assistant 消息中(Phase 1 不处理) assistant_content = result.body["messages"][0]["content"] assert len(assistant_content) == 2 assert assistant_content[1]["type"] == "tool_result" - # Phase 2 上下文已收集 - assert len(result.misplaced_tool_results) == 1 def test_phase1_does_not_repair_orphans(self): """Phase 1 不应为孤儿 tool_use 合成 tool_result.""" @@ -176,512 +164,648 @@ def test_phase1_does_not_repair_orphans(self): assert "orphaned_tool_use_repaired" not in result.adaptations assert len(result.body["messages"]) == 1 # 无合成 user 消息 - -# ── 跨供应商 tool_result 位置错位重定位测试(Phase 1 + Phase 2)── - - -class TestMisplacedToolResultRelocation: - """验证 Phase 1 + Phase 2 组合能正确重定位 misplaced tool_result. - - Phase 1 收集 misplaced 信息,Phase 2 执行实际重定位。 - 此类测试模拟 executor 在 Anthropic tier 时的完整行为。 - """ - - def test_relocates_tool_result_from_assistant_message(self): - """assistant 消息中的 tool_result 经 Phase 2 重定位到新建的 user 消息.""" + def test_phase1_then_enforce_pairing(self): + """Phase 1 + enforce_anthropic_tool_pairing 端到端测试(模拟完整链路).""" result = normalize_anthropic_request( { "messages": [ - { - "role": "user", - "content": "run ls", - }, + {"role": "user", "content": "hello"}, { "role": "assistant", "content": [ { - "type": "tool_use", - "id": "toolu_123", + "type": "server_tool_use", + "id": "srvtoolu_X", "name": "Bash", "input": {"command": "ls"}, }, { - "type": "tool_result", - "tool_use_id": "toolu_123", - "content": "file1.txt\nfile2.txt", - }, - ], - }, - ], - } - ) - - # Phase 2 - body, fixes = _apply_phase2(result) - assert "misplaced_tool_result_relocated" in fixes - - messages = body["messages"] - assert len(messages) == 3 - # assistant 消息应只保留 tool_use - assistant_content = messages[1]["content"] - assert len(assistant_content) == 1 - assert assistant_content[0]["type"] == "tool_use" - assert assistant_content[0]["id"] == "toolu_123" - # 新增一条 user 消息包含被重定位的 tool_result - assert messages[2]["role"] == "user" - relocated_block = messages[2]["content"][0] - assert relocated_block["type"] == "tool_result" - assert relocated_block["tool_use_id"] == "toolu_123" - assert relocated_block["content"] == "file1.txt\nfile2.txt" - - def test_relocates_tool_result_preserves_other_blocks(self): - """重定位 tool_result 时保留同消息中的其他内容块.""" - result = normalize_anthropic_request( - { - "messages": [ - { - "role": "assistant", - "content": [ - {"type": "text", "text": "Let me check."}, - { - "type": "tool_use", - "id": "toolu_456", + "type": "server_tool_use", + "id": "srvtoolu_Y", "name": "Read", - "input": {"path": "/etc/hosts"}, + "input": {"path": "/tmp"}, }, { "type": "tool_result", - "tool_use_id": "toolu_456", - "content": "127.0.0.1 localhost", + "tool_use_id": "srvtoolu_X", + "content": "zhipu result", }, - {"type": "text", "text": "Done."}, ], }, + {"role": "user", "content": "continue"}, ], } ) + assert result.recoverable - # Phase 2 - body, fixes = _apply_phase2(result) + # 在 deep copy 上执行 enforce_anthropic_tool_pairing(模拟 vendor channel) + body_copy = copy.deepcopy(result.body) + fixes = enforce_anthropic_tool_pairing(body_copy.get("messages", [])) assert "misplaced_tool_result_relocated" in fixes + assert "orphaned_tool_use_repaired" in fixes - messages = body["messages"] - # assistant 消息保留 text + tool_use + text - assistant_content = messages[0]["content"] - assert len(assistant_content) == 3 - types = [b["type"] for b in assistant_content] - assert types == ["text", "tool_use", "text"] - # 新增 user 消息包含被重定位的 tool_result - assert len(messages) == 2 - assert messages[1]["role"] == "user" - assert messages[1]["content"][0]["type"] == "tool_result" - assert messages[1]["content"][0]["tool_use_id"] == "toolu_456" + messages = body_copy["messages"] + # assistant 只保留 2 个 tool_use + assistant_content = messages[1]["content"] + assert len(assistant_content) == 2 + assert all(b["type"] == "tool_use" for b in assistant_content) + # user 消息包含 2 个 tool_result + user_content = messages[2]["content"] + result_blocks = [b for b in user_content if b.get("type") == "tool_result"] + assert len(result_blocks) == 2 + expected_ids = {b["id"] for b in assistant_content} + result_ids = {b["tool_use_id"] for b in result_blocks} + assert result_ids == expected_ids - def test_tool_result_in_user_message_untouched(self): - """user 消息中的 tool_result 不受影响.""" - result = normalize_anthropic_request( - { - "messages": [ - { - "role": "assistant", - "content": [ - { - "type": "tool_use", - "id": "toolu_789", - "name": "Bash", - "input": {"command": "echo hi"}, - }, - ], - }, - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "toolu_789", - "content": "hi", - }, - ], - }, - ], - } - ) - assert result.recoverable is True - assert not result.misplaced_tool_results # 无 misplaced 块 - user_content = result.body["messages"][1]["content"] - assert len(user_content) == 1 - assert user_content[0]["type"] == "tool_result" - assert "misplaced_tool_result_relocated" not in result.adaptations +# ── strip_thinking_blocks 函数测试 ────────────────────────────── - def test_mixed_scenario_relocates_to_existing_user_message(self): - """assistant 和 user 消息中同时有 tool_result 时,assistant 中的被重定位到 user 消息.""" - result = normalize_anthropic_request( + +class TestStripThinkingBlocks: + """验证 strip_thinking_blocks 函数(从 vendors/anthropic.py 迁入).""" + + def test_strips_thinking_blocks(self): + """剥离 assistant 消息中的 thinking blocks.""" + body = { + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "Let me think...", + "signature": "sig", + }, + {"type": "text", "text": "Here is my answer."}, + ], + }, + ], + } + stripped = strip_thinking_blocks(body) + assert stripped == 1 + content = body["messages"][0]["content"] + assert len(content) == 1 + assert content[0]["type"] == "text" + + def test_strips_redacted_thinking_blocks(self): + """剥离 redacted_thinking blocks.""" + body = { + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "redacted_thinking", "data": "base64"}, + {"type": "text", "text": "response"}, + ], + }, + ], + } + stripped = strip_thinking_blocks(body) + assert stripped == 1 + assert body["messages"][0]["content"][0]["type"] == "text" + + def test_inserts_placeholder_when_content_becomes_empty(self): + """剥离后 content 为空时插入占位 text block.""" + body = { + "messages": [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "thought", + "signature": "sig", + }, + ], + }, + {"role": "user", "content": "follow up"}, + ], + } + stripped = strip_thinking_blocks(body) + assert stripped == 1 + content = body["messages"][1]["content"] + assert len(content) == 1 + assert content[0]["type"] == "text" + assert content[0]["text"] == "[thinking]" + + def test_preserves_user_messages(self): + """user 消息不受影响.""" + body = { + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "hello"}]}, + ], + } + stripped = strip_thinking_blocks(body) + assert stripped == 0 + assert body["messages"][0]["content"][0]["text"] == "hello" + + def test_preserves_top_level_thinking_param(self): + """body 顶层的 thinking 参数不受影响.""" + body = { + "thinking": {"type": "enabled", "budget_tokens": 10000}, + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "old", "signature": "sig"}, + {"type": "text", "text": "response"}, + ], + }, + ], + } + stripped = strip_thinking_blocks(body) + assert stripped == 1 + assert body["thinking"] == {"type": "enabled", "budget_tokens": 10000} + + def test_multi_turn_strips_all(self): + """多轮对话中所有 assistant thinking blocks 均被剥离.""" + body = { + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "t1", "signature": "s1"}, + {"type": "text", "text": "r1"}, + ], + }, + {"role": "user", "content": "follow up"}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "t2", "signature": "s2"}, + {"type": "text", "text": "r2"}, + ], + }, + ], + } + stripped = strip_thinking_blocks(body) + assert stripped == 2 + assert len(body["messages"][0]["content"]) == 1 + assert len(body["messages"][2]["content"]) == 1 + + def test_returns_zero_when_no_thinking(self): + """无 thinking blocks 时返回 0.""" + body = { + "messages": [ + { + "role": "assistant", + "content": [{"type": "text", "text": "response"}], + }, + ], + } + stripped = strip_thinking_blocks(body) + assert stripped == 0 + + +# ── enforce_anthropic_tool_pairing 函数测试 ────────────────────── + + +def _enforce_pairing(messages): + """在 messages 上直接执行 enforce_anthropic_tool_pairing,返回 (messages, fixes).""" + fixes = enforce_anthropic_tool_pairing(messages) + return messages, fixes + + +class TestEnforceAnthropicToolPairing: + """验证 enforce_anthropic_tool_pairing 单遍强制配对函数. + + 通过单次正向遍历完成 tool_result 剥离、重定位和孤儿合成。 + """ + + # ── 基础场景 ──────────────────────────────────────────── + + def test_no_change_when_correctly_paired(self): + """正确配对的 tool_use/tool_result 不受影响.""" + messages = [ { - "messages": [ + "role": "assistant", + "content": [ { - "role": "assistant", - "content": [ - { - "type": "tool_use", - "id": "toolu_100", - "name": "Bash", - "input": {"command": "ls"}, - }, - { - "type": "tool_result", - "tool_use_id": "toolu_100", - "content": "misplaced result", - }, - ], + "type": "tool_use", + "id": "toolu_ok", + "name": "Bash", + "input": {"command": "ls"}, }, + ], + }, + { + "role": "user", + "content": [ { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "toolu_100", - "content": "correct result", - }, - ], + "type": "tool_result", + "tool_use_id": "toolu_ok", + "content": "file.txt", }, ], - } - ) - - # Phase 2 - body, fixes = _apply_phase2(result) - assert "misplaced_tool_result_relocated" in fixes - - # assistant 中的 tool_result 被移除 - assistant_content = body["messages"][0]["content"] - assert len(assistant_content) == 1 - assert assistant_content[0]["type"] == "tool_use" - # user 消息现在包含两个 tool_result(原有的 + 重定位来的) - user_content = body["messages"][1]["content"] - assert len(user_content) == 2 - assert all(b["type"] == "tool_result" for b in user_content) - - def test_deep_conversation_with_misplaced_tool_result_at_index_105(self): - """模拟长对话(105+ 消息)中 tool_result 出现在 assistant 消息的场景.""" - # 构建一个 106 条消息的对话历史 - messages = [] - for i in range(104): - role = "user" if i % 2 == 0 else "assistant" - messages.append( - { - "role": role, - "content": f"message {i}", - } - ) + }, + ] + _, fixes = _enforce_pairing(messages) + assert not fixes + assert len(messages) == 2 + assert messages[1]["content"][0]["tool_use_id"] == "toolu_ok" - # 在消息 104(assistant)中放置 tool_use + tool_result - messages.append( + def test_strips_tool_result_from_assistant_and_relocates(self): + """从 assistant 剥离 tool_result 并重定位到紧邻 user 消息.""" + messages = [ { "role": "assistant", "content": [ { "type": "tool_use", - "id": "toolu_deep_1", + "id": "toolu_123", "name": "Bash", - "input": {"command": "find / -name '*.log'"}, + "input": {"command": "ls"}, }, { "type": "tool_result", - "tool_use_id": "toolu_deep_1", - "content": "/var/log/system.log", + "tool_use_id": "toolu_123", + "content": "output", }, ], - } - ) - # 消息 105(user) - messages.append( + }, { "role": "user", "content": "thanks", - } - ) - - result = normalize_anthropic_request({"messages": messages}) - - # Phase 2 - body, fixes = _apply_phase2(result) + }, + ] + _, fixes = _enforce_pairing(messages) assert "misplaced_tool_result_relocated" in fixes - - # 消息 104 中的 tool_result 应被移除 - assert len(body["messages"][104]["content"]) == 1 - assert body["messages"][104]["content"][0]["type"] == "tool_use" - # 消息 105(user)应包含原始文本 + 被重定位的 tool_result - user_content = body["messages"][105]["content"] + # assistant 只保留 tool_use + assert len(messages[0]["content"]) == 1 + assert messages[0]["content"][0]["type"] == "tool_use" + # user 消息包含原始文本 + 重定位的 tool_result + user_content = messages[1]["content"] assert isinstance(user_content, list) - tool_result_blocks = [b for b in user_content if b.get("type") == "tool_result"] text_blocks = [b for b in user_content if b.get("type") == "text"] + result_blocks = [b for b in user_content if b.get("type") == "tool_result"] assert len(text_blocks) == 1 assert text_blocks[0]["text"] == "thanks" - assert len(tool_result_blocks) == 1 - assert tool_result_blocks[0]["tool_use_id"] == "toolu_deep_1" - assert tool_result_blocks[0]["content"] == "/var/log/system.log" + assert len(result_blocks) == 1 + assert result_blocks[0]["tool_use_id"] == "toolu_123" - def test_rewrites_srvtoolu_id_when_relocating(self): - """重定位时同时重写 srvtoolu_ 前缀的 tool_use_id.""" - result = normalize_anthropic_request( + def test_synthesizes_missing_tool_result(self): + """缺失 tool_result 时合成 is_error=True 占位块.""" + messages = [ { - "messages": [ + "role": "assistant", + "content": [ { - "role": "assistant", - "content": [ - { - "type": "server_tool_use", - "id": "srvtoolu_bad_1", - "name": "bash", - "input": {"cmd": "pwd"}, - }, - { - "type": "tool_result", - "tool_use_id": "srvtoolu_bad_1", - "content": "/home/user", - }, - ], + "type": "tool_use", + "id": "toolu_orphan", + "name": "Bash", + "input": {"command": "pwd"}, }, ], - } - ) - - # Phase 2 - body, fixes = _apply_phase2(result) - assert "misplaced_tool_result_relocated" in fixes - assert "tool_result_tool_use_id_rewritten" in result.adaptations - - messages = body["messages"] - # assistant 中的 tool_use ID 已重写 - new_id = messages[0]["content"][0]["id"] - assert new_id.startswith("toolu_normalized_") - # 重定位到新 user 消息的 tool_result 使用相同的重写 ID - relocated = messages[1]["content"][0] - assert relocated["type"] == "tool_result" - assert relocated["tool_use_id"] == new_id - - -# ── 孤儿 tool_use 修复测试(Phase 1 + Phase 2)───────────────────── - - -class TestOrphanedToolUseRepair: - """验证 Phase 1 + Phase 2 组合能正确修复孤儿 tool_use. + }, + { + "role": "user", + "content": [ + {"type": "text", "text": "continue"}, + ], + }, + ] + _, fixes = _enforce_pairing(messages) + assert "orphaned_tool_use_repaired" in fixes + user_content = messages[1]["content"] + synthetic = [b for b in user_content if b.get("is_error") is True] + assert len(synthetic) == 1 + assert synthetic[0]["tool_use_id"] == "toolu_orphan" - Phase 1 收集上下文(如 tool_id_map),Phase 2 执行孤儿修复。 - 此类测试模拟 executor 在 Anthropic tier 时的完整行为。 - """ + # ── Zhipu 特征场景 ────────────────────────────────────── - def test_synthesizes_result_for_orphaned_tool_use(self): - """assistant 有 tool_use 且无后续 user 消息时,合成 user 消息含占位 tool_result.""" - result = normalize_anthropic_request( + def test_zhipu_3_tool_use_1_misplaced_result(self): + """zhipu 典型产物: 3 tool_use + 1 misplaced tool_result,修复后完整配对.""" + messages = [ + {"role": "user", "content": "run tools"}, { - "messages": [ + "role": "assistant", + "content": [ { - "role": "assistant", - "content": [ - { - "type": "tool_use", - "id": "toolu_123", - "name": "Bash", - "input": {"command": "ls"}, - }, - ], + "type": "tool_use", + "id": "toolu_normalized_5", + "name": "Bash", + "input": {}, + }, + { + "type": "tool_use", + "id": "toolu_normalized_6", + "name": "Read", + "input": {}, + }, + { + "type": "tool_use", + "id": "toolu_normalized_7", + "name": "Write", + "input": {}, + }, + { + "type": "tool_result", + "tool_use_id": "toolu_normalized_5", + "content": "result from zhipu", }, ], - } - ) - - # Phase 2(手动调用,模拟 Anthropic tier 行为) - body, fixes = _apply_phase2(result) + }, + {"role": "user", "content": "continue"}, + ] + _, fixes = _enforce_pairing(messages) + assert "misplaced_tool_result_relocated" in fixes assert "orphaned_tool_use_repaired" in fixes + # assistant 只保留 3 个 tool_use + assert len(messages[1]["content"]) == 3 + assert all(b["type"] == "tool_use" for b in messages[1]["content"]) + # user 消息包含所有 3 个 tool_result + user_content = messages[2]["content"] + result_ids = { + b["tool_use_id"] + for b in user_content + if isinstance(b, dict) and b.get("type") == "tool_result" + } + assert result_ids == { + "toolu_normalized_5", + "toolu_normalized_6", + "toolu_normalized_7", + } - messages = body["messages"] - assert len(messages) == 2 - assert messages[1]["role"] == "user" - synthetic = messages[1]["content"][0] - assert synthetic["type"] == "tool_result" - assert synthetic["tool_use_id"] == "toolu_123" - assert synthetic["is_error"] is True + def test_zhipu_all_3_results_in_assistant(self): + """zhipu 产物: 3 tool_use + 3 tool_result 都在 assistant 中.""" + messages = [ + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "toolu_A", "name": "Bash", "input": {}}, + {"type": "tool_use", "id": "toolu_B", "name": "Read", "input": {}}, + {"type": "tool_use", "id": "toolu_C", "name": "Write", "input": {}}, + {"type": "tool_result", "tool_use_id": "toolu_A", "content": "a"}, + {"type": "tool_result", "tool_use_id": "toolu_B", "content": "b"}, + {"type": "tool_result", "tool_use_id": "toolu_C", "content": "c"}, + ], + }, + {"role": "user", "content": "thanks"}, + ] + _, fixes = _enforce_pairing(messages) + assert "misplaced_tool_result_relocated" in fixes + assert "orphaned_tool_use_repaired" not in fixes + # assistant 只保留 tool_use + assert len(messages[0]["content"]) == 3 + # user 消息含所有 tool_result + result_ids = { + b["tool_use_id"] + for b in messages[1]["content"] + if b.get("type") == "tool_result" + } + assert result_ids == {"toolu_A", "toolu_B", "toolu_C"} - def test_synthesizes_result_appends_to_existing_user(self): - """assistant 有 tool_use 且后续 user 消息为 text 时,追加合成 tool_result.""" - result = normalize_anthropic_request( + def test_no_subsequent_user_message_inserts_synthetic(self): + """assistant 是最后一条消息时插入合成 user 消息.""" + messages = [ { - "messages": [ + "role": "assistant", + "content": [ { - "role": "assistant", - "content": [ - { - "type": "tool_use", - "id": "toolu_456", - "name": "Bash", - "input": {"command": "pwd"}, - }, - ], + "type": "tool_use", + "id": "toolu_last", + "name": "Bash", + "input": {}, }, { - "role": "user", - "content": "continue", + "type": "tool_result", + "tool_use_id": "toolu_last", + "content": "done", }, ], - } - ) + }, + ] + _, fixes = _enforce_pairing(messages) + assert "misplaced_tool_result_relocated" in fixes + assert len(messages) == 2 + assert messages[1]["role"] == "user" + assert messages[1]["content"][0]["tool_use_id"] == "toolu_last" - # Phase 2(手动调用,模拟 Anthropic tier 行为) - body, fixes = _apply_phase2(result) - assert "orphaned_tool_use_repaired" in fixes + # ── 边缘情况 ────────────────────────────────────────── - messages = body["messages"] + def test_user_content_string_converted_to_list(self): + """next user 消息 content 为字符串时正确转换为 list.""" + messages = [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_str", + "name": "Bash", + "input": {}, + }, + ], + }, + {"role": "user", "content": "hello"}, + ] + _, fixes = _enforce_pairing(messages) + assert "orphaned_tool_use_repaired" in fixes user_content = messages[1]["content"] assert isinstance(user_content, list) - # 原始文本转为 text block + 合成的 tool_result - assert len(user_content) == 2 - assert user_content[0]["type"] == "text" - assert user_content[0]["text"] == "continue" + assert user_content[0] == {"type": "text", "text": "hello"} assert user_content[1]["type"] == "tool_result" - assert user_content[1]["tool_use_id"] == "toolu_456" - assert user_content[1]["is_error"] is True + assert user_content[1]["tool_use_id"] == "toolu_str" - def test_synthesizes_only_missing_results(self): - """assistant 有 2 个 tool_use,user 仅有 1 个 tool_result 时,仅为缺失的合成.""" - result = normalize_anthropic_request( + def test_assistant_content_becomes_empty_gets_placeholder(self): + """assistant 仅含 tool_result 无 tool_use,剥离后插入占位块.""" + messages = [ { - "messages": [ + "role": "assistant", + "content": [ { - "role": "assistant", - "content": [ - { - "type": "tool_use", - "id": "toolu_A", - "name": "Bash", - "input": {"command": "ls"}, - }, - { - "type": "tool_use", - "id": "toolu_B", - "name": "Read", - "input": {"path": "/etc/hosts"}, - }, - ], + "type": "tool_result", + "tool_use_id": "toolu_stray", + "content": "x", }, + ], + }, + {"role": "user", "content": "next"}, + ] + _, fixes = _enforce_pairing(messages) + assert "misplaced_tool_result_relocated" in fixes + # assistant content 不为空(有占位) + assert len(messages[0]["content"]) == 1 + assert messages[0]["content"][0] == {"type": "text", "text": ""} + + def test_duplicate_tool_result_in_user_not_duplicated(self): + """user 已有 tool_result 时不重复添加.""" + messages = [ + { + "role": "assistant", + "content": [ { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "toolu_A", - "content": "file1.txt", - }, - ], + "type": "tool_use", + "id": "toolu_dup", + "name": "Bash", + "input": {}, + }, + { + "type": "tool_result", + "tool_use_id": "toolu_dup", + "content": "misplaced", }, ], - } - ) - - # Phase 2(手动调用,模拟 Anthropic tier 行为) - body, fixes = _apply_phase2(result) - assert "orphaned_tool_use_repaired" in fixes - - user_content = body["messages"][1]["content"] - assert len(user_content) == 2 - # 原有 tool_result 保持不变 - assert user_content[0]["tool_use_id"] == "toolu_A" - # 合成的 tool_result 仅针对 toolu_B - assert user_content[1]["type"] == "tool_result" - assert user_content[1]["tool_use_id"] == "toolu_B" - assert user_content[1]["is_error"] is True + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_dup", + "content": "correct", + }, + ], + }, + ] + _, fixes = _enforce_pairing(messages) + # misplaced 被剥离但不重复添加(user 已有) + result_blocks = [ + b for b in messages[1]["content"] if b.get("type") == "tool_result" + ] + assert len(result_blocks) == 1 + assert result_blocks[0]["content"] == "correct" - def test_no_repair_when_all_results_present(self): - """正常配对场景不应触发修复.""" - result = normalize_anthropic_request( + def test_idempotent_multiple_runs(self): + """多次调用结果一致(幂等性).""" + messages = [ { - "messages": [ + "role": "assistant", + "content": [ { - "role": "assistant", - "content": [ - { - "type": "tool_use", - "id": "toolu_ok", - "name": "Bash", - "input": {"command": "echo hi"}, - }, - ], + "type": "tool_use", + "id": "toolu_idem", + "name": "Bash", + "input": {}, }, + ], + }, + {"role": "user", "content": "go"}, + ] + _enforce_pairing(messages) + snapshot = copy.deepcopy(messages) + _, fixes2 = _enforce_pairing(messages) + # 第二次运行不应产生新的修复 + assert not fixes2 + assert messages == snapshot + + def test_complex_multi_turn_conversation(self): + """模拟真实 bug 场景:多轮 tool_use,部分有 misplaced,部分纯孤儿.""" + messages = [ + {"role": "user", "content": "start"}, + { + "role": "assistant", + "content": [ { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "toolu_ok", - "content": "hi", - }, - ], + "type": "tool_use", + "id": "toolu_normalized_1", + "name": "Bash", + "input": {}, + }, + { + "type": "tool_use", + "id": "toolu_normalized_2", + "name": "Read", + "input": {}, }, ], - } - ) - - assert result.recoverable is True - assert not result.misplaced_tool_results - # Phase 2 不应有任何修复 - body, fixes = _apply_phase2(result) - assert "orphaned_tool_use_repaired" not in fixes - - def test_repair_with_normalized_ids(self): - """跨供应商降级场景:srvtoolu_ ID 被重写后仍需修复孤儿 tool_use. - - 此场景下 tool_id_map 有条目,has_anthropic_fixes 为 True, - executor 会自动触发 Phase 2。 - """ - result = normalize_anthropic_request( + }, { - "messages": [ + "role": "user", + "content": [ { - "role": "assistant", - "content": [ - { - "type": "server_tool_use", - "id": "srvtoolu_X", - "name": "Bash", - "input": {"command": "ls"}, - }, - { - "type": "server_tool_use", - "id": "srvtoolu_Y", - "name": "Read", - "input": {"path": "/tmp"}, - }, - ], + "type": "tool_result", + "tool_use_id": "toolu_normalized_1", + "content": "ok1", }, { - "role": "user", - "content": "continue", + "type": "tool_result", + "tool_use_id": "toolu_normalized_2", + "content": "ok2", }, ], - } - ) - - # has_anthropic_fixes 为 True(因 tool_id_map 有条目) - assert result.has_anthropic_fixes is True - - # Phase 2 - body, fixes = _apply_phase2(result) + }, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "Now running more tools..."}, + { + "type": "tool_use", + "id": "toolu_normalized_5", + "name": "Bash", + "input": {}, + }, + { + "type": "tool_use", + "id": "toolu_normalized_6", + "name": "Read", + "input": {}, + }, + { + "type": "tool_use", + "id": "toolu_normalized_7", + "name": "Write", + "input": {}, + }, + { + "type": "tool_result", + "tool_use_id": "toolu_normalized_5", + "content": "zhipu inline result", + }, + ], + }, + {"role": "user", "content": "continue"}, + ] + _, fixes = _enforce_pairing(messages) + # 第一个 assistant(索引 1)已正确配对,不受影响 + assert len(messages[1]["content"]) == 2 + assert messages[2]["content"][0]["tool_use_id"] == "toolu_normalized_1" + # 第二个 assistant(索引 3): misplaced 被剥离,孤儿被合成 + assert "misplaced_tool_result_relocated" in fixes assert "orphaned_tool_use_repaired" in fixes - - messages = body["messages"] - # assistant 中 tool_use ID 已重写 - new_ids = [b["id"] for b in messages[0]["content"]] - assert len(new_ids) == 2 - assert all(id_.startswith("toolu_normalized_") for id_ in new_ids) - # user 消息应包含原始文本 + 两个合成的 tool_result - user_content = messages[1]["content"] - text_blocks = [b for b in user_content if b.get("type") == "text"] - synthetic_results = [ - b + assistant_content = messages[3]["content"] + assert all(b["type"] != "tool_result" for b in assistant_content) + assert assistant_content[0] == { + "type": "text", + "text": "Now running more tools...", + } + # user 消息(索引 4)包含所有 3 个 tool_result + user_content = messages[4]["content"] + result_ids = { + b["tool_use_id"] for b in user_content - if b.get("type") == "tool_result" and b.get("is_error") is True + if isinstance(b, dict) and b.get("type") == "tool_result" + } + assert result_ids == { + "toolu_normalized_5", + "toolu_normalized_6", + "toolu_normalized_7", + } + + def test_next_message_is_assistant_inserts_user(self): + """下一条消息不是 user 而是 assistant 时,插入合成 user 消息.""" + messages = [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_gap", + "name": "Bash", + "input": {}, + }, + ], + }, + { + "role": "assistant", + "content": [{"type": "text", "text": "follow up"}], + }, ] - assert len(text_blocks) == 1 - assert len(synthetic_results) == 2 - assert {r["tool_use_id"] for r in synthetic_results} == set(new_ids) + _, fixes = _enforce_pairing(messages) + assert "orphaned_tool_use_repaired" in fixes + assert len(messages) == 3 + assert messages[0]["content"][0]["type"] == "tool_use" + assert messages[1]["role"] == "user" + assert messages[1]["content"][0]["type"] == "tool_result" + assert messages[2]["role"] == "assistant" diff --git a/tests/test_router_executor.py b/tests/test_router_executor.py index 80f67b3..c333707 100644 --- a/tests/test_router_executor.py +++ b/tests/test_router_executor.py @@ -1550,3 +1550,202 @@ async def test_non_last_tier_500_still_failovers(self): assert resp.status_code == 200 assert good.send_message.called + + +# ── _determine_source_vendor 源 vendor 推断测试 ────────────────────── + + +class TestDetermineSourceVendor: + """验证 _RouteExecutor._determine_source_vendor 静态方法. + + Priority 1: failed_tier_name(请求内故障转移) + Priority 2: session_record.provider_state 中有已注册转换的 vendor(跨请求) + """ + + def test_returns_failed_tier_as_source(self): + """请求内故障转移:刚失败的 tier 就是源 vendor.""" + session_record = MagicMock() + session_record.provider_state = {"zhipu": {}} + + assert ( + _RouteExecutor._determine_source_vendor("copilot", "zhipu", session_record) + == "zhipu" + ) + + def test_returns_failed_tier_even_with_empty_session(self): + """请求内故障转移优先于 session_record 为空.""" + assert ( + _RouteExecutor._determine_source_vendor("copilot", "zhipu", None) == "zhipu" + ) + + def test_returns_session_vendor_with_registered_transition(self): + """跨请求:会话历史中有已注册转换的 vendor 作为源.""" + session_record = MagicMock() + session_record.provider_state = {"zhipu": {}, "copilot": {}} + + # zhipu → copilot 有注册转换 + assert ( + _RouteExecutor._determine_source_vendor("copilot", None, session_record) + == "zhipu" + ) + + def test_returns_session_vendor_for_anthropic_target(self): + """跨请求:会话历史中有 zhipu → anthropic 已注册转换.""" + session_record = MagicMock() + session_record.provider_state = {"zhipu": {}} + + assert ( + _RouteExecutor._determine_source_vendor("anthropic", None, session_record) + == "zhipu" + ) + + def test_returns_none_for_no_source(self): + """纯同 vendor 会话且无请求内故障 → 无源 vendor.""" + session_record = MagicMock() + session_record.provider_state = {"anthropic": {}} + + assert ( + _RouteExecutor._determine_source_vendor("anthropic", None, session_record) + is None + ) + + def test_returns_none_when_no_registered_transition(self): + """会话历史中有 vendor 但无对应已注册转换 → 无源 vendor. + + 例如 anthropic → zhipu 未注册,不会触发转换。 + """ + session_record = MagicMock() + session_record.provider_state = {"anthropic": {}} + + assert ( + _RouteExecutor._determine_source_vendor("zhipu", None, session_record) + is None + ) + + def test_returns_none_when_session_is_none(self): + """无会话存储且无请求内故障 → 无源 vendor.""" + assert _RouteExecutor._determine_source_vendor("copilot", None, None) is None + + def test_returns_none_when_empty_provider_state(self): + """空 provider_state 且无请求内故障 → 无源 vendor.""" + session_record = MagicMock() + session_record.provider_state = {} + + assert ( + _RouteExecutor._determine_source_vendor("copilot", None, session_record) + is None + ) + + def test_returns_none_when_failed_tier_equals_target(self): + """失败的 tier 就是目标 tier → 不算跨供应商.""" + session_record = MagicMock() + session_record.provider_state = {} + + assert ( + _RouteExecutor._determine_source_vendor("zhipu", "zhipu", session_record) + is None + ) + + +# ── _prepare_body_for_tier 转换通道应用测试 ──────────────────────── + + +class TestPrepareBodyForTierTransition: + """验证 _prepare_body_for_tier 的源→目标转换通道应用行为.""" + + @staticmethod + def _body_with_thinking(): + return { + "model": "claude-opus-4-6", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "Let me think...", + "signature": "zhipu-sig", + }, + {"type": "text", "text": "response"}, + ], + }, + ], + } + + def test_applies_zhipu_to_anthropic_transition(self): + """source_vendor=zhipu, target=anthropic → 剥离 thinking + tool pairing.""" + tier = MagicMock() + tier.name = "anthropic" + + exec_inst = _executor([]) + body = self._body_with_thinking() + result = exec_inst._prepare_body_for_tier(body, tier, source_vendor="zhipu") + + assert result is not body + assert len(result["messages"][0]["content"]) == 1 + assert result["messages"][0]["content"][0]["type"] == "text" + # 原始 body 未被修改 + assert len(body["messages"][0]["content"]) == 2 + + def test_applies_zhipu_to_copilot_transition(self): + """source_vendor=zhipu, target=copilot → 剥离 thinking + cache_control.""" + tier = MagicMock() + tier.name = "copilot" + + exec_inst = _executor([]) + body = self._body_with_thinking() + result = exec_inst._prepare_body_for_tier(body, tier, source_vendor="zhipu") + + assert result is not body + assert len(result["messages"][0]["content"]) == 1 + assert result["messages"][0]["content"][0]["type"] == "text" + + def test_applies_copilot_to_zhipu_transition(self): + """source_vendor=copilot, target=zhipu → 剥离 thinking + cache_control + 移除 thinking 参数.""" + body = { + "messages": self._body_with_thinking()["messages"], + "thinking": {"type": "enabled", "budget_tokens": 10000}, + } + tier = MagicMock() + tier.name = "zhipu" + + exec_inst = _executor([]) + result = exec_inst._prepare_body_for_tier(body, tier, source_vendor="copilot") + + assert result is not body + assert len(result["messages"][0]["content"]) == 1 + assert "thinking" not in result + + def test_returns_body_when_no_source_vendor(self): + """source_vendor=None → 原样返回请求体.""" + tier = MagicMock() + tier.name = "anthropic" + + exec_inst = _executor([]) + body = self._body_with_thinking() + result = exec_inst._prepare_body_for_tier(body, tier, source_vendor=None) + + assert result is body + assert len(result["messages"][0]["content"]) == 2 + + def test_returns_body_for_unregistered_transition(self): + """未注册的转换对(如 anthropic → zhipu)→ 原样返回.""" + tier = MagicMock() + tier.name = "zhipu" + + exec_inst = _executor([]) + body = self._body_with_thinking() + result = exec_inst._prepare_body_for_tier(body, tier, source_vendor="anthropic") + + assert result is body + + def test_returns_body_for_unknown_tier(self): + """未知 tier(无注册转换)→ 原样返回.""" + tier = MagicMock() + tier.name = "antigravity" + + exec_inst = _executor([]) + body = self._body_with_thinking() + result = exec_inst._prepare_body_for_tier(body, tier, source_vendor="zhipu") + + assert result is body diff --git a/tests/test_vendor_channels.py b/tests/test_vendor_channels.py new file mode 100644 index 0000000..7de4eae --- /dev/null +++ b/tests/test_vendor_channels.py @@ -0,0 +1,761 @@ +"""供应商跨供应商转换通道单元测试. + +覆盖 :mod:`coding.proxy.convert.vendor_channels` 的转换通道函数和辅助函数: +- zhipu → anthropic 转换 (prepare_zhipu_to_anthropic) +- zhipu → copilot 转换 (prepare_zhipu_to_copilot) +- copilot → zhipu 转换 (prepare_copilot_to_zhipu) +- 共享辅助函数 (strip_thinking_blocks, _strip_cache_control) +- 转换注册表 (VENDOR_TRANSITIONS, get_transition_channel) +""" + +from __future__ import annotations + +import copy + +from coding.proxy.convert.vendor_channels import ( + VENDOR_TRANSITIONS, + _strip_cache_control, + get_transition_channel, + prepare_copilot_to_zhipu, + prepare_zhipu_to_anthropic, + prepare_zhipu_to_copilot, + strip_thinking_blocks, +) + +# ── 辅助函数测试 ────────────────────────────────────────────── + + +class TestStripThinkingBlocks: + """strip_thinking_blocks 单元测试.""" + + def test_strips_thinking_blocks(self): + body = { + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "thought", "signature": "sig"}, + {"type": "text", "text": "response"}, + ], + }, + ] + } + stripped = strip_thinking_blocks(body) + assert stripped == 1 + assert body["messages"][0]["content"] == [ + {"type": "text", "text": "response"}, + ] + + def test_strips_redacted_thinking_blocks(self): + body = { + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "redacted_thinking", "data": "redacted"}, + ], + }, + ] + } + stripped = strip_thinking_blocks(body) + assert stripped == 1 + # content 为空时插入占位 text block + assert body["messages"][0]["content"] == [ + {"type": "text", "text": "[thinking]"}, + ] + + def test_inserts_placeholder_when_all_thinking(self): + body = { + "messages": [ + { + "role": "user", + "content": "hi", + }, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "t1"}, + {"type": "redacted_thinking", "data": "r1"}, + ], + }, + ] + } + stripped = strip_thinking_blocks(body) + assert stripped == 2 + assert body["messages"][1]["content"] == [ + {"type": "text", "text": "[thinking]"}, + ] + + def test_no_change_when_no_thinking(self): + body = { + "messages": [ + { + "role": "assistant", + "content": [{"type": "text", "text": "hello"}], + }, + ] + } + stripped = strip_thinking_blocks(body) + assert stripped == 0 + assert body["messages"][0]["content"] == [{"type": "text", "text": "hello"}] + + def test_skips_non_assistant_messages(self): + body = { + "messages": [ + {"role": "user", "content": [{"type": "thinking", "thinking": "t"}]}, + ] + } + stripped = strip_thinking_blocks(body) + assert stripped == 0 + + def test_handles_string_content(self): + body = { + "messages": [ + {"role": "assistant", "content": "plain text"}, + ] + } + stripped = strip_thinking_blocks(body) + assert stripped == 0 + + +class TestStripCacheControl: + """_strip_cache_control 单元测试.""" + + def test_removes_cache_control_from_system(self): + body = { + "system": [ + { + "type": "text", + "text": "prompt", + "cache_control": {"type": "ephemeral"}, + }, + ], + "messages": [], + } + removed = _strip_cache_control(body) + assert removed == 1 + assert "cache_control" not in body["system"][0] + + def test_removes_cache_control_from_messages(self): + body = { + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "hi", + "cache_control": {"type": "ephemeral"}, + }, + ], + }, + ], + } + removed = _strip_cache_control(body) + assert removed == 1 + assert "cache_control" not in body["messages"][0]["content"][0] + + def test_removes_cache_control_from_tools(self): + body = { + "messages": [], + "tools": [ + { + "name": "bash", + "input_schema": {}, + "cache_control": {"type": "ephemeral"}, + }, + ], + } + removed = _strip_cache_control(body) + assert removed == 1 + assert "cache_control" not in body["tools"][0] + + def test_removes_from_all_locations(self): + body = { + "system": [ + {"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}, + ], + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "msg", + "cache_control": {"type": "ephemeral"}, + }, + ], + }, + ], + "tools": [ + { + "name": "bash", + "input_schema": {}, + "cache_control": {"type": "ephemeral"}, + }, + ], + } + removed = _strip_cache_control(body) + assert removed == 3 + + def test_no_change_when_no_cache_control(self): + body = { + "system": [{"type": "text", "text": "sys"}], + "messages": [{"role": "user", "content": "hi"}], + "tools": [{"name": "bash", "input_schema": {}}], + } + removed = _strip_cache_control(body) + assert removed == 0 + + +# ── copilot → zhipu 转换通道测试 ──────────────────────────────── + + +class TestCopilotToZhipuChannel: + """prepare_copilot_to_zhipu 转换通道单元测试.""" + + def test_strips_thinking_blocks(self): + body = { + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "thought", "signature": "sig"}, + {"type": "text", "text": "response"}, + ], + }, + ], + } + prepared, adaptations = prepare_copilot_to_zhipu(body) + assert any("thinking_blocks" in a for a in adaptations) + assert prepared["messages"][0]["content"] == [ + {"type": "text", "text": "response"}, + ] + # 原始 body 未被修改 + assert body["messages"][0]["content"][0]["type"] == "thinking" + + def test_removes_cache_control(self): + body = { + "system": [ + {"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}, + ], + "messages": [], + } + prepared, adaptations = prepare_copilot_to_zhipu(body) + assert any("cache_control" in a for a in adaptations) + assert "cache_control" not in prepared["system"][0] + + def test_removes_thinking_params(self): + body = { + "messages": [], + "thinking": {"type": "enabled", "budget_tokens": 10000}, + "extended_thinking": {"type": "enabled"}, + } + prepared, adaptations = prepare_copilot_to_zhipu(body) + assert "thinking" not in prepared + assert "extended_thinking" not in prepared + assert "removed_thinking_param" in adaptations + assert "removed_extended_thinking_param" in adaptations + + def test_enforces_tool_pairing(self): + body = { + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_1", + "name": "bash", + "input": {}, + }, + ], + }, + { + "role": "user", + "content": "next turn", + }, + ], + } + prepared, adaptations = prepare_copilot_to_zhipu(body) + user_content = prepared["messages"][1]["content"] + tool_results = [ + b + for b in user_content + if isinstance(b, dict) and b.get("type") == "tool_result" + ] + assert len(tool_results) == 1 + assert tool_results[0]["tool_use_id"] == "toolu_1" + + def test_combined_transformations(self): + body = { + "system": [ + {"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}, + ], + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "thought", "signature": "sig"}, + { + "type": "tool_use", + "id": "toolu_1", + "name": "bash", + "input": {}, + }, + ], + }, + { + "role": "user", + "content": "ok", + }, + ], + "thinking": {"type": "enabled", "budget_tokens": 10000}, + } + prepared, adaptations = prepare_copilot_to_zhipu(body) + assert all( + b.get("type") not in ("thinking", "redacted_thinking") + for b in prepared["messages"][0]["content"] + ) + assert "cache_control" not in prepared["system"][0] + assert "thinking" not in prepared + user_content = prepared["messages"][1]["content"] + tool_results = [ + b + for b in user_content + if isinstance(b, dict) and b.get("type") == "tool_result" + ] + assert len(tool_results) == 1 + + def test_preserves_original_body(self): + body = { + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "t"}, + {"type": "text", "text": "hi"}, + ], + }, + ], + "thinking": {"type": "enabled"}, + } + original = copy.deepcopy(body) + prepare_copilot_to_zhipu(body) + assert body == original + + def test_noop_when_clean(self): + body = { + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [{"type": "text", "text": "hi"}]}, + ], + } + prepared, adaptations = prepare_copilot_to_zhipu(body) + assert adaptations == [] + assert prepared == body + + def test_idempotency(self): + body = { + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "t"}, + { + "type": "tool_use", + "id": "toolu_1", + "name": "bash", + "input": {}, + }, + ], + }, + { + "role": "user", + "content": "ok", + }, + ], + "thinking": {"type": "enabled"}, + } + prepared1, adaptations1 = prepare_copilot_to_zhipu(body) + prepared2, adaptations2 = prepare_copilot_to_zhipu(prepared1) + assert prepared2 == prepared1 + assert adaptations2 == [] + + +# ── zhipu → anthropic 转换通道测试 ──────────────────────────────── + + +class TestZhipuToAnthropicChannel: + """prepare_zhipu_to_anthropic 转换通道单元测试.""" + + def test_enforces_tool_pairing(self): + body = { + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_1", + "name": "bash", + "input": {}, + }, + ], + }, + { + "role": "user", + "content": "next turn", + }, + ], + } + prepared, adaptations = prepare_zhipu_to_anthropic(body) + user_content = prepared["messages"][1]["content"] + tool_results = [ + b + for b in user_content + if isinstance(b, dict) and b.get("type") == "tool_result" + ] + assert len(tool_results) == 1 + assert tool_results[0]["tool_use_id"] == "toolu_1" + + def test_strips_thinking_blocks(self): + body = { + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "thought", "signature": "sig"}, + {"type": "text", "text": "response"}, + ], + }, + ], + } + prepared, adaptations = prepare_zhipu_to_anthropic(body) + assert any("thinking_blocks" in a for a in adaptations) + assert prepared["messages"][0]["content"] == [ + {"type": "text", "text": "response"} + ] + + def test_combined_tool_pairing_and_thinking_strip(self): + body = { + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "t", "signature": "s"}, + { + "type": "tool_use", + "id": "toolu_1", + "name": "bash", + "input": {}, + }, + ], + }, + { + "role": "user", + "content": "ok", + }, + ], + } + prepared, adaptations = prepare_zhipu_to_anthropic(body) + assert all( + b.get("type") not in ("thinking", "redacted_thinking") + for b in prepared["messages"][0]["content"] + ) + user_content = prepared["messages"][1]["content"] + tool_results = [ + b + for b in user_content + if isinstance(b, dict) and b.get("type") == "tool_result" + ] + assert len(tool_results) == 1 + + def test_preserves_original_body(self): + body = { + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "t"}, + {"type": "text", "text": "hi"}, + ], + }, + ], + } + original = copy.deepcopy(body) + prepare_zhipu_to_anthropic(body) + assert body == original + + def test_noop_when_clean(self): + body = { + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [{"type": "text", "text": "hi"}]}, + ], + } + prepared, adaptations = prepare_zhipu_to_anthropic(body) + assert adaptations == [] + assert prepared == body + + def test_idempotency(self): + body = { + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "t"}, + { + "type": "tool_use", + "id": "toolu_1", + "name": "bash", + "input": {}, + }, + ], + }, + { + "role": "user", + "content": "ok", + }, + ], + } + prepared1, _ = prepare_zhipu_to_anthropic(body) + prepared2, adaptations2 = prepare_zhipu_to_anthropic(prepared1) + assert prepared2 == prepared1 + assert adaptations2 == [] + + def test_preserves_thinking_param(self): + """zhipu → anthropic 通道不移除顶层 thinking 参数(Anthropic API 支持).""" + body = { + "messages": [], + "thinking": {"type": "enabled", "budget_tokens": 5000}, + } + prepared, _ = prepare_zhipu_to_anthropic(body) + assert "thinking" in prepared + + +# ── zhipu → copilot 转换通道测试 ────────────────────────────── + + +class TestZhipuToCopilotChannel: + """prepare_zhipu_to_copilot 转换通道单元测试.""" + + def test_strips_thinking_blocks(self): + body = { + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "thought", "signature": "sig"}, + {"type": "text", "text": "response"}, + ], + }, + ], + } + prepared, adaptations = prepare_zhipu_to_copilot(body) + assert any("thinking_blocks" in a for a in adaptations) + assert prepared["messages"][0]["content"] == [ + {"type": "text", "text": "response"}, + ] + + def test_removes_cache_control(self): + body = { + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "hi", + "cache_control": {"type": "ephemeral"}, + }, + ], + }, + ], + } + prepared, adaptations = prepare_zhipu_to_copilot(body) + assert any("cache_control" in a for a in adaptations) + assert "cache_control" not in prepared["messages"][0]["content"][0] + + def test_preserves_thinking_param(self): + """zhipu → copilot 通道不移除顶层 thinking 参数(由 converter 自行映射).""" + body = { + "messages": [], + "thinking": {"type": "enabled", "budget_tokens": 10000}, + } + prepared, adaptations = prepare_zhipu_to_copilot(body) + assert "thinking" in prepared + assert "removed_thinking_param" not in adaptations + + def test_enforces_tool_pairing(self): + body = { + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_1", + "name": "read", + "input": {}, + }, + ], + }, + { + "role": "user", + "content": "next", + }, + ], + } + prepared, adaptations = prepare_zhipu_to_copilot(body) + user_content = prepared["messages"][1]["content"] + tool_results = [ + b + for b in user_content + if isinstance(b, dict) and b.get("type") == "tool_result" + ] + assert len(tool_results) == 1 + + def test_preserves_original_body(self): + body = { + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "t"}, + {"type": "text", "text": "hi"}, + ], + }, + ], + } + original = copy.deepcopy(body) + prepare_zhipu_to_copilot(body) + assert body == original + + def test_noop_when_clean(self): + body = { + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [{"type": "text", "text": "hi"}]}, + ], + } + prepared, adaptations = prepare_zhipu_to_copilot(body) + assert adaptations == [] + assert prepared == body + + def test_idempotency(self): + body = { + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "t"}, + { + "type": "tool_use", + "id": "toolu_1", + "name": "bash", + "input": {}, + }, + ], + }, + { + "role": "user", + "content": "ok", + }, + ], + } + prepared1, _ = prepare_zhipu_to_copilot(body) + prepared2, adaptations2 = prepare_zhipu_to_copilot(prepared1) + assert prepared2 == prepared1 + assert adaptations2 == [] + + def test_strips_redacted_thinking(self): + body = { + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "redacted_thinking", "data": "redacted"}, + {"type": "text", "text": "response"}, + ], + }, + ], + } + prepared, adaptations = prepare_zhipu_to_copilot(body) + assert any("thinking_blocks" in a for a in adaptations) + assert prepared["messages"][0]["content"] == [ + {"type": "text", "text": "response"}, + ] + + +# ── 转换注册表测试 ──────────────────────────────────────────── + + +class TestTransitionRegistry: + """VENDOR_TRANSITIONS / get_transition_channel 单元测试.""" + + def test_all_transitions_registered(self): + assert ("zhipu", "anthropic") in VENDOR_TRANSITIONS + assert ("zhipu", "copilot") in VENDOR_TRANSITIONS + assert ("copilot", "zhipu") in VENDOR_TRANSITIONS + assert len(VENDOR_TRANSITIONS) == 3 + + def test_get_transition_channel_returns_function(self): + assert ( + get_transition_channel("zhipu", "anthropic") is prepare_zhipu_to_anthropic + ) + assert get_transition_channel("zhipu", "copilot") is prepare_zhipu_to_copilot + assert get_transition_channel("copilot", "zhipu") is prepare_copilot_to_zhipu + + def test_get_transition_channel_returns_none_for_unregistered(self): + assert get_transition_channel("anthropic", "zhipu") is None + assert get_transition_channel("copilot", "anthropic") is None + assert get_transition_channel("unknown", "target") is None + assert get_transition_channel("antigravity", "copilot") is None + + def test_transition_functions_share_signature(self): + body = {"messages": []} + for key, fn in VENDOR_TRANSITIONS.items(): + result = fn(body) + assert isinstance(result, tuple) and len(result) == 2 + assert isinstance(result[0], dict) + assert isinstance(result[1], list) + + +# ── 转换通道差异测试 ────────────────────────────────────────── + + +class TestTransitionDifferences: + """验证不同转换通道的关键行为差异.""" + + def test_copilot_to_zhipu_removes_thinking_param_zhipu_to_copilot_preserves(self): + body = { + "messages": [], + "thinking": {"type": "enabled", "budget_tokens": 5000}, + } + copilot_to_zhipu_result, copilot_to_zhipu_adapt = prepare_copilot_to_zhipu(body) + zhipu_to_copilot_result, zhipu_to_copilot_adapt = prepare_zhipu_to_copilot(body) + + assert "thinking" not in copilot_to_zhipu_result + assert "removed_thinking_param" in copilot_to_zhipu_adapt + + assert "thinking" in zhipu_to_copilot_result + assert "removed_thinking_param" not in zhipu_to_copilot_adapt + + def test_all_transitions_strip_thinking_blocks(self): + body = { + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "t"}, + {"type": "text", "text": "hi"}, + ], + }, + ], + } + for key, fn in VENDOR_TRANSITIONS.items(): + result, adaptations = fn(body) + assert result["messages"][0]["content"] == [ + {"type": "text", "text": "hi"} + ], f"Transition {key} failed to strip thinking blocks" diff --git a/tests/test_vendors.py b/tests/test_vendors.py index e9fc241..578b0b0 100644 --- a/tests/test_vendors.py +++ b/tests/test_vendors.py @@ -46,8 +46,8 @@ async def test_anthropic_prepare_request_filters_headers(): @pytest.mark.asyncio -async def test_anthropic_prepare_request_strips_thinking_blocks(): - """Anthropic vendor 应剥离 assistant messages 中的 thinking blocks.""" +async def test_anthropic_prepare_request_preserves_thinking_blocks(): + """Anthropic vendor 不再剥离 thinking blocks(已提升至 executor 层条件执行).""" vendor = AnthropicVendor(AnthropicConfig(), FailoverConfig()) body = { "model": "claude-opus-4-6", @@ -58,7 +58,7 @@ async def test_anthropic_prepare_request_strips_thinking_blocks(): { "type": "thinking", "thinking": "Let me think about this...", - "signature": "zhipu-issued-signature", + "signature": "anthropic-issued-signature", }, {"type": "text", "text": "Here is my answer."}, ], @@ -67,16 +67,16 @@ async def test_anthropic_prepare_request_strips_thinking_blocks(): } prepared_body, _ = await vendor._prepare_request(body, {}) - # thinking block 被剥离,text block 保留 + # thinking block 保留(剥离逻辑已迁移至 executor 层条件执行) content = prepared_body["messages"][0]["content"] - assert len(content) == 1 - assert content[0]["type"] == "text" - assert content[0]["text"] == "Here is my answer." + assert len(content) == 2 + assert content[0]["type"] == "thinking" + assert content[1]["type"] == "text" @pytest.mark.asyncio -async def test_anthropic_prepare_request_strips_redacted_thinking_blocks(): - """Anthropic vendor 应剥离 assistant messages 中的 redacted_thinking blocks.""" +async def test_anthropic_prepare_request_preserves_redacted_thinking_blocks(): + """Anthropic vendor 不再剥离 redacted_thinking blocks(已提升至 executor 层).""" vendor = AnthropicVendor(AnthropicConfig(), FailoverConfig()) body = { "model": "claude-opus-4-6", @@ -93,8 +93,9 @@ async def test_anthropic_prepare_request_strips_redacted_thinking_blocks(): prepared_body, _ = await vendor._prepare_request(body, {}) content = prepared_body["messages"][0]["content"] - assert len(content) == 1 - assert content[0]["type"] == "text" + assert len(content) == 2 + assert content[0]["type"] == "redacted_thinking" + assert content[1]["type"] == "text" @pytest.mark.asyncio @@ -119,9 +120,10 @@ async def test_anthropic_prepare_request_preserves_thinking_param(): # 顶层 thinking 参数保留 assert prepared_body["thinking"] == {"type": "enabled", "budget_tokens": 10000} - # assistant message 的 thinking block 被剥离 - assert len(prepared_body["messages"][0]["content"]) == 1 - assert prepared_body["messages"][0]["content"][0]["type"] == "text" + # assistant message 的 thinking block 也保留(剥离逻辑已迁移至 executor 层) + assert len(prepared_body["messages"][0]["content"]) == 2 + assert prepared_body["messages"][0]["content"][0]["type"] == "thinking" + assert prepared_body["messages"][0]["content"][1]["type"] == "text" @pytest.mark.asyncio @@ -184,8 +186,8 @@ async def test_anthropic_prepare_request_handles_string_content(): @pytest.mark.asyncio -async def test_anthropic_prepare_request_thinking_only_gets_placeholder(): - """assistant message 仅含 thinking blocks 时,剥离后应插入占位 text block.""" +async def test_anthropic_prepare_request_preserves_thinking_only_message(): + """assistant message 仅含 thinking blocks 时,_prepare_request 不再剥离(由 executor 处理).""" vendor = AnthropicVendor(AnthropicConfig(), FailoverConfig()) body = { "model": "claude-opus-4-6", @@ -206,59 +208,15 @@ async def test_anthropic_prepare_request_thinking_only_gets_placeholder(): } prepared_body, _ = await vendor._prepare_request(body, {}) + # thinking blocks 保留(不再由 _prepare_request 剥离) content = prepared_body["messages"][1]["content"] assert len(content) == 1 - assert content[0]["type"] == "text" - assert content[0]["text"] == "[thinking]" - - -@pytest.mark.asyncio -async def test_anthropic_prepare_request_thinking_only_with_tool_result_context(): - """多轮对话:thinking-only assistant + 后续 user tool_result 不应触发结构错误.""" - vendor = AnthropicVendor(AnthropicConfig(), FailoverConfig()) - body = { - "model": "claude-opus-4-6", - "messages": [ - {"role": "user", "content": "hello"}, - { - "role": "assistant", - "content": [ - { - "type": "thinking", - "thinking": "thought 1", - "signature": "sig-1", - }, - {"type": "redacted_thinking", "data": "base64data"}, - ], - }, - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "toolu_123", - "content": "result data", - }, - ], - }, - ], - } - prepared_body, _ = await vendor._prepare_request(body, {}) - - # assistant message 应包含占位 text block - content = prepared_body["messages"][1]["content"] - assert len(content) == 1 - assert content[0]["type"] == "text" - assert content[0]["text"] == "[thinking]" - - # user message 的 tool_result 应完整保留 - user_content = prepared_body["messages"][2]["content"] - assert user_content[0]["type"] == "tool_result" + assert content[0]["type"] == "thinking" @pytest.mark.asyncio -async def test_anthropic_prepare_request_multi_turn_strips_all_thinking(): - """多轮对话中所有 assistant thinking blocks 均应被剥离.""" +async def test_anthropic_prepare_request_preserves_multi_turn_thinking(): + """多轮对话中 assistant thinking blocks 不再被 _prepare_request 剥离.""" vendor = AnthropicVendor(AnthropicConfig(), FailoverConfig()) body = { "model": "claude-opus-4-6", @@ -288,19 +246,21 @@ async def test_anthropic_prepare_request_multi_turn_strips_all_thinking(): } prepared_body, _ = await vendor._prepare_request(body, {}) - # 第一条 assistant message: thinking 被剥离,text 保留 + # 第一条 assistant message: thinking + text 均保留 content_0 = prepared_body["messages"][0]["content"] - assert len(content_0) == 1 - assert content_0[0]["type"] == "text" + assert len(content_0) == 2 + assert content_0[0]["type"] == "thinking" + assert content_0[1]["type"] == "text" # user message 不变 assert prepared_body["messages"][1]["content"] == "follow up" - # 第三条 assistant message: thinking 被剥离,text + tool_use 保留 + # 第三条 assistant message: thinking + text + tool_use 均保留 content_2 = prepared_body["messages"][2]["content"] - assert len(content_2) == 2 - assert content_2[0]["type"] == "text" - assert content_2[1]["type"] == "tool_use" + assert len(content_2) == 3 + assert content_2[0]["type"] == "thinking" + assert content_2[1]["type"] == "text" + assert content_2[2]["type"] == "tool_use" @pytest.mark.asyncio @@ -870,8 +830,8 @@ async def test_anthropic_preserves_tool_result_in_user_message(): @pytest.mark.asyncio -async def test_anthropic_strips_both_thinking_and_misplaced_tool_result(): - """Anthropic vendor 应同时剥离 thinking 和错位的 tool_result.""" +async def test_anthropic_strips_misplaced_tool_result_preserves_thinking(): + """Anthropic vendor 仅剥离错位的 tool_result(纵深防御),保留 thinking blocks.""" vendor = AnthropicVendor(AnthropicConfig(), FailoverConfig()) body = { "model": "claude-opus-4-6", @@ -901,10 +861,11 @@ async def test_anthropic_strips_both_thinking_and_misplaced_tool_result(): } prepared_body, _ = await vendor._prepare_request(body, {}) - # thinking 和 misplaced tool_result 都被剥离 + # misplaced tool_result 被剥离(纵深防御),thinking 保留 assistant_content = prepared_body["messages"][0]["content"] - assert len(assistant_content) == 1 - assert assistant_content[0]["type"] == "tool_use" + assert len(assistant_content) == 2 + assert assistant_content[0]["type"] == "thinking" + assert assistant_content[1]["type"] == "tool_use" @pytest.mark.asyncio diff --git a/uv.lock b/uv.lock index 6ef84e8..beb44bd 100644 --- a/uv.lock +++ b/uv.lock @@ -51,6 +51,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -65,7 +74,7 @@ wheels = [ [[package]] name = "coding-proxy" -version = "0.2.3" +version = "0.2.4a5" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, @@ -80,6 +89,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -100,6 +110,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "pre-commit", specifier = ">=4.0" }, { name = "pytest", specifier = ">=9.0" }, { name = "pytest-asyncio", specifier = ">=1.3" }, { name = "pytest-cov", specifier = ">=6.0" }, @@ -199,6 +210,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "fastapi" version = "0.135.2" @@ -215,6 +235,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, ] +[[package]] +name = "filelock" +version = "3.28.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/17/6e8890271880903e3538660a21d63a6c1fea969ac71d0d6b608b78727fa9/filelock-3.28.0.tar.gz", hash = "sha256:4ed1010aae813c4ee8d9c660e4792475ee60c4a0ba76073ceaf862bd317e3ca6", size = 56474, upload-time = "2026-04-14T22:54:33.625Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/21/2f728888c45033d34a417bfcd248ea2564c9e08ab1bfd301377cf05d5586/filelock-3.28.0-py3-none-any.whl", hash = "sha256:de9af6712788e7171df1b28b15eba2446c69721433fa427a9bee07b17820a9db", size = 39189, upload-time = "2026-04-14T22:54:32.037Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -252,6 +281,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "identify" +version = "2.6.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -291,6 +329,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -300,6 +347,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -309,6 +365,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -447,6 +519,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "python-discovery" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -601,3 +686,18 @@ sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0 wheels = [ { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] + +[[package]] +name = "virtualenv" +version = "21.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/3a7e644e19cb26133488caff231be390579860bbbb3da35913c49a1d0a46/virtualenv-21.2.4.tar.gz", hash = "sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada", size = 5850742, upload-time = "2026-04-14T22:15:31.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/8d/edd0bd910ff803c308ee9a6b7778621af0d10252219ad9f19ef4d4982a61/virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac", size = 5831232, upload-time = "2026-04-14T22:15:29.342Z" }, +]