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" },
+]