diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..97b1cd1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,137 @@ +# ============================================================================= +# coding-proxy: Continuous Integration Pipeline +# ============================================================================= +# Trigger: +# - Pull Request (所有 PR) +# - Push to master / feature/* 分支 +# - Push of version tags (v*) — 确保 release 前通过质量检查 +# +# Architecture: Parallel Quality Gates +# Job 1 (lint): Ruff 静态分析 +# Job 2 (format): Ruff format 格式校验 +# Job 3 (test): pytest 单元/集成测试 +# → 三者完全并行,最大化效率 +# +# References: +# [1] https://docs.astral.sh/ruff/integrations/#github-actions +# [2] https://docs.pytest.org/en/stable/how-to/continuous_integration.html +# ============================================================================= + +name: CI + +on: + pull_request: + branches: [master, "feature/*"] + push: + branches: [master, "feature/*"] + tags: ["v*"] + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # =========================================================================== + # Job 1: LINT — Ruff static analysis + # =========================================================================== + lint: + name: Lint (Ruff) + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Run Ruff linter + run: uv run ruff check . + + # =========================================================================== + # Job 2: FORMAT — Ruff format verification + # =========================================================================== + format: + name: Format Check (Ruff) + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Check formatting + run: uv run ruff format --check . + + # =========================================================================== + # Job 3: TEST — Unit & integration tests + # =========================================================================== + test: + name: Test (pytest) + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + matrix: + python-version: ["3.12", "3.13"] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Set up uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Run tests with coverage data collection + run: uv run pytest --cov=src/coding --cov-report=term-missing --cov-report=xml:coverage.xml --junitxml=junit.xml + + - name: Upload coverage artifact + if: matrix.python-version == '3.12' + uses: actions/upload-artifact@v4 + with: + name: coverage-data + path: coverage.xml + retention-days: 7 + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-py${{ matrix.python-version }} + path: junit.xml + retention-days: 7 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..7d8a75b --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,89 @@ +# ============================================================================= +# coding-proxy: Test Coverage Reporting +# ============================================================================= +# Trigger: +# - Pull Request (生成覆盖率报告) +# - Push to master (主分支更新时记录基线) +# +# Purpose: +# - 生成 HTML 覆盖率报告供下载查看 +# - 在 Actions Summary 中展示覆盖率摘要 +# - 设置最低覆盖率门槛(初始为 0,后续可逐步提高) +# +# References: +# [1] https://pytest-cov.readthedocs.io/ +# [2] https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/adding-a-job-summary +# ============================================================================= + +name: Coverage + +on: + pull_request: + branches: [master, "feature/*"] + push: + branches: [master] + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + coverage: + name: Coverage Report + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Generate coverage report + run: | + uv run pytest \ + --cov=src/coding \ + --cov-report=term-missing \ + --cov-report=html:htmlcov \ + --cov-report=xml:coverage.xml \ + --cov-fail-under=0 \ + --junitxml=junit.xml + + - name: Upload HTML coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-html-report + path: htmlcov/ + retention-days: 14 + + - name: Upload coverage XML report + uses: actions/upload-artifact@v4 + with: + name: coverage-xml + path: coverage.xml + retention-days: 14 + + - name: Display coverage summary in job summary + run: | + echo "## 📊 测试覆盖率摘要" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + uv run pytest --cov=src/coding --cov-report=term-missing --no-header -q || true diff --git a/.gitignore b/.gitignore index 207fd52..3446867 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,12 @@ build/ *.whl .uv/ +# Coverage reports (pytest-cov) +.coverage +htmlcov/ +coverage.xml +junit.xml + # Custom .idea/ .temp/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/CHANGELOG.md b/CHANGELOG.md index e89d26f..2eef511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,27 +6,25 @@ ## [v0.1.0](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.1.0) — 2026-04-05 -### ✨ 核心亮点 - > [!NOTE] > > **🎉 coding-proxy MVP 惊艳登场!** > > 仅需配置一行环境变量,立刻为你的 Claude Code 接入“永不宕机”的多源智能引擎。主供应商打盹?毫秒级自动无缝切换备用通道,全天候护航你的编码心流,向打断大声说不! -| 🌟 杀手级特性 | 💡 体验升级 | -| :-------------------- | :---------------------------------------------------------------------------------------------------------------------- | -| **N-tier 高可用接力** | 随心编排供应商优先级;
默认内置 `Claude → GitHub Copilot → Antigravity → GLM` 丝滑降级链路,天塌下来有 Proxy 顶着; | -| **自愈式智能熔断** | 微秒级状态机严防“雪崩效应”,搭配指数退避重试,一旦主干回血,静默自愈切回; | -| **账单刺客克星** | 极客专属的 SQLite 本地账本 + CLI 多维看板(按维度:日/模型/供应商),把 Token 消耗拆解到每一比特,精打细算不背锅; | -| **OAuth2 丝滑接入** | 原生集成 GitHub Device Flow 与 Google OAuth。告别干枯的断更密钥,令牌到期自动接力轮转,专注写码不分心; | -| **多协议“同传专家”** | Anthropic 与 OpenAI / Gemini 协议底层双向无损翻译,鸡同鸭讲?在 proxy 层是不存在的; | -| **模型指名道姓** | 随需定制你的神级转发地图,`claude-opus-*` 秒变 `glm-5v-turbo`,指哪打哪,模型矩阵全由你做主; | -| **全透明“隐身衣”** | FastAPI 强劲异步驱动,开箱即用。仅需覆盖注入 `ANTHROPIC_BASE_URL`,对上层应用百分百零侵入、零违和; | -| **SSE 星际流水线** | 彻底打破协议壁垒,流式连线跨体系无损透传,体验每一颗 Token 如丝般顺滑的输出快感; | -| **双擎配额守卫** | “5小时滑动窗口 + 固化周配额”双重护城河。余额濒临红线?主动预警机制,断然拒绝突然“断奶”; | +### ✨ 核心亮点 + +- **N-tier 高可用接力**:随心编排供应商优先级;默认内置 `Claude → GitHub Copilot → Antigravity → GLM` 丝滑降级链路,天塌下来有 Proxy 顶着; +- **自愈式智能熔断**:微秒级状态机严防“雪崩效应”,搭配指数退避重试,一旦主干回血,静默自愈切回; +- **账单刺客克星**:极客专属的 SQLite 本地账本 + CLI 多维看板(按维度:日/模型/供应商),把 Token 消耗拆解到每一比特,精打细算不背锅; +- **OAuth2 丝滑接入**:原生集成 GitHub Device Flow 与 Google OAuth。告别干枯的断更密钥,令牌到期自动接力轮转,专注写码不分心; +- **多协议“同传专家”**:Anthropic 与 OpenAI / Gemini 协议底层双向无损翻译,鸡同鸭讲?在 proxy 层是不存在的; +- **模型指名道姓**:随需定制你的神级转发地图,`claude-opus-*` 秒变 `glm-5v-turbo`,指哪打哪,模型矩阵全由你做主; +- **全透明“隐身衣”**:FastAPI 强劲异步驱动,开箱即用。仅需覆盖注入 `ANTHROPIC_BASE_URL`,对上层应用百分百零侵入、零违和; +- **SSE 星际流水线**:彻底打破协议壁垒,流式连线跨体系无损透传,体验每一颗 Token 如丝般顺滑的输出快感; +- **双擎配额守卫**:“5小时滑动窗口 + 固化周配额”双重护城河。余额濒临红线?主动预警机制,断然拒绝突然“断奶”; -### 🔧 更多细节 +### 🔧 更多特性 - 💰 **细粒度计价引擎**:内置主流大模型实时公开保价,调用开销追踪精确至每分每厘,资本家也薅不到你一根毛; - 🔄 **强迫症级重试流**:深度可配的指数退避策略(不仅是次数,还有倍率),将偶发性异常全部静默拦截在黑盒之中; @@ -34,5 +32,3 @@ - ⏱️ **RateLimit 算命仪**:智能嗅探并解析 Rate Limit Headers,精准算准每一秒 CD 冷却,弹无虚发; - 🛡️ **神秘 421 疫苗**:专治 GitHub Copilot 偶尔抽风的著名 `421 Misdirection` 顽疾,内置“即刻重试”自愈特效药; - 🧹 **洁癖级优雅退出**:挥一挥衣袖不带走一片云彩,挂起、清理、落数据,进程结束得干干净净,像风一样自由; - - diff --git a/config.example.yaml b/config.example.yaml index 3090f91..0e7095a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -83,6 +83,16 @@ vendors: api_key: "${ZHIPU_API_KEY}" timeout_ms: 3000000 # 不配置 circuit_breaker → 自动成为终端层,不触发向下故障转移 + circuit_breaker: + failure_threshold: 3 + recovery_timeout_seconds: 300 + success_threshold: 2 + quota_guard: + enabled: true # 启用后按 Premium Requests 配额管理 + token_budget: 1000000000 # 一天 token 预算(根据订阅计划调整) + window_hours: 24.0 + threshold_percent: 95.0 + probe_interval_seconds: 300 # === 降级链路优先级(可选) === # diff --git a/pyproject.toml b/pyproject.toml index 18432f0..3dc47dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,8 +28,8 @@ dependencies = [ [project.urls] "Source Code" = "https://github.com/ThreeFish-AI/coding-proxy" -docs = "https://github.com/ThreeFish-AI/coding-proxy/blob/master/docs/user-guide.md" -bug-tracker = "https://github.com/ThreeFish-AI/coding-proxy/issues" +"User Guide" = "https://github.com/ThreeFish-AI/coding-proxy/blob/master/docs/user-guide.md" +"Bug Tracker" = "https://github.com/ThreeFish-AI/coding-proxy/issues" [project.scripts] coding-proxy = "coding.proxy.cli:app" @@ -45,4 +45,45 @@ packages = ["src/coding"] dev = [ "pytest>=9.0", "pytest-asyncio>=1.3", + "pytest-cov>=6.0", + "ruff>=0.9.0", +] + +# ── Ruff: Linter & Formatter(替代 flake8 + isort + Black + pyupgrade 等 6+ 工具) ── +# 文档: https://docs.astral.sh/ruff/ +[tool.ruff] +target-version = "py312" +line-length = 88 + +[tool.ruff.lint] +select = [ + "F", # Pyflakes(未定义变量、导入错误等 — 捕获真实 Bug) + "E", # pycodestyle 错误 + "W", # pycodestyle 警告 + "I", # isort(导入排序) + "UP", # pyupgrade(Python 语法现代化,安全自动修复) +] +ignore = [ + "E501", # 行过长(由 formatter 处理) + "E402", # 模块级非顶层导入(__init__.py 文档化分组等合理场景) +] + +[tool.ruff.lint.isort] +known-first-party = ["coding"] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] # __init__.py 允许未使用的导入(re-export) + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +docstring-code-format = true + +# ── Pytest 配置 ── +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +addopts = "-v --tb=short" +filterwarnings = [ + "ignore::DeprecationWarning", ] diff --git a/src/coding/proxy/__init__.py b/src/coding/proxy/__init__.py index d9d007c..32e6976 100644 --- a/src/coding/proxy/__init__.py +++ b/src/coding/proxy/__init__.py @@ -12,6 +12,7 @@ def __get_version() -> str: """ try: from importlib.metadata import version as _meta_version + return _meta_version("coding-proxy") except Exception: pass diff --git a/src/coding/proxy/auth/__init__.py b/src/coding/proxy/auth/__init__.py index 8537e50..458e36f 100644 --- a/src/coding/proxy/auth/__init__.py +++ b/src/coding/proxy/auth/__init__.py @@ -7,7 +7,11 @@ from .store import ProviderTokens, TokenStoreManager __all__ = [ - "OAuthProvider", "GitHubDeviceFlowProvider", "GoogleOAuthProvider", - "RuntimeReauthCoordinator", "ReauthState", - "ProviderTokens", "TokenStoreManager", + "OAuthProvider", + "GitHubDeviceFlowProvider", + "GoogleOAuthProvider", + "RuntimeReauthCoordinator", + "ReauthState", + "ProviderTokens", + "TokenStoreManager", ] diff --git a/src/coding/proxy/auth/providers/github.py b/src/coding/proxy/auth/providers/github.py index a80902a..e81fea7 100644 --- a/src/coding/proxy/auth/providers/github.py +++ b/src/coding/proxy/auth/providers/github.py @@ -4,10 +4,8 @@ import asyncio import logging -import time -from typing import Any - import webbrowser +from typing import Any import httpx diff --git a/src/coding/proxy/auth/providers/google.py b/src/coding/proxy/auth/providers/google.py index 3f9987c..bf63813 100644 --- a/src/coding/proxy/auth/providers/google.py +++ b/src/coding/proxy/auth/providers/google.py @@ -6,7 +6,7 @@ import logging import secrets import time -from http.server import HTTPServer, BaseHTTPRequestHandler +from http.server import BaseHTTPRequestHandler, HTTPServer from typing import Any from urllib.parse import parse_qs, urlencode, urlparse @@ -21,8 +21,7 @@ # SOT(权威源): coding.proxy.config.schema.AuthConfig # 此处默认值仅作 fallback,生产环境应通过 config.yaml 的 auth 段覆盖 _DEFAULT_CLIENT_ID = ( - "1071006060591-tmhssin2h21lcre235vtolojh4g403ep" - ".apps.googleusercontent.com" + "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" ) _DEFAULT_CLIENT_SECRET = "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" @@ -44,7 +43,9 @@ class _CallbackHandler(BaseHTTPRequestHandler): 使用实例级 result dict 避免类属性在并发场景下的交叉污染. """ - def __init__(self, *args: Any, result: dict[str, str | None], **kwargs: Any) -> None: + def __init__( + self, *args: Any, result: dict[str, str | None], **kwargs: Any + ) -> None: self._result = result super().__init__(*args, **kwargs) @@ -103,7 +104,11 @@ def has_required_scopes(scope: str) -> bool: async def login(self) -> ProviderTokens: """执行 Google OAuth2 Code Flow,返回 Token.""" state = secrets.token_urlsafe(32) - result: dict[str, str | None] = {"auth_code": None, "state": None, "error": None} + result: dict[str, str | None] = { + "auth_code": None, + "state": None, + "error": None, + } def _make_handler(*args: Any, **kwargs: Any) -> _CallbackHandler: return _CallbackHandler(*args, result=result, **kwargs) @@ -113,23 +118,26 @@ def _make_handler(*args: Any, **kwargs: Any) -> _CallbackHandler: redirect_port = server.server_address[1] redirect_uri = f"http://127.0.0.1:{redirect_port}/callback" - params = urlencode({ - "client_id": self._client_id, - "redirect_uri": redirect_uri, - "response_type": "code", - "scope": " ".join(_SCOPES), - "state": state, - "access_type": "offline", - "prompt": "consent", - }) + params = urlencode( + { + "client_id": self._client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": " ".join(_SCOPES), + "state": state, + "access_type": "offline", + "prompt": "consent", + } + ) auth_url = f"{_AUTH_URL}?{params}" logger.info("请在浏览器中完成 Google 授权") - print(f"\n 🔗 请在浏览器中访问以下链接完成授权:\n") + print("\n 🔗 请在浏览器中访问以下链接完成授权:\n") print(f" {auth_url}\n") # 打开浏览器 import webbrowser + webbrowser.open(auth_url) # 等待回调 @@ -153,9 +161,7 @@ def _make_handler(*args: Any, **kwargs: Any) -> _CallbackHandler: # 交换 code → token return await self._exchange_code(result["auth_code"], redirect_uri) - async def _exchange_code( - self, code: str, redirect_uri: str - ) -> ProviderTokens: + async def _exchange_code(self, code: str, redirect_uri: str) -> ProviderTokens: """将 authorization code 交换为 access_token + refresh_token.""" resp = await self._http.post( _TOKEN_URL, diff --git a/src/coding/proxy/auth/runtime.py b/src/coding/proxy/auth/runtime.py index 90ea4de..2fc2f78 100644 --- a/src/coding/proxy/auth/runtime.py +++ b/src/coding/proxy/auth/runtime.py @@ -6,7 +6,7 @@ import enum import logging import time -from typing import Callable +from collections.abc import Callable from .providers.base import OAuthProvider from .store import TokenStoreManager diff --git a/src/coding/proxy/auth/store.py b/src/coding/proxy/auth/store.py index bc1a4ff..a438751 100644 --- a/src/coding/proxy/auth/store.py +++ b/src/coding/proxy/auth/store.py @@ -11,7 +11,6 @@ import json import logging -import time from pathlib import Path from typing import Any diff --git a/src/coding/proxy/cli/__init__.py b/src/coding/proxy/cli/__init__.py index 9dc1f33..1ec3fee 100644 --- a/src/coding/proxy/cli/__init__.py +++ b/src/coding/proxy/cli/__init__.py @@ -20,7 +20,8 @@ from ..config.loader import load_config from ..logging.db import TokenLogger from ..logging.stats import show_usage -from .auth_commands import app as auth_app, auto_login_if_needed as _auto_login_if_needed +from .auth_commands import app as auth_app +from .auth_commands import auto_login_if_needed as _auto_login_if_needed app = typer.Typer(name="coding-proxy", help="Claude Code 多供应商智能代理服务") console = Console() @@ -36,10 +37,14 @@ def _build_token_store(cfg_path: Path | None = None): cfg = load_config(cfg_path) store = TokenStoreManager( - store_path=Path(cfg.auth.token_store_path) if cfg.auth.token_store_path else None, + store_path=Path(cfg.auth.token_store_path) + if cfg.auth.token_store_path + else None, ) store.load() - logger.debug("OAuth token store loaded from config path: %s", cfg.auth.token_store_path) + logger.debug( + "OAuth token store loaded from config path: %s", cfg.auth.token_store_path + ) return cfg, store @@ -48,9 +53,9 @@ def _build_token_store(cfg_path: Path | None = None): @app.command() def start( - config: Optional[str] = typer.Option(None, "--config", "-c", help="配置文件路径"), - port: Optional[int] = typer.Option(None, "--port", "-p", help="监听端口"), - host: Optional[str] = typer.Option(None, "--host", "-h", help="监听地址"), + config: str | None = typer.Option(None, "--config", "-c", help="配置文件路径"), + port: int | None = typer.Option(None, "--port", "-p", help="监听端口"), + host: str | None = typer.Option(None, "--host", "-h", help="监听地址"), ) -> None: """启动代理服务.""" import uvicorn @@ -94,10 +99,14 @@ def status( console.print(f"\n[bold green]{name}[/bold green]") cb = tier_info.get("circuit_breaker") if cb: - console.print(f" [cyan]熔断器:[/] {cb.get('state', 'unknown')} 失败={cb.get('failure_count', 0)}") + console.print( + f" [cyan]熔断器:[/] {cb.get('state', 'unknown')} 失败={cb.get('failure_count', 0)}" + ) qg = tier_info.get("quota_guard") if qg: - console.print(f" [cyan]配额:[/] {qg.get('state', 'unknown')} {qg.get('usage_percent', 0)}% ({qg.get('window_usage_tokens', 0)}/{qg.get('budget_tokens', 0)})") + console.print( + f" [cyan]配额:[/] {qg.get('state', 'unknown')} {qg.get('usage_percent', 0)}% ({qg.get('window_usage_tokens', 0)}/{qg.get('budget_tokens', 0)})" + ) except httpx.ConnectError: console.print("[red]代理服务未运行[/red]") @@ -105,9 +114,9 @@ def status( @app.command() def usage( days: int = typer.Option(7, "--days", "-d", help="统计天数"), - vendor: Optional[str] = typer.Option(None, "--vendor", "-v", help="过滤供应商"), - model: Optional[str] = typer.Option(None, "--model", "-m", help="过滤请求模型"), - db_path: Optional[str] = typer.Option(None, "--db", help="数据库路径"), + vendor: str | None = typer.Option(None, "--vendor", "-v", help="过滤供应商"), + model: str | None = typer.Option(None, "--model", "-m", help="过滤请求模型"), + db_path: str | None = typer.Option(None, "--db", help="数据库路径"), ) -> None: """查看 Token 使用统计.""" cfg = load_config(Path(db_path) if db_path else None) @@ -115,9 +124,15 @@ def usage( asyncio.run(_run_usage(token_logger, days, vendor, model, cfg)) -async def _run_usage(token_logger: TokenLogger, days: int, vendor: str | None, - model: str | None, cfg: "ProxyConfig") -> None: +async def _run_usage( + token_logger: TokenLogger, + days: int, + vendor: str | None, + model: str | None, + cfg: ProxyConfig, +) -> None: from ..pricing import PricingTable + await token_logger.init() pricing_table = PricingTable(cfg.pricing) await show_usage(token_logger, days, vendor, model, pricing_table) diff --git a/src/coding/proxy/cli/auth_commands.py b/src/coding/proxy/cli/auth_commands.py index 5d4c6f4..ce4ee78 100644 --- a/src/coding/proxy/cli/auth_commands.py +++ b/src/coding/proxy/cli/auth_commands.py @@ -6,13 +6,12 @@ import inspect import logging from pathlib import Path -from typing import Optional import typer from rich.console import Console -from ..config.loader import load_config from ..auth.store import TokenStoreManager +from ..config.loader import load_config app = typer.Typer(name="auth", help="管理 OAuth 登录凭证") console = Console() @@ -23,10 +22,14 @@ def _build_token_store(cfg_path: Path | None = None): """按配置解析 Token Store 路径并完成加载.""" cfg = load_config(cfg_path) store = TokenStoreManager( - store_path=Path(cfg.auth.token_store_path) if cfg.auth.token_store_path else None, + store_path=Path(cfg.auth.token_store_path) + if cfg.auth.token_store_path + else None, ) store.load() - logger.debug("OAuth token store loaded from config path: %s", cfg.auth.token_store_path) + logger.debug( + "OAuth token store loaded from config path: %s", cfg.auth.token_store_path + ) return cfg, store @@ -35,7 +38,9 @@ def _build_token_store(cfg_path: Path | None = None): @app.command("login") def auth_login( - provider: Optional[str] = typer.Option(None, "--provider", "-p", help="指定 provider (github/google)"), + provider: str | None = typer.Option( + None, "--provider", "-p", help="指定 provider (github/google)" + ), ) -> None: """执行 OAuth 浏览器登录.""" asyncio.run(_run_auth_login(provider)) @@ -51,17 +56,25 @@ async def _run_auth_login(provider: str | None) -> None: if provider == "github": providers = [("github", GitHubDeviceFlowProvider())] elif provider == "google": - providers = [("google", GoogleOAuthProvider( - client_id=cfg.auth.google_client_id, - client_secret=cfg.auth.google_client_secret, - ))] + providers = [ + ( + "google", + GoogleOAuthProvider( + client_id=cfg.auth.google_client_id, + client_secret=cfg.auth.google_client_secret, + ), + ) + ] elif provider is None: providers = [ ("github", GitHubDeviceFlowProvider()), - ("google", GoogleOAuthProvider( - client_id=cfg.auth.google_client_id, - client_secret=cfg.auth.google_client_secret, - )), + ( + "google", + GoogleOAuthProvider( + client_id=cfg.auth.google_client_id, + client_secret=cfg.auth.google_client_secret, + ), + ), ] else: console.print(f"[red]未知 provider: {provider}[/red]") @@ -108,9 +121,11 @@ def auth_reauth( try: resp = _httpx.post(f"http://127.0.0.1:{port}/api/reauth/{provider}", timeout=5) if resp.status_code == 202: - console.print(f"[green]{provider} 重认证已触发,请在浏览器中完成登录[/green]") + console.print( + f"[green]{provider} 重认证已触发,请在浏览器中完成登录[/green]" + ) elif resp.status_code == 404: - console.print(f"[red]重认证不可用(代理未启用对应后端)[/red]") + console.print("[red]重认证不可用(代理未启用对应后端)[/red]") else: console.print(f"[red]触发失败: {resp.status_code} {resp.text}[/red]") except _httpx.ConnectError: @@ -119,7 +134,9 @@ def auth_reauth( @app.command("logout") def auth_logout( - provider: Optional[str] = typer.Option(None, "--provider", "-p", help="指定 provider(不指定则全部登出)"), + provider: str | None = typer.Option( + None, "--provider", "-p", help="指定 provider(不指定则全部登出)" + ), ) -> None: """清除已存储的 OAuth 凭证.""" _, store = _build_token_store() @@ -170,7 +187,9 @@ async def _resolve_needs_login(provider, tokens) -> bool: except Exception: pass # 网络失败不阻塞启动 if needs: - console.print("[bold cyan]Copilot 层缺少有效凭证,启动 GitHub OAuth 登录...[/bold cyan]") + console.print( + "[bold cyan]Copilot 层缺少有效凭证,启动 GitHub OAuth 登录...[/bold cyan]" + ) try: tokens = await prov.login() store.set("github", tokens) @@ -192,14 +211,20 @@ async def _resolve_needs_login(provider, tokens) -> bool: needs = await _resolve_needs_login(prov, tokens) try: if not needs and tokens.is_expired and tokens.refresh_token: - logger.info("Google access_token 已过期,尝试使用 refresh_token 静默刷新") + logger.info( + "Google access_token 已过期,尝试使用 refresh_token 静默刷新" + ) try: tokens = await prov.refresh(tokens) store.set("google", tokens) logger.info("Google refresh_token 静默刷新成功") except Exception as exc: - logger.warning("Google refresh_token 静默刷新失败,回退交互登录: %s", exc) - console.print("[bold cyan]Antigravity 凭证刷新失败,启动 Google OAuth 登录...[/bold cyan]") + logger.warning( + "Google refresh_token 静默刷新失败,回退交互登录: %s", exc + ) + console.print( + "[bold cyan]Antigravity 凭证刷新失败,启动 Google OAuth 登录...[/bold cyan]" + ) tokens = await prov.login() store.set("google", tokens) console.print("[green]Google 登录成功[/green]") @@ -211,7 +236,9 @@ async def _resolve_needs_login(provider, tokens) -> bool: pass if needs: - console.print("[bold cyan]Antigravity 层缺少有效凭证,启动 Google OAuth 登录...[/bold cyan]") + console.print( + "[bold cyan]Antigravity 层缺少有效凭证,启动 Google OAuth 登录...[/bold cyan]" + ) tokens = await prov.login() store.set("google", tokens) console.print("[green]Google 登录成功[/green]") diff --git a/src/coding/proxy/compat/__init__.py b/src/coding/proxy/compat/__init__.py index 13eed50..090374c 100644 --- a/src/coding/proxy/compat/__init__.py +++ b/src/coding/proxy/compat/__init__.py @@ -4,8 +4,8 @@ CanonicalMessagePart, CanonicalPartType, CanonicalRequest, - CanonicalToolCall, CanonicalThinking, + CanonicalToolCall, CompatibilityDecision, CompatibilityProfile, CompatibilityStatus, diff --git a/src/coding/proxy/compat/canonical.py b/src/coding/proxy/compat/canonical.py index 4b5050f..c55c158 100644 --- a/src/coding/proxy/compat/canonical.py +++ b/src/coding/proxy/compat/canonical.py @@ -11,8 +11,8 @@ import hashlib import json -import time import uuid +from typing import Any # noqa: F401 from ..model.compat import ( @@ -62,7 +62,9 @@ def build_canonical_request( ) -def _extract_request_id(body: dict[str, Any], headers: dict[str, str], trace_id: str) -> str: +def _extract_request_id( + body: dict[str, Any], headers: dict[str, str], trace_id: str +) -> str: for key in ("request_id", "id"): value = body.get(key) if isinstance(value, str) and value.strip(): @@ -94,7 +96,9 @@ def _derive_session_key(body: dict[str, Any], headers: dict[str, str]) -> str: "messages": body.get("messages", [])[-6:], } digest = hashlib.sha256( - json.dumps(digest_body, ensure_ascii=False, sort_keys=True, default=str).encode() + json.dumps( + digest_body, ensure_ascii=False, sort_keys=True, default=str + ).encode() ).hexdigest() return f"compat_{digest[:24]}" @@ -107,7 +111,9 @@ def _extract_thinking(body: dict[str, Any]) -> CanonicalThinking: if isinstance(value, dict): return CanonicalThinking( enabled=True, - budget_tokens=value.get("budget_tokens") if isinstance(value.get("budget_tokens"), int) else None, + budget_tokens=value.get("budget_tokens") + if isinstance(value.get("budget_tokens"), int) + else None, effort=str(value.get("effort")) if value.get("effort") else None, source_field=source_field, ) @@ -123,7 +129,11 @@ def _extract_parts(messages: list[dict[str, Any]]) -> list[CanonicalMessagePart] role = str(message.get("role", "user")) content = message.get("content") if isinstance(content, str): - parts.append(CanonicalMessagePart(type=CanonicalPartType.TEXT, role=role, text=content)) + parts.append( + CanonicalMessagePart( + type=CanonicalPartType.TEXT, role=role, text=content + ) + ) continue if not isinstance(content, list): continue @@ -132,50 +142,64 @@ def _extract_parts(messages: list[dict[str, Any]]) -> list[CanonicalMessagePart] continue block_type = str(block.get("type", "")) if block_type == "text": - parts.append(CanonicalMessagePart( - type=CanonicalPartType.TEXT, - role=role, - text=str(block.get("text", "")), - raw_block=block, - )) + parts.append( + CanonicalMessagePart( + type=CanonicalPartType.TEXT, + role=role, + text=str(block.get("text", "")), + raw_block=block, + ) + ) elif block_type == "thinking": - parts.append(CanonicalMessagePart( - type=CanonicalPartType.THINKING, - role=role, - text=str(block.get("thinking", "")), - raw_block=block, - )) + parts.append( + CanonicalMessagePart( + type=CanonicalPartType.THINKING, + role=role, + text=str(block.get("thinking", "")), + raw_block=block, + ) + ) elif block_type == "image": - parts.append(CanonicalMessagePart( - type=CanonicalPartType.IMAGE, - role=role, - raw_block=block, - )) + parts.append( + CanonicalMessagePart( + type=CanonicalPartType.IMAGE, + role=role, + raw_block=block, + ) + ) elif block_type in {"tool_use", "server_tool_use"}: - parts.append(CanonicalMessagePart( - type=CanonicalPartType.TOOL_USE, - role=role, - tool_call=CanonicalToolCall( - tool_id=str(block.get("id", "")), - name=str(block.get("name", "")), - arguments=block.get("input", {}) if isinstance(block.get("input"), dict) else {}, - ), - raw_block=block, - )) + parts.append( + CanonicalMessagePart( + type=CanonicalPartType.TOOL_USE, + role=role, + tool_call=CanonicalToolCall( + tool_id=str(block.get("id", "")), + name=str(block.get("name", "")), + arguments=block.get("input", {}) + if isinstance(block.get("input"), dict) + else {}, + ), + raw_block=block, + ) + ) elif block_type == "tool_result": - parts.append(CanonicalMessagePart( - type=CanonicalPartType.TOOL_RESULT, - role=role, - text=_stringify_tool_result_content(block.get("content")), - tool_result_id=str(block.get("tool_use_id", "")), - raw_block=block, - )) + parts.append( + CanonicalMessagePart( + type=CanonicalPartType.TOOL_RESULT, + role=role, + text=_stringify_tool_result_content(block.get("content")), + tool_result_id=str(block.get("tool_use_id", "")), + raw_block=block, + ) + ) else: - parts.append(CanonicalMessagePart( - type=CanonicalPartType.UNKNOWN, - role=role, - raw_block=block, - )) + parts.append( + CanonicalMessagePart( + type=CanonicalPartType.UNKNOWN, + role=role, + raw_block=block, + ) + ) return parts diff --git a/src/coding/proxy/compat/session_store.py b/src/coding/proxy/compat/session_store.py index 69b92b2..0f72c5d 100644 --- a/src/coding/proxy/compat/session_store.py +++ b/src/coding/proxy/compat/session_store.py @@ -19,7 +19,6 @@ # noqa: F401 from ..model.compat import CompatSessionRecord - _CREATE_TABLE = """ CREATE TABLE IF NOT EXISTS compat_session ( session_key TEXT PRIMARY KEY, @@ -97,7 +96,9 @@ async def upsert(self, record: CompatSessionRecord) -> None: record.session_key, record.trace_id, json.dumps(record.tool_call_map, ensure_ascii=False, sort_keys=True), - json.dumps(record.thought_signature_map, ensure_ascii=False, sort_keys=True), + json.dumps( + record.thought_signature_map, ensure_ascii=False, sort_keys=True + ), json.dumps(record.provider_state, ensure_ascii=False, sort_keys=True), record.state_version, updated_at, @@ -108,7 +109,9 @@ async def upsert(self, record: CompatSessionRecord) -> None: async def delete(self, session_key: str) -> None: if not self._db: return - await self._db.execute("DELETE FROM compat_session WHERE session_key = ?", (session_key,)) + await self._db.execute( + "DELETE FROM compat_session WHERE session_key = ?", (session_key,) + ) await self._db.commit() async def close(self) -> None: @@ -126,7 +129,10 @@ async def _purge_expired(self) -> None: ) def _is_expired(self, updated_at_unix: int) -> bool: - return updated_at_unix > 0 and (int(time.time()) - updated_at_unix) > self._ttl_seconds + return ( + updated_at_unix > 0 + and (int(time.time()) - updated_at_unix) > self._ttl_seconds + ) def _loads_dict(raw: str) -> dict[str, Any]: diff --git a/src/coding/proxy/config/auth_schema.py b/src/coding/proxy/config/auth_schema.py index c3184f3..a27862d 100644 --- a/src/coding/proxy/config/auth_schema.py +++ b/src/coding/proxy/config/auth_schema.py @@ -15,10 +15,10 @@ class AuthConfig(BaseModel): github_client_id: str = "Iv1.b507a08c87ecfe98" google_client_id: str = ( - "1071006060591-tmhssin2h21lcre235vtolojh4g403ep" - ".apps.googleusercontent.com" + "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" ) google_client_secret: str = "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" token_store_path: str = "~/.coding-proxy/tokens.json" + __all__ = ["AuthConfig"] diff --git a/src/coding/proxy/config/loader.py b/src/coding/proxy/config/loader.py index 30cf39d..9e6a462 100644 --- a/src/coding/proxy/config/loader.py +++ b/src/coding/proxy/config/loader.py @@ -16,18 +16,29 @@ _ENV_VAR_RE = re.compile(r"\$\{([^}]+)\}") # ── Legacy flat 格式字段集合(用于检测旧配置,避免与 example vendors 冲突) ── -_LEGACY_FLAT_KEYS: frozenset[str] = frozenset({ - "primary", "copilot", "antigravity", "fallback", - "circuit_breaker", "copilot_circuit_breaker", "antigravity_circuit_breaker", - "quota_guard", "copilot_quota_guard", "antigravity_quota_guard", -}) +_LEGACY_FLAT_KEYS: frozenset[str] = frozenset( + { + "primary", + "copilot", + "antigravity", + "fallback", + "circuit_breaker", + "copilot_circuit_breaker", + "antigravity_circuit_breaker", + "quota_guard", + "copilot_quota_guard", + "antigravity_quota_guard", + } +) def _expand_env(value: str) -> str: """将 ${VAR} 替换为环境变量值.""" + def _replacer(match: re.Match) -> str: var_name = match.group(1) return os.environ.get(var_name, "") + return _ENV_VAR_RE.sub(_replacer, value) diff --git a/src/coding/proxy/config/resiliency.py b/src/coding/proxy/config/resiliency.py index e345742..734167c 100644 --- a/src/coding/proxy/config/resiliency.py +++ b/src/coding/proxy/config/resiliency.py @@ -41,6 +41,10 @@ class QuotaGuardConfig(BaseModel): threshold_percent: float = 99.0 probe_interval_seconds: int = 300 + __all__ = [ - "CircuitBreakerConfig", "RetryConfig", "FailoverConfig", "QuotaGuardConfig", + "CircuitBreakerConfig", + "RetryConfig", + "FailoverConfig", + "QuotaGuardConfig", ] diff --git a/src/coding/proxy/config/routing.py b/src/coding/proxy/config/routing.py index ae46fe7..34dabca 100644 --- a/src/coding/proxy/config/routing.py +++ b/src/coding/proxy/config/routing.py @@ -43,13 +43,27 @@ def _detect_currency(v: Any) -> str | None: # ── 供应商专属字段分组映射 ────────────────────────────────────── # 每个 vendor 类型对应其专属字段集合,用于 VendorConfig 的语义标注与校验 -_COPILOT_FIELDS: frozenset[str] = frozenset({ - "github_token", "account_type", "token_url", "models_cache_ttl_seconds", -}) -_ANTIGRAVITY_FIELDS: frozenset[str] = frozenset({ - "client_id", "client_secret", "refresh_token", "model_endpoint", -}) -_ZHIPU_FIELDS: frozenset[str] = frozenset({"api_key",}) +_COPILOT_FIELDS: frozenset[str] = frozenset( + { + "github_token", + "account_type", + "token_url", + "models_cache_ttl_seconds", + } +) +_ANTIGRAVITY_FIELDS: frozenset[str] = frozenset( + { + "client_id", + "client_secret", + "refresh_token", + "model_endpoint", + } +) +_ZHIPU_FIELDS: frozenset[str] = frozenset( + { + "api_key", + } +) _VENDOR_EXCLUSIVE_FIELDS: dict[str, frozenset[str]] = { "copilot": _COPILOT_FIELDS, @@ -81,12 +95,12 @@ class ModelPricingEntry(BaseModel): model_config = {"extra": "allow"} - vendor: str # 供应商名称(对应 usage 表"供应商"列) - model: str # 实际模型名(对应 usage 表"实际模型"列) - input_cost_per_mtok: PriceField = 0.0 # 输入 Token 单价 + vendor: str # 供应商名称(对应 usage 表"供应商"列) + model: str # 实际模型名(对应 usage 表"实际模型"列) + input_cost_per_mtok: PriceField = 0.0 # 输入 Token 单价 output_cost_per_mtok: PriceField = 0.0 # 输出 Token 单价 cache_write_cost_per_mtok: PriceField = 0.0 # 缓存创建 Token 单价 - cache_read_cost_per_mtok: PriceField = 0.0 # 缓存读取 Token 单价 + cache_read_cost_per_mtok: PriceField = 0.0 # 缓存读取 Token 单价 # ── 内部状态:币种信息(不参与序列化) ─────────────────── _currency: str = PrivateAttr(default="USD") @@ -101,8 +115,10 @@ def _check_currency_consistency(cls, data: Any) -> Any: return data price_field_names = [ - "input_cost_per_mtok", "output_cost_per_mtok", - "cache_write_cost_per_mtok", "cache_read_cost_per_mtok", + "input_cost_per_mtok", + "output_cost_per_mtok", + "cache_write_cost_per_mtok", + "cache_read_cost_per_mtok", ] currencies: set[str] = set() @@ -130,13 +146,16 @@ def _check_currency_consistency(cls, data: Any) -> Any: return data @model_validator(mode="after") - def _capture_currency(self) -> "ModelPricingEntry": + def _capture_currency(self) -> ModelPricingEntry: """将 mode=before 检测到的币种转移到 PrivateAttr,清理临时键.""" detected = getattr(self, "__detected_currency__", None) if detected: self._currency = detected # 从 __pydantic_extra__ 中移除临时键,避免序列化泄露 - if hasattr(self, "__pydantic_extra__") and "__detected_currency__" in self.__pydantic_extra__: + if ( + hasattr(self, "__pydantic_extra__") + and "__detected_currency__" in self.__pydantic_extra__ + ): del self.__pydantic_extra__["__detected_currency__"] else: # 回退:直接删除实例属性 @@ -147,11 +166,13 @@ def _capture_currency(self) -> "ModelPricingEntry": return self @model_validator(mode="after") - def _validate_non_negative(self) -> "ModelPricingEntry": + def _validate_non_negative(self) -> ModelPricingEntry: """校验所有价格字段非负.""" for name in ( - "input_cost_per_mtok", "output_cost_per_mtok", - "cache_write_cost_per_mtok", "cache_read_cost_per_mtok", + "input_cost_per_mtok", + "output_cost_per_mtok", + "cache_write_cost_per_mtok", + "cache_read_cost_per_mtok", ): val = getattr(self, name) if val < 0: @@ -248,7 +269,7 @@ class VendorConfig(BaseModel): weekly_quota_guard: QuotaGuardConfig = Field(default_factory=QuotaGuardConfig) @model_validator(mode="after") - def _warn_irrelevant_fields(self) -> "VendorConfig": + def _warn_irrelevant_fields(self) -> VendorConfig: """对非当前 vendor 类型的非空专属字段发出 warning.""" exclusive = _VENDOR_EXCLUSIVE_FIELDS.get(self.vendor) if not exclusive: @@ -258,13 +279,18 @@ def _warn_irrelevant_fields(self) -> "VendorConfig": continue for field_name in fields: value = getattr(self, field_name, None) - if value and value != getattr(VendorConfig.model_fields[field_name], "default", None): + if value and value != getattr( + VendorConfig.model_fields[field_name], "default", None + ): logger.warning( "VendorConfig(vendor=%s): 字段 %s 属于 %s 供应商,当前值将被忽略", - self.vendor, field_name, vendor_type, + self.vendor, + field_name, + vendor_type, ) return self + # ── 向后兼容别名(v2 移除)──────────────────────────────────── TierConfig = VendorConfig @@ -272,8 +298,15 @@ def _warn_irrelevant_fields(self) -> "VendorConfig": _BACKEND_EXCLUSIVE_FIELDS = _VENDOR_EXCLUSIVE_FIELDS __all__ = [ - "VendorType", "VendorConfig", "ModelMappingRule", "ModelPricingEntry", - "TierConfig", "BackendType", # 向后兼容别名 - "_COPILOT_FIELDS", "_ANTIGRAVITY_FIELDS", "_ZHIPU_FIELDS", - "_VENDOR_EXCLUSIVE_FIELDS", "_BACKEND_EXCLUSIVE_FIELDS", + "VendorType", + "VendorConfig", + "ModelMappingRule", + "ModelPricingEntry", + "TierConfig", + "BackendType", # 向后兼容别名 + "_COPILOT_FIELDS", + "_ANTIGRAVITY_FIELDS", + "_ZHIPU_FIELDS", + "_VENDOR_EXCLUSIVE_FIELDS", + "_BACKEND_EXCLUSIVE_FIELDS", ] diff --git a/src/coding/proxy/config/schema.py b/src/coding/proxy/config/schema.py index 8f6cd9c..e3b402a 100644 --- a/src/coding/proxy/config/schema.py +++ b/src/coding/proxy/config/schema.py @@ -19,35 +19,35 @@ from pydantic import BaseModel, Field, model_validator -# ── 子模块 re-export ──────────────────────────────────────────── - -from .server import ServerConfig, DatabaseConfig, LoggingConfig # noqa: F401 -from .vendors import ( # noqa: F401 - AnthropicConfig, - CopilotConfig, - AntigravityConfig, - ZhipuConfig, -) -from .resiliency import ( # noqa: F401 +from .auth_schema import AuthConfig # noqa: F401 +from .resiliency import ( # noqa: F401 CircuitBreakerConfig, - RetryConfig, FailoverConfig, QuotaGuardConfig, + RetryConfig, ) -from .routing import ( # noqa: F401 - VendorType, - VendorConfig, - TierConfig, # 向后兼容别名 +from .routing import ( # noqa: F401 + _ANTIGRAVITY_FIELDS, + _BACKEND_EXCLUSIVE_FIELDS, # 向后兼容别名 + _COPILOT_FIELDS, + _VENDOR_EXCLUSIVE_FIELDS, + _ZHIPU_FIELDS, BackendType, # 向后兼容别名 ModelMappingRule, ModelPricingEntry, - _COPILOT_FIELDS, - _ANTIGRAVITY_FIELDS, - _ZHIPU_FIELDS, - _VENDOR_EXCLUSIVE_FIELDS, - _BACKEND_EXCLUSIVE_FIELDS, # 向后兼容别名 + TierConfig, # 向后兼容别名 + VendorConfig, + VendorType, +) + +# ── 子模块 re-export ──────────────────────────────────────────── +from .server import DatabaseConfig, LoggingConfig, ServerConfig # noqa: F401 +from .vendors import ( # noqa: F401 + AnthropicConfig, + AntigravityConfig, + CopilotConfig, + ZhipuConfig, ) -from .auth_schema import AuthConfig # noqa: F401 logger = logging.getLogger(__name__) @@ -99,9 +99,13 @@ class ProxyConfig(BaseModel): failover: FailoverConfig = FailoverConfig() model_mapping: list[ModelMappingRule] = Field( default=[ - ModelMappingRule(pattern="claude-sonnet-.*", target="glm-5.1", is_regex=True), + ModelMappingRule( + pattern="claude-sonnet-.*", target="glm-5.1", is_regex=True + ), ModelMappingRule(pattern="claude-opus-.*", target="glm-5.1", is_regex=True), - ModelMappingRule(pattern="claude-haiku-.*", target="glm-4.5-air", is_regex=True), + ModelMappingRule( + pattern="claude-haiku-.*", target="glm-4.5-air", is_regex=True + ), ModelMappingRule(pattern="claude-.*", target="glm-5.1", is_regex=True), ], ) @@ -157,18 +161,33 @@ def _migrate_legacy_fields(cls, data: Any) -> Any: # 使用 key 存在性检测(而非真值检测),以正确处理 vendors: [] 等显式空配置 if "vendors" in data or "tiers" in data: # 如果用户使用了旧的 tiers 字段名但实际是 vendor 定义列表,重映射 - if "tiers" in data and "vendors" not in data and isinstance(data["tiers"], list): + if ( + "tiers" in data + and "vendors" not in data + and isinstance(data["tiers"], list) + ): # 检测是否为新格式的 vendor 定义列表(每项有 vendor 字段,兼容旧 backend 字段) first_item = data["tiers"][0] if data["tiers"] else {} - if isinstance(first_item, dict) and ("vendor" in first_item or "backend" in first_item): + if isinstance(first_item, dict) and ( + "vendor" in first_item or "backend" in first_item + ): data["vendors"] = data.pop("tiers") return data # 3. 从旧 flat 格式自动构建 vendors(触发时记录废弃日志) vendors: list[dict[str, Any]] = [] - _legacy_keys = {"primary", "copilot", "antigravity", "fallback", - "circuit_breaker", "copilot_circuit_breaker", "antigravity_circuit_breaker", - "quota_guard", "copilot_quota_guard", "antigravity_quota_guard"} + _legacy_keys = { + "primary", + "copilot", + "antigravity", + "fallback", + "circuit_breaker", + "copilot_circuit_breaker", + "antigravity_circuit_breaker", + "quota_guard", + "copilot_quota_guard", + "antigravity_quota_guard", + } if any(k in data for k in _legacy_keys): logger.info( "检测到旧 flat 格式配置字段,已自动迁移至 vendors 列表格式。" @@ -217,7 +236,7 @@ def _migrate_legacy_fields(cls, data: Any) -> Any: return data @model_validator(mode="after") - def _validate_tiers(self) -> "ProxyConfig": + def _validate_tiers(self) -> ProxyConfig: """校验 tiers 引用的 vendor 必须在 enabled vendors 中存在.""" if self.tiers is None: return self # 未配置,跳过校验 @@ -229,9 +248,7 @@ def _validate_tiers(self) -> "ProxyConfig": seen: set[str] = set() for name in self.tiers: if name in seen: - raise ValueError( - f"tiers 包含重复的 vendor 名称: {name!r}" - ) + raise ValueError(f"tiers 包含重复的 vendor 名称: {name!r}") seen.add(name) # 检查引用是否存在 @@ -245,8 +262,7 @@ def _validate_tiers(self) -> "ProxyConfig": # 存在但 disabled → warning if name not in enabled_vendors: logger.warning( - "tiers 引用了 disabled 的 vendor: %s," - "该层级将在运行时被跳过", + "tiers 引用了 disabled 的 vendor: %s,该层级将在运行时被跳过", name, ) @@ -264,17 +280,31 @@ def compat_state_path(self) -> Path: __all__ = [ "ProxyConfig", # server - "ServerConfig", "DatabaseConfig", "LoggingConfig", + "ServerConfig", + "DatabaseConfig", + "LoggingConfig", # vendors - "AnthropicConfig", "CopilotConfig", "AntigravityConfig", "ZhipuConfig", + "AnthropicConfig", + "CopilotConfig", + "AntigravityConfig", + "ZhipuConfig", # resiliency - "CircuitBreakerConfig", "RetryConfig", "FailoverConfig", "QuotaGuardConfig", + "CircuitBreakerConfig", + "RetryConfig", + "FailoverConfig", + "QuotaGuardConfig", # routing - "VendorType", "VendorConfig", "ModelMappingRule", "ModelPricingEntry", + "VendorType", + "VendorConfig", + "ModelMappingRule", + "ModelPricingEntry", "TierConfig", # 向后兼容别名 "BackendType", # 向后兼容别名 - "_COPILOT_FIELDS", "_ANTIGRAVITY_FIELDS", "_ZHIPU_FIELDS", - "_VENDOR_EXCLUSIVE_FIELDS", "_BACKEND_EXCLUSIVE_FIELDS", + "_COPILOT_FIELDS", + "_ANTIGRAVITY_FIELDS", + "_ZHIPU_FIELDS", + "_VENDOR_EXCLUSIVE_FIELDS", + "_BACKEND_EXCLUSIVE_FIELDS", # auth "AuthConfig", ] diff --git a/src/coding/proxy/config/server.py b/src/coding/proxy/config/server.py index 7ff4032..c6894a5 100644 --- a/src/coding/proxy/config/server.py +++ b/src/coding/proxy/config/server.py @@ -20,4 +20,5 @@ class LoggingConfig(BaseModel): level: str = "INFO" file: str | None = None + __all__ = ["ServerConfig", "DatabaseConfig", "LoggingConfig"] diff --git a/src/coding/proxy/config/vendors.py b/src/coding/proxy/config/vendors.py index 3ef0afa..a0fd467 100644 --- a/src/coding/proxy/config/vendors.py +++ b/src/coding/proxy/config/vendors.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pydantic import BaseModel, Field +from pydantic import BaseModel class AnthropicConfig(BaseModel): @@ -48,6 +48,10 @@ class ZhipuConfig(BaseModel): api_key: str = "" timeout_ms: int = 3000000 + __all__ = [ - "AnthropicConfig", "CopilotConfig", "AntigravityConfig", "ZhipuConfig", + "AnthropicConfig", + "CopilotConfig", + "AntigravityConfig", + "ZhipuConfig", ] diff --git a/src/coding/proxy/convert/__init__.py b/src/coding/proxy/convert/__init__.py index a07f588..fc2ead2 100644 --- a/src/coding/proxy/convert/__init__.py +++ b/src/coding/proxy/convert/__init__.py @@ -1,7 +1,7 @@ """Anthropic ↔ Gemini 格式转换模块.""" -from .anthropic_to_openai import convert_request as convert_openai_request from .anthropic_to_gemini import convert_request +from .anthropic_to_openai import convert_request as convert_openai_request from .gemini_to_anthropic import convert_response, extract_usage from .openai_to_anthropic import convert_response as convert_openai_response diff --git a/src/coding/proxy/convert/anthropic_to_gemini.py b/src/coding/proxy/convert/anthropic_to_gemini.py index d20345f..27f4660 100644 --- a/src/coding/proxy/convert/anthropic_to_gemini.py +++ b/src/coding/proxy/convert/anthropic_to_gemini.py @@ -63,7 +63,9 @@ def convert_request( result["contents"] = [{"role": "user", "parts": [{"text": " "}]}] adaptations.append("empty_contents_padded") - generation_config = _build_generation_config(anthropic_body, model=model, adaptations=adaptations) + generation_config = _build_generation_config( + anthropic_body, model=model, adaptations=adaptations + ) if generation_config: result["generationConfig"] = generation_config @@ -168,12 +170,14 @@ def _convert_content( elif block_type == "image": source = block.get("source", {}) if source.get("type") == "base64": - parts.append({ - "inlineData": { - "mimeType": source.get("media_type", "image/png"), - "data": source.get("data", ""), + parts.append( + { + "inlineData": { + "mimeType": source.get("media_type", "image/png"), + "data": source.get("data", ""), + } } - }) + ) elif block_type == "tool_use": name = block.get("name", "") tool_id = block.get("id", "") @@ -194,13 +198,15 @@ def _convert_content( tool_use_id = block.get("tool_use_id", "") tool_content = block.get("content", "") text = _stringify_tool_content(tool_content) - parts.append({ - "functionResponse": { - "name": tool_name_by_id.get(tool_use_id, tool_use_id), - "response": {"result": text}, - "id": tool_use_id or None, + parts.append( + { + "functionResponse": { + "name": tool_name_by_id.get(tool_use_id, tool_use_id), + "response": {"result": text}, + "id": tool_use_id or None, + } } - }) + ) if tool_use_id and tool_use_id not in tool_name_by_id: adaptations.append("tool_result_name_fallback_to_tool_use_id") else: @@ -280,7 +286,13 @@ def _build_generation_config( ) for msg in body.get("messages", []) ) - if config.get("thinkingConfig") and has_tools and has_tool_use and model and not model.startswith("gemini-"): + if ( + config.get("thinkingConfig") + and has_tools + and has_tool_use + and model + and not model.startswith("gemini-") + ): del config["thinkingConfig"] adaptations.append("thinking_disabled_for_tool_call_compatibility") @@ -344,7 +356,9 @@ def _build_tools( if isinstance(name, str) and name: mode = "ANY" allowed_names = [name] - adaptations.append("tool_choice_tool_mapped_to_allowed_function_names") + adaptations.append( + "tool_choice_tool_mapped_to_allowed_function_names" + ) tool_config = {"functionCallingConfig": {"mode": mode}} if allowed_names: tool_config["functionCallingConfig"]["allowedFunctionNames"] = allowed_names diff --git a/src/coding/proxy/convert/anthropic_to_openai.py b/src/coding/proxy/convert/anthropic_to_openai.py index f2ec0f8..82baeba 100644 --- a/src/coding/proxy/convert/anthropic_to_openai.py +++ b/src/coding/proxy/convert/anthropic_to_openai.py @@ -96,7 +96,9 @@ def _translate_model_name(model: str) -> str: # 新增:处理带 minor 版本的 Anthropic 格式 # 例如 claude-sonnet-4.6-20250514 -> claude-sonnet-4.6 - versioned_match = re.match(r"^(claude-(?:sonnet|opus|haiku))-(\d+\.\d+)-\d+$", model) + versioned_match = re.match( + r"^(claude-(?:sonnet|opus|haiku))-(\d+\.\d+)-\d+$", model + ) if versioned_match: family = versioned_match.group(1) version = versioned_match.group(2) @@ -154,7 +156,9 @@ def _translate_messages( return translated -def _translate_system(system: str | list[dict[str, Any]] | None) -> list[dict[str, Any]]: +def _translate_system( + system: str | list[dict[str, Any]] | None, +) -> list[dict[str, Any]]: """转换 system prompt,保留 cache_control 边界信息(通过 DEBUG 日志). OpenAI 的 system role message 不原生支持 cache_control block。 @@ -196,8 +200,16 @@ def _translate_user_message(message: dict[str, Any]) -> list[dict[str, Any]]: return [{"role": "user", "content": content or ""}] translated: list[dict[str, Any]] = [] - tool_results = [block for block in content if isinstance(block, dict) and block.get("type") == "tool_result"] - other_blocks = [block for block in content if isinstance(block, dict) and block.get("type") != "tool_result"] + tool_results = [ + block + for block in content + if isinstance(block, dict) and block.get("type") == "tool_result" + ] + other_blocks = [ + block + for block in content + if isinstance(block, dict) and block.get("type") != "tool_result" + ] for block in tool_results: tool_result_content = _map_block_content(block.get("content", "")) @@ -209,17 +221,21 @@ def _translate_user_message(message: dict[str, Any]) -> list[dict[str, Any]]: block.get("tool_use_id", ""), ) tool_result_content = f"[ERROR]\n{tool_result_content}" - translated.append({ - "role": "tool", - "tool_call_id": block.get("tool_use_id", ""), - "content": tool_result_content, - }) + translated.append( + { + "role": "tool", + "tool_call_id": block.get("tool_use_id", ""), + "content": tool_result_content, + } + ) if other_blocks: - translated.append({ - "role": "user", - "content": _map_block_content(other_blocks), - }) + translated.append( + { + "role": "user", + "content": _map_block_content(other_blocks), + } + ) return translated @@ -228,7 +244,11 @@ def _translate_assistant_message(message: dict[str, Any]) -> list[dict[str, Any] if not isinstance(content, list): return [{"role": "assistant", "content": content or ""}] - tool_uses = [block for block in content if isinstance(block, dict) and block.get("type") == "tool_use"] + tool_uses = [ + block + for block in content + if isinstance(block, dict) and block.get("type") == "tool_use" + ] text_parts: list[str] = [] thinking_parts: list[str] = [] @@ -253,7 +273,8 @@ def _translate_assistant_message(message: dict[str, Any]) -> list[dict[str, Any] logger.debug( "copilot: assistant message has both thinking (%d blocks) and text (%d blocks), " "thinking will be prepended as [Thinking]...[/Thinking] context", - len(thinking_parts), len(text_parts), + len(thinking_parts), + len(text_parts), ) final_text_parts = [ f"[Thinking]\n{''.join(thinking_parts)}\n[/Thinking]\n\n", @@ -263,27 +284,35 @@ def _translate_assistant_message(message: dict[str, Any]) -> list[dict[str, Any] final_text_parts = text_parts if tool_uses: - return [{ - "role": "assistant", - "content": "\n\n".join(part for part in final_text_parts if part) or None, - "tool_calls": [ - { - "id": block.get("id", ""), - "type": "function", - "function": { - "name": block.get("name", ""), - "arguments": json.dumps(block.get("input", {}), ensure_ascii=False), - }, - } - for block in tool_uses - ], - }] + return [ + { + "role": "assistant", + "content": "\n\n".join(part for part in final_text_parts if part) + or None, + "tool_calls": [ + { + "id": block.get("id", ""), + "type": "function", + "function": { + "name": block.get("name", ""), + "arguments": json.dumps( + block.get("input", {}), ensure_ascii=False + ), + }, + } + for block in tool_uses + ], + } + ] - return [{ - "role": "assistant", - "content": _map_block_content(content) if not thinking_parts and not tool_uses - else "\n\n".join(part for part in final_text_parts if part) or "", - }] + return [ + { + "role": "assistant", + "content": _map_block_content(content) + if not thinking_parts and not tool_uses + else "\n\n".join(part for part in final_text_parts if part) or "", + } + ] def _map_block_content(content: Any) -> Any: @@ -292,7 +321,9 @@ def _map_block_content(content: Any) -> Any: if not isinstance(content, list): return None - has_image = any(isinstance(block, dict) and block.get("type") == "image" for block in content) + has_image = any( + isinstance(block, dict) and block.get("type") == "image" for block in content + ) if not has_image: parts: list[str] = [] for block in content: @@ -314,12 +345,14 @@ def _map_block_content(content: Any) -> Any: translated.append({"type": "text", "text": block.get("thinking", "")}) elif block.get("type") == "image": source = block.get("source", {}) - translated.append({ - "type": "image_url", - "image_url": { - "url": f"data:{source.get('media_type', 'image/png')};base64,{source.get('data', '')}", - }, - }) + translated.append( + { + "type": "image_url", + "image_url": { + "url": f"data:{source.get('media_type', 'image/png')};base64,{source.get('data', '')}", + }, + } + ) return translated @@ -334,7 +367,9 @@ def _translate_tool(tool: dict[str, Any]) -> dict[str, Any]: } -def _translate_tool_choice(tool_choice: dict[str, Any] | None) -> str | dict[str, Any] | None: +def _translate_tool_choice( + tool_choice: dict[str, Any] | None, +) -> str | dict[str, Any] | None: if not isinstance(tool_choice, dict): return None choice_type = tool_choice.get("type") diff --git a/src/coding/proxy/convert/gemini_sse_adapter.py b/src/coding/proxy/convert/gemini_sse_adapter.py index 02a0a5a..f363dbd 100644 --- a/src/coding/proxy/convert/gemini_sse_adapter.py +++ b/src/coding/proxy/convert/gemini_sse_adapter.py @@ -5,7 +5,8 @@ import json import logging import uuid -from typing import Any, AsyncIterator +from collections.abc import AsyncIterator +from typing import Any from .gemini_to_anthropic import GEMINI_FINISH_REASON_MAP @@ -62,77 +63,114 @@ async def adapt_sse_stream( continue if not started: started = True - yield _make_event("message_start", { - "type": "message_start", - "message": { - "id": msg_id, - "type": "message", - "role": "assistant", - "content": [], - "model": model, - "usage": {"input_tokens": input_tokens, "output_tokens": 0}, + yield _make_event( + "message_start", + { + "type": "message_start", + "message": { + "id": msg_id, + "type": "message", + "role": "assistant", + "content": [], + "model": model, + "usage": { + "input_tokens": input_tokens, + "output_tokens": 0, + }, + }, }, - }) + ) if current_block_type != block_type: if current_block_type is not None: - yield _make_event("content_block_stop", { - "type": "content_block_stop", - "index": block_index, - }) + yield _make_event( + "content_block_stop", + { + "type": "content_block_stop", + "index": block_index, + }, + ) block_index += 1 - yield _make_event("content_block_start", { - "type": "content_block_start", - "index": block_index, - "content_block": start_block, - }) + yield _make_event( + "content_block_start", + { + "type": "content_block_start", + "index": block_index, + "content_block": start_block, + }, + ) current_block_type = block_type - yield _make_event("content_block_delta", { - "type": "content_block_delta", - "index": block_index, - "delta": delta, - }) + yield _make_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": block_index, + "delta": delta, + }, + ) if block_type == "tool_use": used_tool = True - yield _make_event("content_block_stop", { - "type": "content_block_stop", - "index": block_index, - }) + yield _make_event( + "content_block_stop", + { + "type": "content_block_stop", + "index": block_index, + }, + ) block_index += 1 current_block_type = None if finish_reason and finish_reason != "FINISH_REASON_UNSPECIFIED": if current_block_type is not None: - yield _make_event("content_block_stop", { - "type": "content_block_stop", - "index": block_index, - }) + yield _make_event( + "content_block_stop", + { + "type": "content_block_stop", + "index": block_index, + }, + ) current_block_type = None - stop_reason = "tool_use" if used_tool else _map_finish_reason(finish_reason) - yield _make_event("message_delta", { - "type": "message_delta", - "delta": {"stop_reason": stop_reason, "stop_sequence": None}, - "usage": {"output_tokens": total_output_tokens}, - }) + stop_reason = ( + "tool_use" if used_tool else _map_finish_reason(finish_reason) + ) + yield _make_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": stop_reason, "stop_sequence": None}, + "usage": {"output_tokens": total_output_tokens}, + }, + ) yield _make_event("message_stop", {"type": "message_stop"}) return if current_block_type is not None: - yield _make_event("content_block_stop", { - "type": "content_block_stop", - "index": block_index, - }) - yield _make_event("message_delta", { - "type": "message_delta", - "delta": {"stop_reason": "tool_use" if used_tool else "end_turn", "stop_sequence": None}, - "usage": {"output_tokens": total_output_tokens}, - }) + yield _make_event( + "content_block_stop", + { + "type": "content_block_stop", + "index": block_index, + }, + ) + yield _make_event( + "message_delta", + { + "type": "message_delta", + "delta": { + "stop_reason": "tool_use" if used_tool else "end_turn", + "stop_sequence": None, + }, + "usage": {"output_tokens": total_output_tokens}, + }, + ) yield _make_event("message_stop", {"type": "message_stop"}) -def _part_to_events(part: dict[str, Any]) -> tuple[str, dict[str, Any], dict[str, Any] | None]: +def _part_to_events( + part: dict[str, Any], +) -> tuple[str, dict[str, Any], dict[str, Any] | None]: if part.get("functionCall"): fc = part["functionCall"] start_block = { @@ -141,22 +179,34 @@ def _part_to_events(part: dict[str, Any]) -> tuple[str, dict[str, Any], dict[str "name": fc.get("name", ""), "input": {}, } - return "tool_use", start_block, { - "type": "input_json_delta", - "partial_json": json.dumps(fc.get("args", {}), ensure_ascii=False), - } + return ( + "tool_use", + start_block, + { + "type": "input_json_delta", + "partial_json": json.dumps(fc.get("args", {}), ensure_ascii=False), + }, + ) if part.get("text") is not None and part.get("thought"): - return "thinking", {"type": "thinking", "thinking": ""}, { - "type": "thinking_delta", - "thinking": part.get("text", ""), - } + return ( + "thinking", + {"type": "thinking", "thinking": ""}, + { + "type": "thinking_delta", + "thinking": part.get("text", ""), + }, + ) if part.get("text"): - return "text", {"type": "text", "text": ""}, { - "type": "text_delta", - "text": part["text"], - } + return ( + "text", + {"type": "text", "text": ""}, + { + "type": "text_delta", + "text": part["text"], + }, + ) return "text", {"type": "text", "text": ""}, None diff --git a/src/coding/proxy/convert/gemini_to_anthropic.py b/src/coding/proxy/convert/gemini_to_anthropic.py index f64eed3..2cebe77 100644 --- a/src/coding/proxy/convert/gemini_to_anthropic.py +++ b/src/coding/proxy/convert/gemini_to_anthropic.py @@ -34,12 +34,16 @@ def convert_response( content_blocks = _convert_parts(content_parts) finish_reason = candidate.get("finishReason", "STOP") - stop_reason = "tool_use" if any(block.get("type") == "tool_use" for block in content_blocks) else ( - GEMINI_FINISH_REASON_MAP.get(finish_reason, "end_turn") + stop_reason = ( + "tool_use" + if any(block.get("type") == "tool_use" for block in content_blocks) + else (GEMINI_FINISH_REASON_MAP.get(finish_reason, "end_turn")) ) usage = extract_usage(gemini_resp) - msg_id = request_id or gemini_resp.get("responseId") or f"msg_{uuid.uuid4().hex[:24]}" + msg_id = ( + request_id or gemini_resp.get("responseId") or f"msg_{uuid.uuid4().hex[:24]}" + ) result = { "id": msg_id, @@ -75,24 +79,30 @@ def _convert_parts(parts: list[dict[str, Any]]) -> list[dict[str, Any]]: signature = part.get("thoughtSignature") if part.get("functionCall"): fc = part["functionCall"] - blocks.append({ - "type": "tool_use", - "id": fc.get("id") or f"toolu_{uuid.uuid4().hex[:24]}", - "name": fc.get("name", ""), - "input": fc.get("args", {}), - **({"signature": signature} if signature else {}), - }) + blocks.append( + { + "type": "tool_use", + "id": fc.get("id") or f"toolu_{uuid.uuid4().hex[:24]}", + "name": fc.get("name", ""), + "input": fc.get("args", {}), + **({"signature": signature} if signature else {}), + } + ) continue if part.get("text") is not None: text = part.get("text", "") if part.get("thought"): - blocks.append({ - "type": "thinking", - "thinking": text, - **({"signature": signature} if signature else {}), - }) + blocks.append( + { + "type": "thinking", + "thinking": text, + **({"signature": signature} if signature else {}), + } + ) elif text: blocks.append({"type": "text", "text": text}) elif signature: - blocks.append({"type": "thinking", "thinking": "", "signature": signature}) + blocks.append( + {"type": "thinking", "thinking": "", "signature": signature} + ) return blocks diff --git a/src/coding/proxy/convert/openai_to_anthropic.py b/src/coding/proxy/convert/openai_to_anthropic.py index 048a67c..6d274dd 100644 --- a/src/coding/proxy/convert/openai_to_anthropic.py +++ b/src/coding/proxy/convert/openai_to_anthropic.py @@ -33,7 +33,11 @@ def convert_response(response: dict[str, Any]) -> dict[str, Any]: text_blocks.append({"type": "text", "text": content}) elif isinstance(content, list): for part in content: - if isinstance(part, dict) and part.get("type") == "text" and part.get("text"): + if ( + isinstance(part, dict) + and part.get("type") == "text" + and part.get("text") + ): text_blocks.append({"type": "text", "text": part["text"]}) for tool_call in message.get("tool_calls", []) or []: @@ -42,18 +46,24 @@ def convert_response(response: dict[str, Any]) -> dict[str, Any]: function = tool_call.get("function", {}) arguments = function.get("arguments", "{}") try: - parsed_arguments = json.loads(arguments) if isinstance(arguments, str) else arguments + parsed_arguments = ( + json.loads(arguments) if isinstance(arguments, str) else arguments + ) except json.JSONDecodeError: parsed_arguments = {} - tool_use_blocks.append({ - "type": "tool_use", - "id": tool_call.get("id", ""), - "name": function.get("name", ""), - "input": parsed_arguments if isinstance(parsed_arguments, dict) else {}, - }) + tool_use_blocks.append( + { + "type": "tool_use", + "id": tool_call.get("id", ""), + "name": function.get("name", ""), + "input": parsed_arguments + if isinstance(parsed_arguments, dict) + else {}, + } + ) usage = response.get("usage", {}) or {} - cached_tokens = ((usage.get("prompt_tokens_details") or {}).get("cached_tokens", 0)) + cached_tokens = (usage.get("prompt_tokens_details") or {}).get("cached_tokens", 0) content_blocks = [*text_blocks, *tool_use_blocks] return { @@ -65,7 +75,9 @@ def convert_response(response: dict[str, Any]) -> dict[str, Any]: "stop_reason": _map_stop_reason(finish_reason) or "end_turn", "stop_sequence": None, "usage": { - "input_tokens": max((usage.get("prompt_tokens", 0) or 0) - cached_tokens, 0), + "input_tokens": max( + (usage.get("prompt_tokens", 0) or 0) - cached_tokens, 0 + ), "output_tokens": usage.get("completion_tokens", 0) or 0, **({"cache_read_input_tokens": cached_tokens} if cached_tokens else {}), }, @@ -83,6 +95,8 @@ def _map_stop_reason(reason: str | None) -> str | None: } mapped = mapping.get(reason) if mapped is None: - logger.debug("copilot: unknown finish_reason '%s', defaulting to end_turn", reason) + logger.debug( + "copilot: unknown finish_reason '%s', defaulting to end_turn", reason + ) return "end_turn" return mapped diff --git a/src/coding/proxy/logging/__init__.py b/src/coding/proxy/logging/__init__.py index f3eb2d9..d89259a 100644 --- a/src/coding/proxy/logging/__init__.py +++ b/src/coding/proxy/logging/__init__.py @@ -1,4 +1,5 @@ """日志模块.""" + from __future__ import annotations diff --git a/src/coding/proxy/logging/db.py b/src/coding/proxy/logging/db.py index 64ced87..76cf0ff 100644 --- a/src/coding/proxy/logging/db.py +++ b/src/coding/proxy/logging/db.py @@ -3,12 +3,12 @@ from __future__ import annotations import logging -from datetime import datetime, timedelta, timezone - -import aiosqlite +from datetime import UTC, datetime, timedelta from pathlib import Path from zoneinfo import ZoneInfo +import aiosqlite + logger = logging.getLogger(__name__) @@ -18,7 +18,7 @@ def _local_tz() -> ZoneInfo: return datetime.now().astimezone().tzinfo # type: ignore[return-value] except Exception: logger.warning("无法获取系统本地时区,降级使用 UTC") - return timezone.utc + return UTC def _days_start_utc_iso(days: int) -> str: @@ -31,12 +31,12 @@ def _days_start_utc_iso(days: int) -> str: tz = _local_tz() start_date = datetime.now(tz).date() - timedelta(days=max(1, days) - 1) start_dt = datetime(start_date.year, start_date.month, start_date.day, tzinfo=tz) - return start_dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%f+00:00") + return start_dt.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%f+00:00") def _hours_ago_utc_iso(hours: float) -> str: """计算 hours 小时前的 UTC ISO 字符串(用于滚动窗口).""" - cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) + cutoff = datetime.now(UTC) - timedelta(hours=hours) return cutoff.strftime("%Y-%m-%dT%H:%M:%f+00:00") @@ -51,9 +51,11 @@ def _local_date_udf(ts_str: str) -> str: """ try: tz = _local_tz() - return datetime.fromisoformat( - ts_str.replace("Z", "+00:00") - ).astimezone(tz).strftime("%Y-%m-%d") + return ( + datetime.fromisoformat(ts_str.replace("Z", "+00:00")) + .astimezone(tz) + .strftime("%Y-%m-%d") + ) except (ValueError, TypeError, AttributeError): # 非 ISO 格式(如旧数据 'now')降级为字符串截取前 10 位 if isinstance(ts_str, str) and len(ts_str) >= 10: @@ -142,15 +144,28 @@ async def _migrate_rename_backend_to_vendor(self) -> None: cursor = await self._db.execute(f"PRAGMA table_info({table})") columns = {row["name"] for row in await cursor.fetchall()} if "backend" in columns and "vendor" not in columns: - await self._db.execute(f"ALTER TABLE {table} RENAME COLUMN backend TO vendor") - logger.info("Migration: renamed 'backend' column to 'vendor' in %s", table) - - async def log(self, vendor: str, model_requested: str, model_served: str, - input_tokens: int = 0, output_tokens: int = 0, - cache_creation_tokens: int = 0, cache_read_tokens: int = 0, - duration_ms: int = 0, success: bool = True, - failover: bool = False, failover_from: str | None = None, - request_id: str = "") -> None: + await self._db.execute( + f"ALTER TABLE {table} RENAME COLUMN backend TO vendor" + ) + logger.info( + "Migration: renamed 'backend' column to 'vendor' in %s", table + ) + + async def log( + self, + vendor: str, + model_requested: str, + model_served: str, + input_tokens: int = 0, + output_tokens: int = 0, + cache_creation_tokens: int = 0, + cache_read_tokens: int = 0, + duration_ms: int = 0, + success: bool = True, + failover: bool = False, + failover_from: str | None = None, + request_id: str = "", + ) -> None: if not self._db: return await self._db.execute( @@ -160,10 +175,21 @@ async def log(self, vendor: str, model_requested: str, model_served: str, cache_creation_tokens, cache_read_tokens, duration_ms, success, failover, failover_from, request_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - (vendor, model_requested, model_served, - input_tokens, output_tokens, - cache_creation_tokens, cache_read_tokens, - duration_ms, success, failover, failover_from, request_id)) + ( + vendor, + model_requested, + model_served, + input_tokens, + output_tokens, + cache_creation_tokens, + cache_read_tokens, + duration_ms, + success, + failover, + failover_from, + request_id, + ), + ) await self._db.commit() async def log_evidence( @@ -222,8 +248,9 @@ async def query_evidence(self, request_id: str) -> list[dict]: rows = await cursor.fetchall() return [dict(row) for row in rows] - async def query_daily(self, days: int = 7, vendor: str | None = None, - model: str | None = None) -> list[dict]: + async def query_daily( + self, days: int = 7, vendor: str | None = None, model: str | None = None + ) -> list[dict]: if not self._db: return [] days = max(1, days) @@ -244,13 +271,17 @@ async def query_daily(self, days: int = 7, vendor: str | None = None, if model: sql += " AND model_requested = ?" params.append(model) - sql += (" GROUP BY local_date(ts), vendor, model_requested, model_served" - " ORDER BY local_date(ts) DESC, vendor, model_requested, model_served") + sql += ( + " GROUP BY local_date(ts), vendor, model_requested, model_served" + " ORDER BY local_date(ts) DESC, vendor, model_requested, model_served" + ) cursor = await self._db.execute(sql, params) rows = await cursor.fetchall() return [dict(row) for row in rows] - async def query_failover_stats(self, days: int = 7, include_model_info: bool = False) -> list[dict]: + async def query_failover_stats( + self, days: int = 7, include_model_info: bool = False + ) -> list[dict]: """ 按 failover_from → vendor 聚合故障转移次数. @@ -286,7 +317,9 @@ async def query_failover_stats(self, days: int = 7, include_model_info: bool = F return [dict(row) for row in rows] async def query_window_total( - self, window_hours: float, vendor: str = "anthropic", + self, + window_hours: float, + vendor: str = "anthropic", ) -> int: """查询滚动时间窗口内指定供应商的 token 总用量.""" if not self._db: diff --git a/src/coding/proxy/logging/stats.py b/src/coding/proxy/logging/stats.py index 930d16a..53724c1 100644 --- a/src/coding/proxy/logging/stats.py +++ b/src/coding/proxy/logging/stats.py @@ -42,11 +42,7 @@ def _detect_model_variants(failover_stats: list[dict]) -> bool: for stat in failover_stats } # 检查是否存在模型映射(请求模型与实际模型不同) - return any( - pair[0] != pair[1] - for pair in model_pairs - if pair[0] and pair[1] - ) + return any(pair[0] != pair[1] for pair in model_pairs if pair[0] and pair[1]) async def show_usage( @@ -54,7 +50,7 @@ async def show_usage( days: int = 7, vendor: str | None = None, model: str | None = None, - pricing_table: "PricingTable | None" = None, + pricing_table: PricingTable | None = None, ) -> None: """展示 Token 使用统计.""" console = Console() @@ -83,14 +79,20 @@ async def show_usage( total_output = row.get("total_output", 0) or 0 total_cache_creation = row.get("total_cache_creation", 0) or 0 total_cache_read = row.get("total_cache_read", 0) or 0 - total_tokens = total_input + total_output + total_cache_creation + total_cache_read + total_tokens = ( + total_input + total_output + total_cache_creation + total_cache_read + ) vendor_name = str(row.get("vendor", "")) model_served = str(row.get("model_served", "")) if pricing_table is not None: cost_value = pricing_table.compute_cost( - vendor_name, model_served, - total_input, total_output, total_cache_creation, total_cache_read, + vendor_name, + model_served, + total_input, + total_output, + total_cache_creation, + total_cache_read, ) cost_str = cost_value.format() if cost_value is not None else "-" else: diff --git a/src/coding/proxy/model/__init__.py b/src/coding/proxy/model/__init__.py index 44b6ad6..4921e60 100644 --- a/src/coding/proxy/model/__init__.py +++ b/src/coding/proxy/model/__init__.py @@ -16,8 +16,6 @@ # ── 供应商核心类型 ────────────────────────────────────────── from .vendor import ( # noqa: F401 - VendorCapabilities, - VendorResponse, CapabilityLossReason, CopilotExchangeDiagnostics, CopilotMisdirectedRequest, @@ -25,6 +23,8 @@ NoCompatibleVendorError, RequestCapabilities, UsageInfo, + VendorCapabilities, + VendorResponse, decode_json_body, extract_error_message, sanitize_headers_for_synthetic_response, @@ -36,21 +36,29 @@ NoCompatibleBackendError = NoCompatibleVendorError # noqa: F401 # ── 兼容层抽象类型 ──────────────────────────────────────── +# ── 认证凭证模型 ────────────────────────────────────────── +from .auth import ProviderTokens # noqa: F401 from .compat import ( # noqa: F401 CanonicalMessagePart, CanonicalPartType, CanonicalRequest, CanonicalThinking, CanonicalToolCall, - CompatSessionRecord, CompatibilityDecision, CompatibilityProfile, CompatibilityStatus, CompatibilityTrace, + CompatSessionRecord, ) -# ── 认证凭证模型 ────────────────────────────────────────── -from .auth import ProviderTokens # noqa: F401 +# ── 共享常量 ──────────────────────────────────────────────── +from .constants import ( # noqa: F401 + PROXY_SKIP_HEADERS, + RESPONSE_SANITIZE_SKIP_HEADERS, +) + +# ── 定价模型 ──────────────────────────────────────────────── +from .pricing import CostValue, Currency, ModelPricing # noqa: F401 # ── Token 管理类型 ────────────────────────────────────────── from .token import ( # noqa: F401 @@ -59,35 +67,47 @@ TokenManagerDiagnostics, ) -# ── 定价模型 ──────────────────────────────────────────────── -from .pricing import CostValue, Currency, ModelPricing # noqa: F401 - -# ── 共享常量 ──────────────────────────────────────────────── -from .constants import ( # noqa: F401 - PROXY_SKIP_HEADERS, - RESPONSE_SANITIZE_SKIP_HEADERS, -) - __all__ = [ # vendor(新命名) - "VendorCapabilities", "VendorResponse", "NoCompatibleVendorError", + "VendorCapabilities", + "VendorResponse", + "NoCompatibleVendorError", # 向后兼容别名 - "BackendCapabilities", "BackendResponse", "NoCompatibleBackendError", + "BackendCapabilities", + "BackendResponse", + "NoCompatibleBackendError", # 通用类型 - "CapabilityLossReason", "CopilotExchangeDiagnostics", - "CopilotMisdirectedRequest", "CopilotModelCatalog", - "RequestCapabilities", "UsageInfo", - "decode_json_body", "extract_error_message", "sanitize_headers_for_synthetic_response", + "CapabilityLossReason", + "CopilotExchangeDiagnostics", + "CopilotMisdirectedRequest", + "CopilotModelCatalog", + "RequestCapabilities", + "UsageInfo", + "decode_json_body", + "extract_error_message", + "sanitize_headers_for_synthetic_response", # compat - "CanonicalMessagePart", "CanonicalPartType", "CanonicalRequest", - "CanonicalThinking", "CanonicalToolCall", "CompatSessionRecord", - "CompatibilityDecision", "CompatibilityProfile", "CompatibilityStatus", "CompatibilityTrace", + "CanonicalMessagePart", + "CanonicalPartType", + "CanonicalRequest", + "CanonicalThinking", + "CanonicalToolCall", + "CompatSessionRecord", + "CompatibilityDecision", + "CompatibilityProfile", + "CompatibilityStatus", + "CompatibilityTrace", # auth "ProviderTokens", # token - "TokenAcquireError", "TokenErrorKind", "TokenManagerDiagnostics", + "TokenAcquireError", + "TokenErrorKind", + "TokenManagerDiagnostics", # pricing - "CostValue", "Currency", "ModelPricing", + "CostValue", + "Currency", + "ModelPricing", # constants - "PROXY_SKIP_HEADERS", "RESPONSE_SANITIZE_SKIP_HEADERS", + "PROXY_SKIP_HEADERS", + "RESPONSE_SANITIZE_SKIP_HEADERS", ] diff --git a/src/coding/proxy/model/compat.py b/src/coding/proxy/model/compat.py index 6bb59e1..d208b6f 100644 --- a/src/coding/proxy/model/compat.py +++ b/src/coding/proxy/model/compat.py @@ -9,16 +9,15 @@ from __future__ import annotations from dataclasses import dataclass, field -from enum import Enum +from enum import StrEnum from typing import Any - # ═══════════════════════════════════════════════════════════════ # 消息部分类型体系 # ═══════════════════════════════════════════════════════════════ -class CanonicalPartType(str, Enum): +class CanonicalPartType(StrEnum): """规范消息部分的类型枚举.""" TEXT = "text" @@ -82,7 +81,7 @@ class CanonicalRequest: # ═══════════════════════════════════════════════════════════════ -class CompatibilityStatus(str, Enum): +class CompatibilityStatus(StrEnum): """供应商对某语义特性的兼容状态.""" NATIVE = "native" @@ -127,7 +126,9 @@ class CompatibilityTrace: unsupported_semantics: list[str] = field(default_factory=list) session_state_hits: int = 0 request_adaptations: list[str] = field(default_factory=list) - generated_at_unix: int = field(default_factory=lambda: int(__import__("time").time())) + generated_at_unix: int = field( + default_factory=lambda: int(__import__("time").time()) + ) def to_dict(self) -> dict[str, Any]: from dataclasses import asdict diff --git a/src/coding/proxy/model/constants.py b/src/coding/proxy/model/constants.py index 6b7549c..2f5802b 100644 --- a/src/coding/proxy/model/constants.py +++ b/src/coding/proxy/model/constants.py @@ -3,14 +3,23 @@ # ── 代理转发头过滤规则 ───────────────────────────────────── # 代理转发时应跳过的 hop-by-hop 请求头 -PROXY_SKIP_HEADERS: frozenset[str] = frozenset({ - "host", "content-length", "transfer-encoding", "connection", -}) +PROXY_SKIP_HEADERS: frozenset[str] = frozenset( + { + "host", + "content-length", + "transfer-encoding", + "connection", + } +) # 构造合成 Response 时需移除的头部(避免 httpx 二次解压已解压内容) -RESPONSE_SANITIZE_SKIP_HEADERS: frozenset[str] = frozenset({ - "content-encoding", "content-length", "transfer-encoding", -}) +RESPONSE_SANITIZE_SKIP_HEADERS: frozenset[str] = frozenset( + { + "content-encoding", + "content-length", + "transfer-encoding", + } +) # ── Copilot URL / 版本常量 ───────────────────────────────── diff --git a/src/coding/proxy/model/pricing.py b/src/coding/proxy/model/pricing.py index 5964b4b..72ed35b 100644 --- a/src/coding/proxy/model/pricing.py +++ b/src/coding/proxy/model/pricing.py @@ -25,10 +25,10 @@ def symbol(self) -> str: if self is Currency.USD: return "$" # CNY 及未来扩展币种 - return "\u00a5" # ¥ (U+00A5) + return "\u00a5" # ¥ (U+00A5) @classmethod - def default(cls) -> "Currency": + def default(cls) -> Currency: """默认币种(向后兼容:无前缀视为 USD).""" return cls.USD diff --git a/src/coding/proxy/model/token.py b/src/coding/proxy/model/token.py index ec1603a..930e53d 100644 --- a/src/coding/proxy/model/token.py +++ b/src/coding/proxy/model/token.py @@ -38,7 +38,7 @@ def with_kind( *, kind: TokenErrorKind, needs_reauth: bool = False, - ) -> "TokenAcquireError": + ) -> TokenAcquireError: err = cls(message, needs_reauth=needs_reauth) err.kind = kind return err diff --git a/src/coding/proxy/model/vendor.py b/src/coding/proxy/model/vendor.py index 899b5c2..dd5ef4f 100644 --- a/src/coding/proxy/model/vendor.py +++ b/src/coding/proxy/model/vendor.py @@ -18,8 +18,7 @@ import httpx -from .constants import PROXY_SKIP_HEADERS, RESPONSE_SANITIZE_SKIP_HEADERS - +from .constants import RESPONSE_SANITIZE_SKIP_HEADERS # ═══════════════════════════════════════════════════════════════ # 工具函数(公开 API,去除原 _ 前缀) @@ -28,7 +27,11 @@ def sanitize_headers_for_synthetic_response(headers: httpx.Headers) -> dict[str, str]: """移除 content-encoding 等头部,避免合成 httpx.Response 时触发二次解压.""" - return {k: v for k, v in headers.items() if k.lower() not in RESPONSE_SANITIZE_SKIP_HEADERS} + return { + k: v + for k, v in headers.items() + if k.lower() not in RESPONSE_SANITIZE_SKIP_HEADERS + } def decode_json_body(response: httpx.Response) -> dict[str, Any] | list[Any] | None: @@ -52,7 +55,9 @@ def decode_json_body(response: httpx.Response) -> dict[str, Any] | list[Any] | N return None -def extract_error_message(response: httpx.Response, resp_body: dict[str, Any] | list[Any] | None) -> str | None: +def extract_error_message( + response: httpx.Response, resp_body: dict[str, Any] | list[Any] | None +) -> str | None: """从 HTTP 响应中提取可读错误消息.""" if isinstance(resp_body, dict): error = resp_body.get("error") @@ -175,7 +180,9 @@ def to_dict(self) -> dict[str, Any]: if self.expires_in_seconds: data["expires_in_seconds"] = self.expires_in_seconds if self.expires_at_unix: - data["ttl_seconds"] = max(self.expires_at_unix - int(__import__("time").time()), 0) + data["ttl_seconds"] = max( + self.expires_at_unix - int(__import__("time").time()), 0 + ) if self.capabilities: data["capabilities"] = self.capabilities if self.updated_at_unix: @@ -206,13 +213,23 @@ def age_seconds(self) -> int | None: __all__ = [ # 新命名 - "VendorCapabilities", "VendorResponse", "NoCompatibleVendorError", + "VendorCapabilities", + "VendorResponse", + "NoCompatibleVendorError", # 向后兼容别名 - "BackendCapabilities", "BackendResponse", "NoCompatibleBackendError", + "BackendCapabilities", + "BackendResponse", + "NoCompatibleBackendError", # 通用类型(不变) - "UsageInfo", "CapabilityLossReason", "RequestCapabilities", + "UsageInfo", + "CapabilityLossReason", + "RequestCapabilities", # Copilot 诊断类 - "CopilotExchangeDiagnostics", "CopilotMisdirectedRequest", "CopilotModelCatalog", + "CopilotExchangeDiagnostics", + "CopilotMisdirectedRequest", + "CopilotModelCatalog", # 工具函数 - "decode_json_body", "extract_error_message", "sanitize_headers_for_synthetic_response", + "decode_json_body", + "extract_error_message", + "sanitize_headers_for_synthetic_response", ] diff --git a/src/coding/proxy/routing/__init__.py b/src/coding/proxy/routing/__init__.py index 7c8efda..2127339 100644 --- a/src/coding/proxy/routing/__init__.py +++ b/src/coding/proxy/routing/__init__.py @@ -9,8 +9,8 @@ from .executor import _RouteExecutor from .model_mapper import ModelMapper from .quota_guard import QuotaGuard, QuotaState -from .rate_limit import RateLimitInfo from .rate_limit import ( + RateLimitInfo, compute_effective_retry_seconds, compute_rate_limit_deadline, parse_rate_limit_headers, @@ -28,20 +28,31 @@ __all__ = [ # Core routing - "CircuitBreaker", "CircuitState", - "ModelMapper", "RequestRouter", "BackendTier", + "CircuitBreaker", + "CircuitState", + "ModelMapper", + "RequestRouter", + "BackendTier", # Decomposed components (internal use) - "_RouteExecutor", "UsageRecorder", "RouteSessionManager", + "_RouteExecutor", + "UsageRecorder", + "RouteSessionManager", # Resiliency - "QuotaGuard", "QuotaState", "RateLimitInfo", + "QuotaGuard", + "QuotaState", + "RateLimitInfo", "RetryConfig", - "parse_rate_limit_headers", "compute_effective_retry_seconds", + "parse_rate_limit_headers", + "compute_effective_retry_seconds", "compute_rate_limit_deadline", - "is_retryable_error", "calculate_delay", + "is_retryable_error", + "calculate_delay", # Error classification - "build_request_capabilities", "is_semantic_rejection", + "build_request_capabilities", + "is_semantic_rejection", "extract_error_payload_from_http_status", # Usage parsing - "build_usage_evidence_records", "has_missing_input_usage_signals", + "build_usage_evidence_records", + "has_missing_input_usage_signals", "parse_usage_from_chunk", ] diff --git a/src/coding/proxy/routing/circuit_breaker.py b/src/coding/proxy/routing/circuit_breaker.py index 5dbed29..eedc24e 100644 --- a/src/coding/proxy/routing/circuit_breaker.py +++ b/src/coding/proxy/routing/circuit_breaker.py @@ -11,9 +11,9 @@ class CircuitState(Enum): - CLOSED = "closed" # 正常:使用主后端 - OPEN = "open" # 故障:使用备选后端 - HALF_OPEN = "half_open" # 试探:测试主后端是否恢复 + CLOSED = "closed" # 正常:使用主后端 + OPEN = "open" # 故障:使用备选后端 + HALF_OPEN = "half_open" # 试探:测试主后端是否恢复 class CircuitBreaker: @@ -92,9 +92,13 @@ def record_failure(self, retry_after_seconds: float | None = None) -> None: elif self._state == CircuitState.CLOSED: if self._failure_count >= self._failure_threshold: self._transition_to(CircuitState.OPEN) - if retry_after_seconds and retry_after_seconds > self._current_recovery: + if ( + retry_after_seconds + and retry_after_seconds > self._current_recovery + ): self._current_recovery = min( - retry_after_seconds, self._max_recovery, + retry_after_seconds, + self._max_recovery, ) logger.warning( "Circuit breaker: CLOSED → OPEN (%d consecutive failures, next retry in %ds)", @@ -133,7 +137,6 @@ def _check_recovery(self) -> None: logger.info("Circuit breaker: OPEN → HALF_OPEN (recovery timeout)") def _transition_to(self, new_state: CircuitState) -> None: - old = self._state self._state = new_state if new_state == CircuitState.CLOSED: self._failure_count = 0 diff --git a/src/coding/proxy/routing/error_classifier.py b/src/coding/proxy/routing/error_classifier.py index 8bddbf6..c125f1e 100644 --- a/src/coding/proxy/routing/error_classifier.py +++ b/src/coding/proxy/routing/error_classifier.py @@ -10,7 +10,9 @@ from ..vendors.base import RequestCapabilities -def extract_error_payload_from_http_status(exc: httpx.HTTPStatusError) -> dict[str, Any] | None: +def extract_error_payload_from_http_status( + exc: httpx.HTTPStatusError, +) -> dict[str, Any] | None: response = exc.response if response is None or not response.content: return None diff --git a/src/coding/proxy/routing/executor.py b/src/coding/proxy/routing/executor.py index 6f39389..1566f9d 100644 --- a/src/coding/proxy/routing/executor.py +++ b/src/coding/proxy/routing/executor.py @@ -6,13 +6,19 @@ from __future__ import annotations -import json import logging import time -from typing import Any, AsyncIterator +from collections.abc import AsyncIterator +from typing import Any import httpx +from ..vendors.base import ( + NoCompatibleVendorError, + RequestCapabilities, + VendorResponse, +) +from ..vendors.token_manager import TokenAcquireError from .error_classifier import ( build_request_capabilities, extract_error_payload_from_http_status, @@ -31,8 +37,6 @@ parse_usage_from_chunk, ) from .usage_recorder import UsageRecorder -from ..vendors.base import VendorResponse, NoCompatibleVendorError, RequestCapabilities, UsageInfo -from ..vendors.token_manager import TokenAcquireError # 向后兼容别名 BackendResponse = VendorResponse @@ -42,7 +46,9 @@ logger = logging.getLogger(__name__) -def _log_http_error_detail(tier_name: str, exc: Exception, *, is_stream: bool = False) -> None: +def _log_http_error_detail( + tier_name: str, exc: Exception, *, is_stream: bool = False +) -> None: """记录 HTTP 错误的详细信息(状态码 / 响应体摘要 / 异常类型). 替代原先单行 ``logger.warning("Tier %s stream failed: %s", ...)``, @@ -53,7 +59,11 @@ def _log_http_error_detail(tier_name: str, exc: Exception, *, is_stream: bool = if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None: resp = exc.response detail_parts.append(f" status={resp.status_code}") - body_preview = (resp.text[:300] if resp.text else "(empty)") if resp.content else "(no content)" + body_preview = ( + (resp.text[:300] if resp.text else "(empty)") + if resp.content + else "(no content)" + ) detail_parts.append(f" response_body={body_preview}") # 尝试提取 error type / message try: @@ -122,6 +132,7 @@ def _log_vendor_response_error( detail_parts.append(f" response_body_preview={raw_text}") logger.warning("\n".join(detail_parts)) + # tier.name → 上游 Vendor 协议标签映射(用于 token 用量日志标注) _VENDOR_PROTOCOL_LABEL_MAP: dict[str, str] = { "anthropic": "Anthropic", @@ -173,14 +184,22 @@ async def execute_stream( request_caps = build_request_capabilities(body) canonical_request = build_canonical_request(body, headers) session_record = await self._session_mgr.get_or_create_record( - canonical_request.session_key, canonical_request.trace_id, + canonical_request.session_key, + canonical_request.trace_id, ) incompatible_reasons: list[str] = [] for i, tier in enumerate(self._tiers): is_last = i == last_idx - gate = await self._try_gate_tier(tier, is_last, request_caps, canonical_request, session_record, incompatible_reasons) + gate = await self._try_gate_tier( + tier, + is_last, + request_caps, + canonical_request, + session_record, + incompatible_reasons, + ) if gate == "skip": continue @@ -190,7 +209,8 @@ async def execute_stream( try: async for chunk in tier.vendor.send_message_stream(body, headers): parse_usage_from_chunk( - chunk, usage, + chunk, + usage, vendor_label=_VENDOR_PROTOCOL_LABEL_MAP.get(tier.name), ) yield chunk, tier.name @@ -210,23 +230,55 @@ async def execute_stream( duration = int((time.monotonic() - start) * 1000) model = body.get("model", "unknown") model_served = usage.get("model_served") or tier.vendor.map_model(model) - self._recorder.log_model_call(vendor=tier.name, model_requested=model, model_served=model_served, duration_ms=duration, usage=info) - await self._session_mgr.persist_session(tier.vendor.get_compat_trace(), session_record) + self._recorder.log_model_call( + vendor=tier.name, + model_requested=model, + model_served=model_served, + duration_ms=duration, + usage=info, + ) + await self._session_mgr.persist_session( + tier.vendor.get_compat_trace(), session_record + ) await self._recorder.record( - tier.name, model, model_served, info, duration, True, - failed_tier_name is not None, failed_tier_name, - evidence_records=build_usage_evidence_records(usage, vendor=tier.name, model_served=model_served, request_id=info.request_id), + tier.name, + model, + model_served, + info, + duration, + True, + failed_tier_name is not None, + failed_tier_name, + evidence_records=build_usage_evidence_records( + usage, + vendor=tier.name, + model_served=model_served, + request_id=info.request_id, + ), ) return except TokenAcquireError as exc: - failed_tier_name, last_exc = await self._handle_token_error(tier, exc, is_last, failed_tier_name) + failed_tier_name, last_exc = await self._handle_token_error( + tier, exc, is_last, failed_tier_name + ) if is_last and last_exc is exc: raise - except (httpx.HTTPStatusError, httpx.TimeoutException, httpx.ConnectError, httpx.ReadError) as exc: + except ( + httpx.HTTPStatusError, + httpx.TimeoutException, + httpx.ConnectError, + httpx.ReadError, + ) as exc: _log_http_error_detail(tier.name, exc, is_stream=True) - should_continue, failed_tier_name, last_exc = await self._handle_http_error(tier, exc, is_last, failed_tier_name, last_exc, is_stream=True) + ( + should_continue, + failed_tier_name, + last_exc, + ) = await self._handle_http_error( + tier, exc, is_last, failed_tier_name, last_exc, is_stream=True + ) if should_continue: continue if is_last: @@ -234,7 +286,9 @@ async def execute_stream( except Exception as exc: logger.error( "Tier %s stream unexpected error: %s: %s", - tier.name, type(exc).__name__, exc, + tier.name, + type(exc).__name__, + exc, exc_info=True, ) tier.record_failure() @@ -245,7 +299,10 @@ async def execute_stream( if last_exc: raise last_exc - raise NoCompatibleVendorError("当前请求包含仅客户端/MCP 可安全承接的能力,未找到兼容供应商", reasons=incompatible_reasons) + raise NoCompatibleVendorError( + "当前请求包含仅客户端/MCP 可安全承接的能力,未找到兼容供应商", + reasons=incompatible_reasons, + ) async def execute_message( self, @@ -259,14 +316,22 @@ async def execute_message( request_caps = build_request_capabilities(body) canonical_request = build_canonical_request(body, headers) session_record = await self._session_mgr.get_or_create_record( - canonical_request.session_key, canonical_request.trace_id, + canonical_request.session_key, + canonical_request.trace_id, ) incompatible_reasons: list[str] = [] for i, tier in enumerate(self._tiers): is_last = i == last_idx - gate = await self._try_gate_tier(tier, is_last, request_caps, canonical_request, session_record, incompatible_reasons) + gate = await self._try_gate_tier( + tier, + is_last, + request_caps, + canonical_request, + session_record, + incompatible_reasons, + ) if gate == "skip": continue @@ -277,24 +342,57 @@ async def execute_message( duration = int((time.monotonic() - start) * 1000) model = body.get("model", "unknown") model_served = resp.model_served or tier.vendor.map_model(model) - self._recorder.log_model_call(vendor=tier.name, model_requested=model, model_served=model_served, duration_ms=duration, usage=resp.usage) - await self._session_mgr.persist_session(tier.vendor.get_compat_trace(), session_record) + self._recorder.log_model_call( + vendor=tier.name, + model_requested=model, + model_served=model_served, + duration_ms=duration, + usage=resp.usage, + ) + await self._session_mgr.persist_session( + tier.vendor.get_compat_trace(), session_record + ) await self._recorder.record( - tier.name, model, model_served, resp.usage, duration, True, - failed_tier_name is not None, failed_tier_name, - evidence_records=self._recorder.build_nonstream_evidence_records(vendor=tier.name, model_served=model_served, usage=resp.usage), + tier.name, + model, + model_served, + resp.usage, + duration, + True, + failed_tier_name is not None, + failed_tier_name, + evidence_records=self._recorder.build_nonstream_evidence_records( + vendor=tier.name, + model_served=model_served, + usage=resp.usage, + ), ) return resp # 非流式的 semantic rejection 和 failover 判断(从响应对象而非异常中提取) - if not is_last and is_semantic_rejection(status_code=resp.status_code, error_type=resp.error_type, error_message=resp.error_message): - logger.warning("Tier %s semantic rejection (%s), trying next tier without recording failure", tier.name, resp.error_type or resp.status_code) + if not is_last and is_semantic_rejection( + status_code=resp.status_code, + error_type=resp.error_type, + error_message=resp.error_message, + ): + logger.warning( + "Tier %s semantic rejection (%s), trying next tier without recording failure", + tier.name, + resp.error_type or resp.status_code, + ) failed_tier_name = tier.name continue - if not is_last and tier.vendor.should_trigger_failover(resp.status_code, {"error": {"type": resp.error_type, "message": resp.error_message}}): - logger.warning("Tier %s error %d, failing over", tier.name, resp.status_code) - rl_info = parse_rate_limit_headers(resp.response_headers, resp.status_code, resp.error_message) + if not is_last and tier.vendor.should_trigger_failover( + resp.status_code, + {"error": {"type": resp.error_type, "message": resp.error_message}}, + ): + logger.warning( + "Tier %s error %d, failing over", tier.name, resp.status_code + ) + rl_info = parse_rate_limit_headers( + resp.response_headers, resp.status_code, resp.error_message + ) tier.record_failure( is_cap_error=self._is_cap_error(resp) or rl_info.is_cap_error, retry_after_seconds=compute_effective_retry_seconds(rl_info), @@ -308,16 +406,32 @@ async def execute_message( duration = int((time.monotonic() - start) * 1000) model = body.get("model", "unknown") model_served = resp.model_served or tier.vendor.map_model(model) - self._recorder.log_model_call(vendor=tier.name, model_requested=model, model_served=model_served, duration_ms=duration, usage=resp.usage) + self._recorder.log_model_call( + vendor=tier.name, + model_requested=model, + model_served=model_served, + duration_ms=duration, + usage=resp.usage, + ) await self._recorder.record( - tier.name, model, model_served, resp.usage, duration, resp.status_code < 400, - failed_tier_name is not None, failed_tier_name, - evidence_records=self._recorder.build_nonstream_evidence_records(vendor=tier.name, model_served=model_served, usage=resp.usage), + tier.name, + model, + model_served, + resp.usage, + duration, + resp.status_code < 400, + failed_tier_name is not None, + failed_tier_name, + evidence_records=self._recorder.build_nonstream_evidence_records( + vendor=tier.name, model_served=model_served, usage=resp.usage + ), ) return resp except TokenAcquireError as exc: - failed_tier_name, last_exc = await self._handle_token_error(tier, exc, is_last, failed_tier_name) + failed_tier_name, last_exc = await self._handle_token_error( + tier, exc, is_last, failed_tier_name + ) if is_last: raise continue @@ -332,7 +446,9 @@ async def execute_message( except Exception as exc: logger.error( "Tier %s message unexpected error: %s: %s", - tier.name, type(exc).__name__, exc, + tier.name, + type(exc).__name__, + exc, exc_info=True, ) tier.record_failure() @@ -342,7 +458,10 @@ async def execute_message( raise if incompatible_reasons: - raise NoCompatibleVendorError("当前请求包含仅客户端/MCP 可安全承接的能力,未找到兼容供应商", reasons=incompatible_reasons) + raise NoCompatibleVendorError( + "当前请求包含仅客户端/MCP 可安全承接的能力,未找到兼容供应商", + reasons=incompatible_reasons, + ) raise RuntimeError("无可用供应商层级") # ── 门控与错误处理 ────────────────────────────────────── @@ -366,18 +485,29 @@ async def _try_gate_tier( if not supported: reason_text = ",".join(sorted({r.value for r in reasons})) incompatible_reasons.append(f"{tier.name}:{reason_text}") - logger.info("Tier %s skipped due to incompatible capabilities: %s", tier.name, reason_text) + logger.info( + "Tier %s skipped due to incompatible capabilities: %s", + tier.name, + reason_text, + ) return "skip" decision = tier.vendor.make_compatibility_decision(canonical_request) if decision.status is CompatibilityStatus.UNSAFE: reason_text = ",".join(sorted(decision.unsupported_semantics)) incompatible_reasons.append(f"{tier.name}:{reason_text}") - logger.info("Tier %s skipped due to compatibility decision: %s", tier.name, reason_text) + logger.info( + "Tier %s skipped due to compatibility decision: %s", + tier.name, + reason_text, + ) return "skip" self._session_mgr.apply_compat_context( - tier=tier, canonical_request=canonical_request, decision=decision, session_record=session_record, + tier=tier, + canonical_request=canonical_request, + decision=decision, + session_record=session_record, ) # 非终端层使用健康检查门控;终端层仅检查 can_execute @@ -430,10 +560,17 @@ async def _handle_http_error( error_message=error.get("message") if isinstance(error, dict) else None, ) if semantic_rejection and not is_last: - logger.warning("Tier %s semantic rejection, trying next tier without recording failure", tier.name) + logger.warning( + "Tier %s semantic rejection, trying next tier without recording failure", + tier.name, + ) return True, tier.name, exc - rl_info = parse_rate_limit_headers(exc.response.headers, exc.response.status_code, exc.response.text[:500] if exc.response.text else None) + rl_info = parse_rate_limit_headers( + exc.response.headers, + exc.response.status_code, + exc.response.text[:500] if exc.response.text else None, + ) tier.record_failure( is_cap_error=rl_info.is_cap_error, retry_after_seconds=compute_effective_retry_seconds(rl_info), diff --git a/src/coding/proxy/routing/model_mapper.py b/src/coding/proxy/routing/model_mapper.py index 319c0ad..0896a29 100644 --- a/src/coding/proxy/routing/model_mapper.py +++ b/src/coding/proxy/routing/model_mapper.py @@ -42,7 +42,9 @@ def _rule_applies_to_vendor(self, rule: ModelMappingRule, vendor: str) -> bool: normalized = {self._normalize_vendor(name) for name in rule.vendors} return vendor in normalized - def map(self, model: str, vendor: str = "fallback", default: str | None = None) -> str: + def map( + self, model: str, vendor: str = "fallback", default: str | None = None + ) -> str: """将源模型名映射为目标模型名. 优先级:精确匹配 > 通配符/正则匹配 > default/_DEFAULT_TARGET。 @@ -57,7 +59,9 @@ def map(self, model: str, vendor: str = "fallback", default: str | None = None) if rule.pattern == model: logger.debug( "Model mapped: %s -> %s (vendor=%s exact)", - model, rule.target, display_name, + model, + rule.target, + display_name, ) return rule.target @@ -70,14 +74,20 @@ def map(self, model: str, vendor: str = "fallback", default: str | None = None) if compiled.fullmatch(model): logger.debug( "Model mapped: %s -> %s (vendor=%s regex=%s)", - model, rule.target, display_name, rule.pattern, + model, + rule.target, + display_name, + rule.pattern, ) return rule.target elif "*" in rule.pattern: if fnmatch.fnmatch(model, rule.pattern): logger.debug( "Model mapped: %s -> %s (vendor=%s glob=%s)", - model, rule.target, display_name, rule.pattern, + model, + rule.target, + display_name, + rule.pattern, ) return rule.target @@ -85,6 +95,8 @@ def map(self, model: str, vendor: str = "fallback", default: str | None = None) fallback_target = default or _DEFAULT_TARGET logger.debug( "Model unmapped: %s -> %s (vendor=%s default)", - model, fallback_target, display_name, + model, + fallback_target, + display_name, ) return fallback_target diff --git a/src/coding/proxy/routing/quota_guard.py b/src/coding/proxy/routing/quota_guard.py index ff6ea26..a7c680e 100644 --- a/src/coding/proxy/routing/quota_guard.py +++ b/src/coding/proxy/routing/quota_guard.py @@ -63,7 +63,9 @@ def can_use_primary(self) -> bool: with self._lock: self._expire() if self._state == QuotaState.WITHIN_QUOTA: - if self._budget > 0 and self._total >= int(self._budget * self._threshold): + if self._budget > 0 and self._total >= int( + self._budget * self._threshold + ): self._transition_to(QuotaState.QUOTA_EXCEEDED) logger.warning( "Quota guard: WITHIN_QUOTA → EXCEEDED (%.1f%%)", @@ -72,7 +74,11 @@ def can_use_primary(self) -> bool: return False return True # QUOTA_EXCEEDED — cap 错误触发时仅允许探测恢复,不做预算自动恢复 - if not self._cap_error_active and self._budget > 0 and self._total < int(self._budget * self._threshold): + if ( + not self._cap_error_active + and self._budget > 0 + and self._total < int(self._budget * self._threshold) + ): self._transition_to(QuotaState.WITHIN_QUOTA) logger.info("Quota guard: EXCEEDED → WITHIN_QUOTA (usage dropped)") return True @@ -149,7 +155,9 @@ def get_info(self) -> dict: "state": self._state.value, "window_usage_tokens": self._total, "budget_tokens": self._budget, - "usage_percent": round(self._total / self._budget * 100, 1) if self._budget > 0 else 0, + "usage_percent": round(self._total / self._budget * 100, 1) + if self._budget > 0 + else 0, "threshold_percent": self._threshold * 100, } diff --git a/src/coding/proxy/routing/rate_limit.py b/src/coding/proxy/routing/rate_limit.py index 37694c0..c91b62e 100644 --- a/src/coding/proxy/routing/rate_limit.py +++ b/src/coding/proxy/routing/rate_limit.py @@ -5,7 +5,7 @@ import logging import time from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from email.utils import parsedate_to_datetime from typing import Any @@ -140,7 +140,7 @@ def _parse_retry_after(value: str) -> float | None: pass try: dt = parsedate_to_datetime(value) - return max(0, (dt - datetime.now(timezone.utc)).total_seconds()) + return max(0, (dt - datetime.now(UTC)).total_seconds()) except (ValueError, TypeError): logger.warning("Cannot parse retry-after header: %s", value) return None @@ -151,8 +151,8 @@ def _parse_reset_time(value: str) -> float | None: try: dt = datetime.fromisoformat(value.replace("Z", "+00:00")) if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - remaining = (dt - datetime.now(timezone.utc)).total_seconds() + dt = dt.replace(tzinfo=UTC) + remaining = (dt - datetime.now(UTC)).total_seconds() return time.monotonic() + max(0, remaining) except (ValueError, TypeError): logger.warning("Cannot parse reset time: %s", value) diff --git a/src/coding/proxy/routing/retry.py b/src/coding/proxy/routing/retry.py index b606192..c407cc1 100644 --- a/src/coding/proxy/routing/retry.py +++ b/src/coding/proxy/routing/retry.py @@ -25,11 +25,11 @@ class RetryConfig: """传输层重试配置(运行时).""" - max_retries: int = 2 # 最大重试次数(0 = 禁用) - initial_delay_ms: int = 500 # 初始退避延迟(毫秒) - max_delay_ms: int = 5000 # 最大退避延迟(毫秒) - backoff_multiplier: float = 2.0 # 退避倍数 - jitter: bool = True # 是否添加随机抖动 + max_retries: int = 2 # 最大重试次数(0 = 禁用) + initial_delay_ms: int = 500 # 初始退避延迟(毫秒) + max_delay_ms: int = 5000 # 最大退避延迟(毫秒) + backoff_multiplier: float = 2.0 # 退避倍数 + jitter: bool = True # 是否添加随机抖动 @property def enabled(self) -> bool: @@ -73,7 +73,7 @@ def calculate_delay(attempt: int, cfg: RetryConfig) -> float: Full Jitter 策略: delay = random(0, min(initial * backoff^attempt, max)) 参考: AWS "Exponential Backoff And Jitter" (Marc Brooker, 2015) """ - delay = cfg.initial_delay_ms * (cfg.backoff_multiplier ** attempt) + delay = cfg.initial_delay_ms * (cfg.backoff_multiplier**attempt) delay = min(delay, cfg.max_delay_ms) if cfg.jitter: diff --git a/src/coding/proxy/routing/router.py b/src/coding/proxy/routing/router.py index 3b1e437..4e58a25 100644 --- a/src/coding/proxy/routing/router.py +++ b/src/coding/proxy/routing/router.py @@ -10,7 +10,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, AsyncIterator +from collections.abc import AsyncIterator +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from ..pricing import PricingTable @@ -21,9 +22,9 @@ # 向后兼容别名 BackendTier = VendorTier -from .usage_recorder import UsageRecorder from ..compat.session_store import CompatSessionStore from ..logging.db import TokenLogger +from .usage_recorder import UsageRecorder class RequestRouter: diff --git a/src/coding/proxy/routing/session_manager.py b/src/coding/proxy/routing/session_manager.py index 1e6abb3..0d90410 100644 --- a/src/coding/proxy/routing/session_manager.py +++ b/src/coding/proxy/routing/session_manager.py @@ -5,7 +5,6 @@ from typing import Any from ..compat.canonical import ( - CompatibilityStatus, CompatibilityTrace, ) from ..compat.session_store import CompatSessionRecord, CompatSessionStore @@ -18,7 +17,9 @@ class RouteSessionManager: def __init__(self, compat_session_store: CompatSessionStore | None = None) -> None: self._store = compat_session_store - async def get_or_create_record(self, session_key: str, trace_id: str) -> CompatSessionRecord | None: + async def get_or_create_record( + self, session_key: str, trace_id: str + ) -> CompatSessionRecord | None: if self._store is None: return None record = await self._store.get(session_key) @@ -41,21 +42,33 @@ def apply_compat_context( "anthropic": "anthropic_messages", }.get(tier.name, "unknown") compat_trace = CompatibilityTrace( - trace_id=canonical_request.trace_id, vendor=tier.name, - session_key=canonical_request.session_key, provider_protocol=provider_protocol, - compat_mode=decision.status.value, simulation_actions=list(decision.simulation_actions), + trace_id=canonical_request.trace_id, + vendor=tier.name, + session_key=canonical_request.session_key, + provider_protocol=provider_protocol, + compat_mode=decision.status.value, + simulation_actions=list(decision.simulation_actions), unsupported_semantics=list(decision.unsupported_semantics), - session_state_hits=1 if session_record else 0, request_adaptations=[], + session_state_hits=1 if session_record else 0, + request_adaptations=[], + ) + tier.vendor.set_compat_context( + trace=compat_trace, session_record=session_record ) - tier.vendor.set_compat_context(trace=compat_trace, session_record=session_record) - async def persist_session(self, trace: CompatibilityTrace | None, session_record: CompatSessionRecord | None) -> None: + async def persist_session( + self, + trace: CompatibilityTrace | None, + session_record: CompatSessionRecord | None, + ) -> None: if self._store is None or trace is None or session_record is None: return provider_states = dict(session_record.provider_state) provider_states[trace.vendor] = { - "compat_mode": trace.compat_mode, "simulation_actions": trace.simulation_actions, - "unsupported_semantics": trace.unsupported_semantics, "trace_id": trace.trace_id, + "compat_mode": trace.compat_mode, + "simulation_actions": trace.simulation_actions, + "unsupported_semantics": trace.unsupported_semantics, + "trace_id": trace.trace_id, } session_record.trace_id = trace.trace_id session_record.provider_state = provider_states diff --git a/src/coding/proxy/routing/tier.py b/src/coding/proxy/routing/tier.py index 13964f8..3d11ece 100644 --- a/src/coding/proxy/routing/tier.py +++ b/src/coding/proxy/routing/tier.py @@ -4,7 +4,6 @@ import logging import time - from dataclasses import dataclass, field from ..vendors.base import BaseVendor @@ -90,9 +89,14 @@ def record_failure( if self.quota_guard and is_cap_error: self.quota_guard.notify_cap_error(retry_after_seconds=retry_after_seconds) if self.weekly_quota_guard and is_cap_error: - self.weekly_quota_guard.notify_cap_error(retry_after_seconds=retry_after_seconds) + self.weekly_quota_guard.notify_cap_error( + retry_after_seconds=retry_after_seconds + ) - if rate_limit_deadline is not None and rate_limit_deadline > self._rate_limit_deadline: + if ( + rate_limit_deadline is not None + and rate_limit_deadline > self._rate_limit_deadline + ): self._rate_limit_deadline = rate_limit_deadline logger.info( "Tier %s: rate limit deadline updated, %.1fs remaining", @@ -120,7 +124,11 @@ async def can_execute_with_health_check(self) -> bool: cb_allows = self.circuit_breaker.can_execute() if self.circuit_breaker else True qg_allows = self.quota_guard.can_use_primary() if self.quota_guard else True - wqg_allows = self.weekly_quota_guard.can_use_primary() if self.weekly_quota_guard else True + wqg_allows = ( + self.weekly_quota_guard.can_use_primary() + if self.weekly_quota_guard + else True + ) if not cb_allows and not qg_allows and not wqg_allows: return False @@ -135,7 +143,10 @@ async def can_execute_with_health_check(self) -> bool: if self.quota_guard._state == QuotaState.QUOTA_EXCEEDED and qg_allows: is_probe_scenario = True if self.weekly_quota_guard: - if self.weekly_quota_guard._state == QuotaState.QUOTA_EXCEEDED and wqg_allows: + if ( + self.weekly_quota_guard._state == QuotaState.QUOTA_EXCEEDED + and wqg_allows + ): is_probe_scenario = True if not is_probe_scenario: diff --git a/src/coding/proxy/routing/usage_parser.py b/src/coding/proxy/routing/usage_parser.py index 8d2bc7a..0e643df 100644 --- a/src/coding/proxy/routing/usage_parser.py +++ b/src/coding/proxy/routing/usage_parser.py @@ -35,39 +35,59 @@ def _append_usage_evidence( entries = usage.setdefault("_usage_evidence", []) if not isinstance(entries, list): return - entries.append({ - "evidence_kind": evidence_kind, - "raw_usage": raw_usage, - "request_id": request_id or "", - "model_served": model_served or "", - "source_field_map": { - "input_tokens": next( - (key for key in ("input_tokens", "prompt_tokens") if key in raw_usage), - "", - ), - "output_tokens": next( - (key for key in ("output_tokens", "completion_tokens") if key in raw_usage), - "", - ), - "cache_creation_tokens": next( - (key for key in ("cache_creation_input_tokens",) if key in raw_usage), - "", - ), - "cache_read_tokens": next( - ( - key for key in ( - "cache_read_input_tokens", - "cached_tokens", - ) if key in raw_usage + entries.append( + { + "evidence_kind": evidence_kind, + "raw_usage": raw_usage, + "request_id": request_id or "", + "model_served": model_served or "", + "source_field_map": { + "input_tokens": next( + ( + key + for key in ("input_tokens", "prompt_tokens") + if key in raw_usage + ), + "", + ), + "output_tokens": next( + ( + key + for key in ("output_tokens", "completion_tokens") + if key in raw_usage + ), + "", ), - "", + "cache_creation_tokens": next( + ( + key + for key in ("cache_creation_input_tokens",) + if key in raw_usage + ), + "", + ), + "cache_read_tokens": next( + ( + key + for key in ( + "cache_read_input_tokens", + "cached_tokens", + ) + if key in raw_usage + ), + "", + ), + }, + "cache_signal_present": any( + key in raw_usage + for key in ( + "cache_creation_input_tokens", + "cache_read_input_tokens", + "cached_tokens", + ) ), - }, - "cache_signal_present": any( - key in raw_usage - for key in ("cache_creation_input_tokens", "cache_read_input_tokens", "cached_tokens") - ), - }) + } + ) def build_usage_evidence_records( @@ -91,23 +111,31 @@ def build_usage_evidence_records( source_field_map = entry.get("source_field_map") if not isinstance(source_field_map, dict): source_field_map = {} - records.append({ - "vendor": vendor, - "request_id": str(entry.get("request_id") or request_id or ""), - "model_served": str(entry.get("model_served") or model_served or ""), - "evidence_kind": str(entry.get("evidence_kind") or "stream_usage"), - "raw_usage_json": json.dumps(raw_usage, ensure_ascii=False, sort_keys=True), - "parsed_input_tokens": usage.get("input_tokens", 0), - "parsed_output_tokens": usage.get("output_tokens", 0), - "parsed_cache_creation_tokens": usage.get("cache_creation_tokens", 0), - "parsed_cache_read_tokens": usage.get("cache_read_tokens", 0), - "cache_signal_present": bool(entry.get("cache_signal_present")), - "source_field_map_json": json.dumps(source_field_map, ensure_ascii=False, sort_keys=True), - }) + records.append( + { + "vendor": vendor, + "request_id": str(entry.get("request_id") or request_id or ""), + "model_served": str(entry.get("model_served") or model_served or ""), + "evidence_kind": str(entry.get("evidence_kind") or "stream_usage"), + "raw_usage_json": json.dumps( + raw_usage, ensure_ascii=False, sort_keys=True + ), + "parsed_input_tokens": usage.get("input_tokens", 0), + "parsed_output_tokens": usage.get("output_tokens", 0), + "parsed_cache_creation_tokens": usage.get("cache_creation_tokens", 0), + "parsed_cache_read_tokens": usage.get("cache_read_tokens", 0), + "cache_signal_present": bool(entry.get("cache_signal_present")), + "source_field_map_json": json.dumps( + source_field_map, ensure_ascii=False, sort_keys=True + ), + } + ) return records -def parse_usage_from_chunk(chunk: bytes, usage: dict, *, vendor_label: str | None = None) -> None: +def parse_usage_from_chunk( + chunk: bytes, usage: dict, *, vendor_label: str | None = None +) -> None: """从 SSE chunk 提取 token 用量. 同时支持 Anthropic 原生格式和 OpenAI/Zhipu 兼容格式: @@ -135,10 +163,16 @@ def parse_usage_from_chunk(chunk: bytes, usage: dict, *, vendor_label: str | Non u = msg["usage"] input_tokens = u.get("input_tokens", 0) or u.get("prompt_tokens", 0) if input_tokens > 0: - logger.debug("Extracted input tokens from message.usage: %d", input_tokens) + logger.debug( + "Extracted input tokens from message.usage: %d", input_tokens + ) _set_if_nonzero(usage, "input_tokens", input_tokens) - _set_if_nonzero(usage, "cache_creation_tokens", u.get("cache_creation_input_tokens", 0)) - _set_if_nonzero(usage, "cache_read_tokens", u.get("cache_read_input_tokens", 0)) + _set_if_nonzero( + usage, "cache_creation_tokens", u.get("cache_creation_input_tokens", 0) + ) + _set_if_nonzero( + usage, "cache_read_tokens", u.get("cache_read_input_tokens", 0) + ) if "id" in msg: usage["request_id"] = msg["id"] if "model" in msg: @@ -162,9 +196,15 @@ def parse_usage_from_chunk(chunk: bytes, usage: dict, *, vendor_label: str | Non _label = f" ({vendor_label})" if vendor_label else "" if output_tokens > 0: - logger.debug("Extracted output tokens from data.usage: %d%s", output_tokens, _label) + logger.debug( + "Extracted output tokens from data.usage: %d%s", + output_tokens, + _label, + ) if input_tokens > 0: - logger.debug("Extracted input tokens from data.usage: %d%s", input_tokens, _label) + logger.debug( + "Extracted input tokens from data.usage: %d%s", input_tokens, _label + ) _set_if_nonzero(usage, "output_tokens", output_tokens) _set_if_nonzero(usage, "input_tokens", input_tokens) diff --git a/src/coding/proxy/routing/usage_recorder.py b/src/coding/proxy/routing/usage_recorder.py index 784fa5d..501de52 100644 --- a/src/coding/proxy/routing/usage_recorder.py +++ b/src/coding/proxy/routing/usage_recorder.py @@ -4,12 +4,11 @@ import json import logging -import time from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from ..pricing import PricingTable from ..logging.db import TokenLogger + from ..pricing import PricingTable from .usage_parser import UsageInfo logger = logging.getLogger(__name__) @@ -70,9 +69,15 @@ def log_model_call( logger.info( "ModelCall: vendor=%s model_requested=%s model_served=%s " "duration=%dms tokens=[in:%d out:%d cache_create:%d cache_read:%d] cost=%s", - vendor, model_requested, model_served, duration_ms, - usage.input_tokens, usage.output_tokens, - usage.cache_creation_tokens, usage.cache_read_tokens, cost_str, + vendor, + model_requested, + model_served, + duration_ms, + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_tokens, + usage.cache_read_tokens, + cost_str, ) # ── 持久化记录 ──────────────────────────────────────── @@ -92,10 +97,17 @@ async def record( if not self._token_logger: return await self._token_logger.log( - vendor=vendor, model_requested=model_requested, model_served=model_served, - input_tokens=usage.input_tokens, output_tokens=usage.output_tokens, - cache_creation_tokens=usage.cache_creation_tokens, cache_read_tokens=usage.cache_read_tokens, - duration_ms=duration_ms, success=success, failover=failover, failover_from=failover_from, + vendor=vendor, + model_requested=model_requested, + model_served=model_served, + input_tokens=usage.input_tokens, + output_tokens=usage.output_tokens, + cache_creation_tokens=usage.cache_creation_tokens, + cache_read_tokens=usage.cache_read_tokens, + duration_ms=duration_ms, + success=success, + failover=failover, + failover_from=failover_from, request_id=usage.request_id, ) if not evidence_records or vendor != "copilot": @@ -108,24 +120,47 @@ async def record( # ── 证据记录构建 ────────────────────────────────────── @staticmethod - def build_nonstream_evidence_records(*, vendor: str, model_served: str, usage: UsageInfo) -> list[dict[str, Any]]: + def build_nonstream_evidence_records( + *, vendor: str, model_served: str, usage: UsageInfo + ) -> list[dict[str, Any]]: if vendor != "copilot": return [] - raw_usage: dict[str, Any] = {"input_tokens": usage.input_tokens, "output_tokens": usage.output_tokens} + raw_usage: dict[str, Any] = { + "input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + } if usage.cache_creation_tokens > 0: raw_usage["cache_creation_input_tokens"] = usage.cache_creation_tokens if usage.cache_read_tokens > 0: raw_usage["cache_read_input_tokens"] = usage.cache_read_tokens - return [{ - "vendor": vendor, "request_id": usage.request_id, "model_served": model_served, - "evidence_kind": "nonstream_usage_summary", - "raw_usage_json": json.dumps(raw_usage, ensure_ascii=False, sort_keys=True), - "parsed_input_tokens": usage.input_tokens, "parsed_output_tokens": usage.output_tokens, - "parsed_cache_creation_tokens": usage.cache_creation_tokens, "parsed_cache_read_tokens": usage.cache_read_tokens, - "cache_signal_present": usage.cache_creation_tokens > 0 or usage.cache_read_tokens > 0, - "source_field_map_json": json.dumps({ - "input_tokens": "input_tokens", "output_tokens": "output_tokens", - "cache_creation_tokens": "cache_creation_input_tokens" if usage.cache_creation_tokens > 0 else "", - "cache_read_tokens": "cache_read_input_tokens" if usage.cache_read_tokens > 0 else "", - }, ensure_ascii=False, sort_keys=True), - }] + return [ + { + "vendor": vendor, + "request_id": usage.request_id, + "model_served": model_served, + "evidence_kind": "nonstream_usage_summary", + "raw_usage_json": json.dumps( + raw_usage, ensure_ascii=False, sort_keys=True + ), + "parsed_input_tokens": usage.input_tokens, + "parsed_output_tokens": usage.output_tokens, + "parsed_cache_creation_tokens": usage.cache_creation_tokens, + "parsed_cache_read_tokens": usage.cache_read_tokens, + "cache_signal_present": usage.cache_creation_tokens > 0 + or usage.cache_read_tokens > 0, + "source_field_map_json": json.dumps( + { + "input_tokens": "input_tokens", + "output_tokens": "output_tokens", + "cache_creation_tokens": "cache_creation_input_tokens" + if usage.cache_creation_tokens > 0 + else "", + "cache_read_tokens": "cache_read_input_tokens" + if usage.cache_read_tokens > 0 + else "", + }, + ensure_ascii=False, + sort_keys=True, + ), + } + ] diff --git a/src/coding/proxy/server/app.py b/src/coding/proxy/server/app.py index a588716..4282dee 100644 --- a/src/coding/proxy/server/app.py +++ b/src/coding/proxy/server/app.py @@ -12,25 +12,25 @@ from fastapi import FastAPI +from .. import __version__ from ..auth.providers.github import GitHubDeviceFlowProvider from ..auth.providers.google import GoogleOAuthProvider from ..auth.runtime import RuntimeReauthCoordinator from ..auth.store import TokenStoreManager -from ..vendors.antigravity import AntigravityVendor -from ..vendors.copilot import CopilotVendor -from ..config.loader import load_config from ..compat.session_store import CompatSessionStore +from ..config.loader import load_config from ..config.schema import ProxyConfig from ..logging.db import TokenLogger from ..routing.router import RequestRouter from ..routing.tier import VendorTier +from ..vendors.antigravity import AntigravityVendor +from ..vendors.copilot import CopilotVendor from .factory import ( # noqa: F401 _build_circuit_breaker, _build_quota_guard, _create_vendor_from_config, ) from .routes import register_all_routes -from .. import __version__ logger = logging.getLogger(__name__) @@ -68,7 +68,9 @@ async def lifespan(app: FastAPI): ) tier.weekly_quota_guard.load_baseline(total) - logger.info("coding-proxy started: host=%s port=%d", config.server.host, config.server.port) + logger.info( + "coding-proxy started: host=%s port=%d", config.server.host, config.server.port + ) yield await router.close() await compat_session_store.close() @@ -92,7 +94,9 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: # 加载 Token Store 用于凭证合并 token_store = TokenStoreManager( - store_path=Path(config.auth.token_store_path) if config.auth.token_store_path else None + store_path=Path(config.auth.token_store_path) + if config.auth.token_store_path + else None ) token_store.load() @@ -101,11 +105,19 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: for vendor_cfg in config.vendors: if not vendor_cfg.enabled: continue - vendor = _create_vendor_from_config(vendor_cfg, config.failover, mapper, token_store) - cb = _build_circuit_breaker(vendor_cfg.circuit_breaker) if vendor_cfg.circuit_breaker else None + vendor = _create_vendor_from_config( + vendor_cfg, config.failover, mapper, token_store + ) + cb = ( + _build_circuit_breaker(vendor_cfg.circuit_breaker) + if vendor_cfg.circuit_breaker + else None + ) qg = _build_quota_guard(vendor_cfg.quota_guard) wqg = _build_quota_guard(vendor_cfg.weekly_quota_guard) - _vendor_map[vendor_cfg.vendor] = VendorTier(vendor=vendor, circuit_breaker=cb, quota_guard=qg, weekly_quota_guard=wqg) + _vendor_map[vendor_cfg.vendor] = VendorTier( + vendor=vendor, circuit_breaker=cb, quota_guard=qg, weekly_quota_guard=wqg + ) # 阶段二:按 tiers 指定的顺序组装最终链路(或回退到 vendors 原始顺序) if config.tiers is not None: @@ -126,9 +138,13 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI: reauth_coordinator: RuntimeReauthCoordinator | None = None if reauth_providers: - reauth_coordinator = RuntimeReauthCoordinator(token_store, reauth_providers, token_updaters) + reauth_coordinator = RuntimeReauthCoordinator( + token_store, reauth_providers, token_updaters + ) - router = RequestRouter(tiers, token_logger, reauth_coordinator, compat_session_store) + router = RequestRouter( + tiers, token_logger, reauth_coordinator, compat_session_store + ) app = FastAPI(title="coding-proxy", version=__version__, lifespan=lifespan) app.state.router = router diff --git a/src/coding/proxy/server/factory.py b/src/coding/proxy/server/factory.py index 2ee35f5..98dbb9d 100644 --- a/src/coding/proxy/server/factory.py +++ b/src/coding/proxy/server/factory.py @@ -3,24 +3,24 @@ from __future__ import annotations import logging -from pathlib import Path from typing import Any from ..auth.providers.google import ( - GoogleOAuthProvider, _DEFAULT_CLIENT_ID as _GOOGLE_DEFAULT_CLIENT_ID, +) +from ..auth.providers.google import ( _DEFAULT_CLIENT_SECRET as _GOOGLE_DEFAULT_CLIENT_SECRET, +) +from ..auth.providers.google import ( _REQUIRED_SCOPE_SET as _GOOGLE_REQUIRED_SCOPE_SET, ) -from ..auth.runtime import RuntimeReauthCoordinator +from ..auth.providers.google import ( + GoogleOAuthProvider, +) from ..auth.store import TokenStoreManager -from ..vendors.antigravity import AntigravityVendor -from ..vendors.anthropic import AnthropicVendor -from ..vendors.copilot import CopilotVendor -from ..vendors.zhipu import ZhipuVendor from ..config.schema import ( - AntigravityConfig, AnthropicConfig, + AntigravityConfig, CircuitBreakerConfig, CopilotConfig, FailoverConfig, @@ -32,6 +32,11 @@ from ..routing.model_mapper import ModelMapper from ..routing.quota_guard import QuotaGuard from ..routing.tier import VendorTier +from ..vendors.anthropic import AnthropicVendor +from ..vendors.antigravity import AntigravityVendor +from ..vendors.copilot import CopilotVendor +from ..vendors.zhipu import ZhipuVendor + # 向后兼容别名 BackendTier = VendorTier # noqa: F401 (deprecated) @@ -108,7 +113,8 @@ def _create_vendor_from_config( client_id=vendor_cfg.client_id, client_secret=vendor_cfg.client_secret, refresh_token=vendor_cfg.refresh_token, - base_url=vendor_cfg.base_url or "https://generativelanguage.googleapis.com/v1beta", + base_url=vendor_cfg.base_url + or "https://generativelanguage.googleapis.com/v1beta", model_endpoint=vendor_cfg.model_endpoint, timeout_ms=vendor_cfg.timeout_ms, ) @@ -117,7 +123,8 @@ def _create_vendor_from_config( case "zhipu": cfg = ZhipuConfig( enabled=vendor_cfg.enabled, - base_url=vendor_cfg.base_url or "https://open.bigmodel.cn/api/anthropic", + base_url=vendor_cfg.base_url + or "https://open.bigmodel.cn/api/anthropic", api_key=vendor_cfg.api_key, timeout_ms=vendor_cfg.timeout_ms, ) @@ -126,7 +133,9 @@ def _create_vendor_from_config( raise ValueError(f"未知的 vendor 类型: {vendor_cfg.vendor!r}") -def _resolve_copilot_credentials(cfg: CopilotConfig, token_store: TokenStoreManager) -> CopilotConfig: +def _resolve_copilot_credentials( + cfg: CopilotConfig, token_store: TokenStoreManager +) -> CopilotConfig: """合并 Copilot 凭证: Token Store > Config YAML. 返回更新后的 CopilotConfig(github_token 已填充)。 @@ -142,7 +151,9 @@ def _resolve_copilot_credentials(cfg: CopilotConfig, token_store: TokenStoreMana return cfg -def _resolve_antigravity_credentials(cfg: AntigravityConfig, token_store: TokenStoreManager) -> AntigravityConfig: +def _resolve_antigravity_credentials( + cfg: AntigravityConfig, token_store: TokenStoreManager +) -> AntigravityConfig: """合并 Antigravity 凭证: Token Store > Config YAML. 优先使用 Token Store 中的 refresh_token; @@ -162,8 +173,13 @@ def _resolve_antigravity_credentials(cfg: AntigravityConfig, token_store: TokenS cfg = cfg.model_copy(update=updates) logger.info("Antigravity: 使用 Token Store 中的 Google 凭证") if tokens.scope and not GoogleOAuthProvider.has_required_scopes(tokens.scope): - missing = sorted(_GOOGLE_REQUIRED_SCOPE_SET.difference(tokens.scope.split())) - logger.warning("Antigravity: Token Store 中的 Google scope 不完整,缺少: %s", ", ".join(missing)) + missing = sorted( + _GOOGLE_REQUIRED_SCOPE_SET.difference(tokens.scope.split()) + ) + logger.warning( + "Antigravity: Token Store 中的 Google scope 不完整,缺少: %s", + ", ".join(missing), + ) return cfg diff --git a/src/coding/proxy/server/request_normalizer.py b/src/coding/proxy/server/request_normalizer.py index 71ebce3..f9e23c3 100644 --- a/src/coding/proxy/server/request_normalizer.py +++ b/src/coding/proxy/server/request_normalizer.py @@ -7,7 +7,6 @@ from dataclasses import dataclass, field from typing import Any - _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 = { @@ -56,10 +55,15 @@ def normalize_content_block( adaptations.append(f"vendor_block_removed:{block_type}") return None - if message_role == "assistant" and block_type in {"tool_use", "server_tool_use"}: + if message_role == "assistant" and block_type in { + "tool_use", + "server_tool_use", + }: normalized_block = dict(block) tool_id = normalized_block.get("id") - if isinstance(tool_id, str) and _ANTHROPIC_SERVER_TOOL_USE_ID_RE.match(tool_id): + if isinstance(tool_id, str) and _ANTHROPIC_SERVER_TOOL_USE_ID_RE.match( + tool_id + ): new_id = next_tool_id() tool_id_map[tool_id] = new_id normalized_block["id"] = new_id diff --git a/src/coding/proxy/server/responses.py b/src/coding/proxy/server/responses.py index eab7745..2365e4f 100644 --- a/src/coding/proxy/server/responses.py +++ b/src/coding/proxy/server/responses.py @@ -32,7 +32,9 @@ def json_error_response( ) -def stream_error_event(error_type: str, message: str, details: list[str] | None = None) -> bytes: +def stream_error_event( + error_type: str, message: str, details: list[str] | None = None +) -> bytes: """构造 SSE 格式的错误事件.""" payload: dict[str, Any] = { "type": "error", diff --git a/src/coding/proxy/server/routes.py b/src/coding/proxy/server/routes.py index 00b3729..6159f50 100644 --- a/src/coding/proxy/server/routes.py +++ b/src/coding/proxy/server/routes.py @@ -11,6 +11,7 @@ from fastapi.responses import StreamingResponse from ..vendors.base import NoCompatibleVendorError + # 向后兼容别名 NoCompatibleBackendError = NoCompatibleVendorError # noqa: F401 (deprecated) from ..vendors.token_manager import TokenAcquireError @@ -46,7 +47,8 @@ async def _stream_proxy(router: Any, body: dict, headers: dict) -> Any: except Exception as exc: logger.error( "_stream_proxy 未预期异常: %s: %s", - type(exc).__name__, exc, + type(exc).__name__, + exc, exc_info=True, ) yield stream_error_event( @@ -69,7 +71,10 @@ async def messages(request: Request) -> Response: is_streaming = body.get("stream", False) if normalization.adaptations: - logger.debug("Request normalized before routing: %s", ", ".join(normalization.adaptations)) + logger.debug( + "Request normalized before routing: %s", + ", ".join(normalization.adaptations), + ) if is_streaming: return StreamingResponse( @@ -81,18 +86,30 @@ async def messages(request: Request) -> Response: try: resp = await router.route_message(body, headers) except NoCompatibleVendorError as exc: - return json_error_response(400, error_type="invalid_request_error", message=str(exc), details=exc.reasons) + return json_error_response( + 400, + error_type="invalid_request_error", + message=str(exc), + details=exc.reasons, + ) except TokenAcquireError as exc: - return json_error_response(503, error_type="authentication_error", message=str(exc)) + return json_error_response( + 503, error_type="authentication_error", message=str(exc) + ) except (httpx.TimeoutException, httpx.ConnectError, httpx.ReadError) as exc: - return json_error_response(502, error_type="api_error", message=f"上游不可达: {exc}") + return json_error_response( + 502, error_type="api_error", message=f"上游不可达: {exc}" + ) except Exception as exc: logger.error( "messages() 非流式路径未预期异常: %s: %s", - type(exc).__name__, exc, + type(exc).__name__, + exc, exc_info=True, ) - return json_error_response(500, error_type="api_error", message=f"内部错误: {type(exc).__name__}") + return json_error_response( + 500, error_type="api_error", message=f"内部错误: {type(exc).__name__}" + ) # 对上游返回的非标准错误格式输出诊断日志(如 Zhipu 使用 code 而非 type) if resp.status_code >= 500 and resp.raw_body: @@ -108,7 +125,11 @@ async def messages(request: Request) -> Response: except (json.JSONDecodeError, UnicodeDecodeError): pass - return Response(content=resp.raw_body or b"{}", status_code=resp.status_code, media_type="application/json") + return Response( + content=resp.raw_body or b"{}", + status_code=resp.status_code, + media_type="application/json", + ) @app.post("/v1/messages/count_tokens") async def count_tokens(request: Request) -> Response: @@ -128,7 +149,9 @@ async def count_tokens(request: Request) -> Response: body = await request.json() headers = dict(request.headers) - prepared_body, prepared_headers = await anthropic_vendor._prepare_request(body, headers) + prepared_body, prepared_headers = await anthropic_vendor._prepare_request( + body, headers + ) client = anthropic_vendor._get_client() url = "/v1/messages/count_tokens" @@ -136,8 +159,14 @@ async def count_tokens(request: Request) -> Response: url = f"{url}?{request.query_params}" try: - response = await client.post(url, json=prepared_body, headers=prepared_headers) - return Response(content=response.content, status_code=response.status_code, media_type="application/json") + response = await client.post( + url, json=prepared_body, headers=prepared_headers + ) + return Response( + content=response.content, + status_code=response.status_code, + media_type="application/json", + ) except (httpx.TimeoutException, httpx.ConnectError, httpx.ReadError) as exc: logger.warning("count_tokens proxy failed: %s", exc) return Response( @@ -192,7 +221,9 @@ async def copilot_diagnostics() -> Response: """返回 Copilot 认证与交换链路的脱敏诊断信息.""" vendor = _find_copilot_vendor(router) if vendor is None: - return json_error_response(404, error_type="not_found", message="copilot vendor not enabled") + return json_error_response( + 404, error_type="not_found", message="copilot vendor not enabled" + ) return Response( content=json.dumps(vendor.get_diagnostics(), ensure_ascii=False).encode(), status_code=200, @@ -204,13 +235,21 @@ async def copilot_models() -> Response: """按需探测当前 Copilot 会话可见模型列表.""" vendor = _find_copilot_vendor(router) if vendor is None: - return json_error_response(404, error_type="not_found", message="copilot vendor not enabled") + return json_error_response( + 404, error_type="not_found", message="copilot vendor not enabled" + ) try: probe = await vendor.probe_models() except TokenAcquireError as exc: - return json_error_response(503, error_type="authentication_error", message=str(exc)) + return json_error_response( + 503, error_type="authentication_error", message=str(exc) + ) except (httpx.TimeoutException, httpx.ConnectError) as exc: - return json_error_response(502, error_type="api_error", message=f"copilot models probe failed: {exc}") + return json_error_response( + 502, + error_type="api_error", + message=f"copilot models probe failed: {exc}", + ) return Response( content=json.dumps(probe, ensure_ascii=False).encode(), status_code=200 if probe.get("probe_status") == "ok" else 502, @@ -248,12 +287,22 @@ async def reauth_status() -> dict: async def trigger_reauth(provider: str) -> Response: """手动触发指定 provider 的运行时重认证.""" if not reauth_coordinator: - return Response(content=b'{"error":"reauth not available"}', status_code=404, media_type="application/json") + return Response( + content=b'{"error":"reauth not available"}', + status_code=404, + media_type="application/json", + ) await reauth_coordinator.request_reauth(provider) - return Response(content=b'{"status":"reauth requested"}', status_code=202, media_type="application/json") + return Response( + content=b'{"status":"reauth requested"}', + status_code=202, + media_type="application/json", + ) -def register_all_routes(app: Any, router: Any, reauth_coordinator: Any | None = None) -> None: +def register_all_routes( + app: Any, router: Any, reauth_coordinator: Any | None = None +) -> None: """一次性注册所有路由分组.""" register_core_routes(app, router) register_health_routes(app) diff --git a/src/coding/proxy/streaming/anthropic_compat.py b/src/coding/proxy/streaming/anthropic_compat.py index 2ce0054..d908442 100644 --- a/src/coding/proxy/streaming/anthropic_compat.py +++ b/src/coding/proxy/streaming/anthropic_compat.py @@ -5,7 +5,8 @@ import json import logging import uuid -from typing import Any, AsyncIterator +from collections.abc import AsyncIterator +from typing import Any logger = logging.getLogger(__name__) @@ -50,28 +51,35 @@ def ensure_started(self) -> list[bytes]: return [] self.started = True return [ - _make_event("message_start", { - "type": "message_start", - "message": { - "id": self.message_id, - "type": "message", - "role": "assistant", - "content": [], - "model": self.model, - "usage": { - "input_tokens": self.input_tokens, - "output_tokens": 0, - **( - {"cache_creation_input_tokens": self.cache_creation_tokens} - if self.cache_creation_tokens > 0 else {} - ), - **( - {"cache_read_input_tokens": self.cache_read_tokens} - if self.cache_read_tokens > 0 else {} - ), + _make_event( + "message_start", + { + "type": "message_start", + "message": { + "id": self.message_id, + "type": "message", + "role": "assistant", + "content": [], + "model": self.model, + "usage": { + "input_tokens": self.input_tokens, + "output_tokens": 0, + **( + { + "cache_creation_input_tokens": self.cache_creation_tokens + } + if self.cache_creation_tokens > 0 + else {} + ), + **( + {"cache_read_input_tokens": self.cache_read_tokens} + if self.cache_read_tokens > 0 + else {} + ), + }, }, }, - }), + ), ] def close(self, reason: str = "end_turn") -> list[bytes]: @@ -80,10 +88,15 @@ def close(self, reason: str = "end_turn") -> list[bytes]: self.stopped = True chunks: list[bytes] = [] if self.started and self.content_block_open: - chunks.append(_make_event("content_block_stop", { - "type": "content_block_stop", - "index": self.block_index, - })) + chunks.append( + _make_event( + "content_block_stop", + { + "type": "content_block_stop", + "index": self.block_index, + }, + ) + ) self.content_block_open = False usage_data = {"output_tokens": self.output_tokens} if self.usage_updated and self.input_tokens > 0: @@ -92,11 +105,16 @@ def close(self, reason: str = "end_turn") -> list[bytes]: usage_data["cache_creation_input_tokens"] = self.cache_creation_tokens if self.cache_read_tokens > 0: usage_data["cache_read_input_tokens"] = self.cache_read_tokens - chunks.append(_make_event("message_delta", { - "type": "message_delta", - "delta": {"stop_reason": reason, "stop_sequence": None}, - "usage": usage_data, - })) + chunks.append( + _make_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": reason, "stop_sequence": None}, + "usage": usage_data, + }, + ) + ) chunks.append(_make_event("message_stop", {"type": "message_stop"})) return chunks @@ -127,10 +145,15 @@ def close_content_block(self) -> list[bytes]: return [] self.content_block_open = False self.block_index += 1 - return [_make_event("content_block_stop", { - "type": "content_block_stop", - "index": self.block_index - 1, - })] + return [ + _make_event( + "content_block_stop", + { + "type": "content_block_stop", + "index": self.block_index - 1, + }, + ) + ] def open_thinking_block(self) -> list[bytes]: """打开 thinking 内容块(如尚未打开).""" @@ -138,12 +161,19 @@ def open_thinking_block(self) -> list[bytes]: return [] self.thinking_block_open = True self.content_block_open = True - logger.debug("copilot-stream: opening thinking block at index=%d", self.block_index) - return [_make_event("content_block_start", { - "type": "content_block_start", - "index": self.block_index, - "content_block": {"type": "thinking", "thinking": ""}, - })] + logger.debug( + "copilot-stream: opening thinking block at index=%d", self.block_index + ) + return [ + _make_event( + "content_block_start", + { + "type": "content_block_start", + "index": self.block_index, + "content_block": {"type": "thinking", "thinking": ""}, + }, + ) + ] def ensure_text_block(self) -> list[bytes]: """确保当前为 text 内容块:先关闭 thinking,再处理工具块冲突,最后打开 text 块.""" @@ -154,10 +184,15 @@ def ensure_text_block(self) -> list[bytes]: "copilot-stream: closing thinking block at index=%d before opening text block", self.block_index, ) - chunks.append(_make_event("content_block_stop", { - "type": "content_block_stop", - "index": self.block_index, - })) + chunks.append( + _make_event( + "content_block_stop", + { + "type": "content_block_stop", + "index": self.block_index, + }, + ) + ) self.block_index += 1 self.thinking_block_open = False self.content_block_open = False @@ -169,15 +204,22 @@ def ensure_text_block(self) -> list[bytes]: chunks.extend(self.close_content_block()) # 打开 text 块 if not self.content_block_open: - chunks.append(_make_event("content_block_start", { - "type": "content_block_start", - "index": self.block_index, - "content_block": {"type": "text", "text": ""}, - })) + chunks.append( + _make_event( + "content_block_start", + { + "type": "content_block_start", + "index": self.block_index, + "content_block": {"type": "text", "text": ""}, + }, + ) + ) self.content_block_open = True return chunks - def open_tool_block(self, tool_index: int, tool_call: dict[str, Any]) -> list[bytes]: + def open_tool_block( + self, tool_index: int, tool_call: dict[str, Any] + ) -> list[bytes]: """注册并打开 tool_use 内容块.""" chunks: list[bytes] = [] if self.content_block_open: @@ -187,16 +229,21 @@ def open_tool_block(self, tool_index: int, tool_call: dict[str, Any]) -> list[by "name": tool_call["function"]["name"], "anthropic_block_index": self.block_index, } - chunks.append(_make_event("content_block_start", { - "type": "content_block_start", - "index": self.block_index, - "content_block": { - "type": "tool_use", - "id": tool_call["id"], - "name": tool_call["function"]["name"], - "input": {}, - }, - })) + chunks.append( + _make_event( + "content_block_start", + { + "type": "content_block_start", + "index": self.block_index, + "content_block": { + "type": "tool_use", + "id": tool_call["id"], + "name": tool_call["function"]["name"], + "input": {}, + }, + }, + ) + ) self.content_block_open = True return chunks @@ -205,14 +252,19 @@ def feed_tool_arguments(self, tool_index: int, arguments: str) -> list[bytes]: tool_info = self.tool_calls.get(tool_index) if not tool_info or not arguments: return [] - return [_make_event("content_block_delta", { - "type": "content_block_delta", - "index": tool_info["anthropic_block_index"], - "delta": { - "type": "input_json_delta", - "partial_json": arguments, - }, - })] + return [ + _make_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": tool_info["anthropic_block_index"], + "delta": { + "type": "input_json_delta", + "partial_json": arguments, + }, + }, + ) + ] def _make_event(event_type: str, data: dict[str, Any]) -> bytes: @@ -243,13 +295,17 @@ def _extract_text_fragments(delta: Any) -> list[str]: _INPUT_JSON_DELTA_TYPES = {"input_json_delta", "arguments_delta", "tool_call_delta"} -def _normalize_direct_event(data: dict[str, Any], event_name: str | None) -> list[bytes]: +def _normalize_direct_event( + data: dict[str, Any], event_name: str | None +) -> list[bytes]: event_type = data.get("type") if event_type == "content_block_start": block = data.get("content_block", {}) block_type = block.get("type") if block_type not in _TOOL_USE_BLOCK_TYPES: - logger.debug("Filtered non-standard content_block_start type: %s", block_type) + logger.debug( + "Filtered non-standard content_block_start type: %s", block_type + ) return [] # OpenAI 兼容供应商可能在 content_block_start.input 中内联返回完整工具参数 if block_type == "tool_use": @@ -261,18 +317,27 @@ def _normalize_direct_event(data: dict[str, Any], event_name: str | None) -> lis block.get("name", "?"), json.dumps(inline_input, ensure_ascii=False)[:200], ) - result.append(_make_event("content_block_delta", { - "type": "content_block_delta", - "index": data.get("index", 0), - "delta": { - "type": "input_json_delta", - "partial_json": json.dumps(inline_input, ensure_ascii=False), - }, - })) + result.append( + _make_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": data.get("index", 0), + "delta": { + "type": "input_json_delta", + "partial_json": json.dumps( + inline_input, ensure_ascii=False + ), + }, + }, + ) + ) return result # 非标准类型归一化为 tool_use if block_type in ("tool_call", "function_call"): - logger.debug("Normalizing non-standard block type '%s' to 'tool_use'", block_type) + logger.debug( + "Normalizing non-standard block type '%s' to 'tool_use'", block_type + ) normalized_block = {**block, "type": "tool_use"} normalized_data = {**data, "content_block": normalized_block} return [_make_event(event_name or event_type, normalized_data)] @@ -286,10 +351,20 @@ def _normalize_direct_event(data: dict[str, Any], event_name: str | None) -> lis # 归一化非标准 input_json_delta 别名(OpenAI 兼容供应商可能使用 arguments_delta 等) if delta_type in _INPUT_JSON_DELTA_TYPES: normalized_delta = {**delta, "type": "input_json_delta"} - if "partial_json" not in normalized_delta and "arguments" in normalized_delta: + if ( + "partial_json" not in normalized_delta + and "arguments" in normalized_delta + ): normalized_delta["partial_json"] = normalized_delta.pop("arguments") - logger.debug("Normalizing non-standard delta type '%s' to 'input_json_delta'", delta_type) - return [_make_event(event_name or event_type, {**data, "delta": normalized_delta})] + logger.debug( + "Normalizing non-standard delta type '%s' to 'input_json_delta'", + delta_type, + ) + return [ + _make_event( + event_name or event_type, {**data, "delta": normalized_delta} + ) + ] # 其他 delta 类型过滤 logger.debug("Filtered non-standard content_block_delta type: %s", delta_type) return [] @@ -300,7 +375,9 @@ def _normalize_direct_event(data: dict[str, Any], event_name: str | None) -> lis return [_make_event(event_name or event_type, data)] -def _normalize_stream_event(data: dict[str, Any], event_name: str | None) -> list[bytes]: +def _normalize_stream_event( + data: dict[str, Any], event_name: str | None +) -> list[bytes]: nested = data.get("event") if not isinstance(nested, dict): return [] @@ -337,7 +414,9 @@ def _extract_cache_creation_tokens(usage: dict[str, Any]) -> int: return 0 -def _normalize_openai_chunk(data: dict[str, Any], state: _OpenAICompatState) -> list[bytes]: +def _normalize_openai_chunk( + data: dict[str, Any], state: _OpenAICompatState +) -> list[bytes]: """将 OpenAI 格式 chunk 转换为 Anthropic SSE 事件序列. 重构后为纯粹的事件分发器:token 更新、块生命周期管理均委托给 State 方法。 @@ -360,11 +439,16 @@ def _normalize_openai_chunk(data: dict[str, Any], state: _OpenAICompatState) -> if reasoning_content: chunks.extend(state.ensure_started()) chunks.extend(state.open_thinking_block()) - chunks.append(_make_event("content_block_delta", { - "type": "content_block_delta", - "index": state.block_index, - "delta": {"type": "thinking_delta", "thinking": reasoning_content}, - })) + chunks.append( + _make_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": state.block_index, + "delta": {"type": "thinking_delta", "thinking": reasoning_content}, + }, + ) + ) # 3. Text 内容 → text 块 text_fragments = _extract_text_fragments(delta.get("content")) @@ -372,11 +456,16 @@ def _normalize_openai_chunk(data: dict[str, Any], state: _OpenAICompatState) -> chunks.extend(state.ensure_started()) chunks.extend(state.ensure_text_block()) for text in text_fragments: - chunks.append(_make_event("content_block_delta", { - "type": "content_block_delta", - "index": state.block_index, - "delta": {"type": "text_delta", "text": text}, - })) + chunks.append( + _make_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": state.block_index, + "delta": {"type": "text_delta", "text": text}, + }, + ) + ) # 4. Tool calls → tool_use 块 tool_calls = delta.get("tool_calls") or [] @@ -385,7 +474,11 @@ def _normalize_openai_chunk(data: dict[str, Any], state: _OpenAICompatState) -> continue tool_index = int(tool_call.get("index", 0)) # 注册新工具调用 - if tool_call.get("id") and isinstance(tool_call.get("function"), dict) and tool_call["function"].get("name"): + if ( + tool_call.get("id") + and isinstance(tool_call.get("function"), dict) + and tool_call["function"].get("name") + ): chunks.extend(state.ensure_started()) chunks.extend(state.open_tool_block(tool_index, tool_call)) # 追加工具参数 @@ -395,10 +488,15 @@ def _normalize_openai_chunk(data: dict[str, Any], state: _OpenAICompatState) -> tool_info = state.tool_calls.get(tool_index) if arguments: chunks.extend(state.feed_tool_arguments(tool_index, arguments)) - elif tool_info and arguments is None and finish_reason in ("tool_calls", "stop"): + elif ( + tool_info + and arguments is None + and finish_reason in ("tool_calls", "stop") + ): logger.debug( "Tool call '%s' has null arguments at finish_reason=%s", - tool_info.get("name", "?"), finish_reason, + tool_info.get("name", "?"), + finish_reason, ) # 5. Finish reason → 关闭消息 diff --git a/src/coding/proxy/vendors/__init__.py b/src/coding/proxy/vendors/__init__.py index 44c696b..24242ad 100644 --- a/src/coding/proxy/vendors/__init__.py +++ b/src/coding/proxy/vendors/__init__.py @@ -1,29 +1,39 @@ """供应商适配层 — 所有供应商实现的统一入口.""" from .base import ( # noqa: F401 - BaseVendor, BaseBackend, # 向后兼容别名 - VendorCapabilities, - VendorResponse, - NoCompatibleVendorError, - NoCompatibleBackendError, # 向后兼容别名 + BaseVendor, CapabilityLossReason, - RequestCapabilities, - UsageInfo, CopilotExchangeDiagnostics, CopilotMisdirectedRequest, CopilotModelCatalog, + NoCompatibleBackendError, # 向后兼容别名 + NoCompatibleVendorError, + RequestCapabilities, + UsageInfo, + VendorCapabilities, + VendorResponse, decode_json_body, extract_error_message, sanitize_headers_for_synthetic_response, ) __all__ = [ - "BaseVendor", "BaseBackend", - "VendorCapabilities", "BackendCapabilities", - "VendorResponse", "BackendResponse", - "NoCompatibleVendorError", "NoCompatibleBackendError", - "CapabilityLossReason", "RequestCapabilities", "UsageInfo", - "CopilotExchangeDiagnostics", "CopilotMisdirectedRequest", "CopilotModelCatalog", - "decode_json_body", "extract_error_message", "sanitize_headers_for_synthetic_response", + "BaseVendor", + "BaseBackend", + "VendorCapabilities", + "BackendCapabilities", + "VendorResponse", + "BackendResponse", + "NoCompatibleVendorError", + "NoCompatibleBackendError", + "CapabilityLossReason", + "RequestCapabilities", + "UsageInfo", + "CopilotExchangeDiagnostics", + "CopilotMisdirectedRequest", + "CopilotModelCatalog", + "decode_json_body", + "extract_error_message", + "sanitize_headers_for_synthetic_response", ] diff --git a/src/coding/proxy/vendors/anthropic.py b/src/coding/proxy/vendors/anthropic.py index bed7100..388827d 100644 --- a/src/coding/proxy/vendors/anthropic.py +++ b/src/coding/proxy/vendors/anthropic.py @@ -14,7 +14,9 @@ class AnthropicVendor(BaseVendor): 透传 Claude Code 发来的 OAuth token 和请求体到 Anthropic API. """ - def __init__(self, config: AnthropicConfig, failover_config: FailoverConfig) -> None: + def __init__( + self, config: AnthropicConfig, failover_config: FailoverConfig + ) -> None: super().__init__(config.base_url, config.timeout_ms, failover_config) def get_name(self) -> str: @@ -36,7 +38,9 @@ async def _prepare_request( headers: dict[str, str], ) -> tuple[dict[str, Any], dict[str, str]]: """透传请求体,过滤无关请求头.""" - filtered = {k: v for k, v in headers.items() if k.lower() not in PROXY_SKIP_HEADERS} + filtered = { + k: v for k, v in headers.items() if k.lower() not in PROXY_SKIP_HEADERS + } return request_body, filtered diff --git a/src/coding/proxy/vendors/antigravity.py b/src/coding/proxy/vendors/antigravity.py index ac95158..e7d75cc 100644 --- a/src/coding/proxy/vendors/antigravity.py +++ b/src/coding/proxy/vendors/antigravity.py @@ -4,15 +4,16 @@ import json import logging -from typing import Any, AsyncIterator +from collections.abc import AsyncIterator +from typing import Any import httpx from ..compat.canonical import CompatibilityProfile, CompatibilityStatus from ..config.schema import AntigravityConfig, FailoverConfig from ..convert.anthropic_to_gemini import convert_request -from ..convert.gemini_to_anthropic import convert_response, extract_usage from ..convert.gemini_sse_adapter import adapt_sse_stream +from ..convert.gemini_to_anthropic import convert_response, extract_usage from ..routing.model_mapper import ModelMapper from .base import ( BaseVendor, @@ -20,8 +21,10 @@ RequestCapabilities, UsageInfo, VendorCapabilities, + VendorResponse, _sanitize_headers_for_synthetic_response, ) + # GoogleOAuthTokenManager 已从 antigravity_token_manager.py 合并至本文件末尾 from .mixins import TokenBackendMixin from .token_manager import BaseTokenManager, TokenAcquireError, TokenErrorKind @@ -123,7 +126,9 @@ def __init__( model_mapper: ModelMapper, ) -> None: token_manager = GoogleOAuthTokenManager( - config.client_id, config.client_secret, config.refresh_token, + config.client_id, + config.client_secret, + config.refresh_token, ) TokenBackendMixin.__init__(self, token_manager) BaseVendor.__init__(self, config.base_url, config.timeout_ms, failover_config) @@ -157,13 +162,16 @@ def get_compatibility_profile(self) -> CompatibilityProfile: ) def supports_request( - self, request_caps: RequestCapabilities, + self, + request_caps: RequestCapabilities, ) -> tuple[bool, list[CapabilityLossReason]]: supported, reasons = super().supports_request(request_caps) if not supported: reasons = [ - reason for reason in reasons - if reason not in { + reason + for reason in reasons + if reason + not in { CapabilityLossReason.THINKING, CapabilityLossReason.TOOLS, CapabilityLossReason.METADATA, @@ -180,7 +188,8 @@ def map_model(self, model: str) -> str: self._last_requested_model = model self._last_resolved_model = resolved self._last_model_resolution_reason = ( - "configured_mapping" if resolved != self._default_model or model == self._default_model + "configured_mapping" + if resolved != self._default_model or model == self._default_model else "config_default_model_endpoint" ) return resolved @@ -292,7 +301,10 @@ async def send_message_stream( logger.debug("send_message_stream: POST %s", endpoint) async with client.stream( - "POST", endpoint, json=body, headers=prepared_headers, + "POST", + endpoint, + json=body, + headers=prepared_headers, ) as response: if response.status_code >= 400: self._on_error_status(response.status_code) @@ -302,7 +314,9 @@ async def send_message_stream( ) logger.warning( "%s stream error: status=%d body=%s", - self.get_name(), response.status_code, error_body[:500], + self.get_name(), + response.status_code, + error_body[:500], ) raise httpx.HTTPStatusError( f"{self.get_name()} API error: {response.status_code}", @@ -310,7 +324,9 @@ async def send_message_stream( response=httpx.Response( response.status_code, content=error_body, - headers=_sanitize_headers_for_synthetic_response(response.headers), + headers=_sanitize_headers_for_synthetic_response( + response.headers + ), request=response.request, ), ) diff --git a/src/coding/proxy/vendors/base.py b/src/coding/proxy/vendors/base.py index 4eed85d..ef9fa6a 100644 --- a/src/coding/proxy/vendors/base.py +++ b/src/coding/proxy/vendors/base.py @@ -4,14 +4,18 @@ import logging from abc import ABC, abstractmethod -from typing import Any, AsyncIterator +from collections.abc import AsyncIterator +from typing import Any import httpx +from ..model.constants import ( # noqa: F401 + PROXY_SKIP_HEADERS, + RESPONSE_SANITIZE_SKIP_HEADERS, +) + # 从 model/ 模块正交导入所有类型、常量与工具函数,并 re-export 以保持向后兼容 from ..model.vendor import ( # noqa: F401 - VendorCapabilities, - VendorResponse, CapabilityLossReason, CopilotExchangeDiagnostics, CopilotMisdirectedRequest, @@ -19,14 +23,12 @@ NoCompatibleVendorError, RequestCapabilities, UsageInfo, + VendorCapabilities, + VendorResponse, decode_json_body, extract_error_message, sanitize_headers_for_synthetic_response, ) -from ..model.constants import ( # noqa: F401 - PROXY_SKIP_HEADERS, - RESPONSE_SANITIZE_SKIP_HEADERS, -) # ── 废弃别名(向后兼容旧名称) ────────────────────────── _decode_json_body = decode_json_body @@ -94,7 +96,9 @@ def get_compatibility_profile(self) -> CompatibilityProfile: return CompatibilityProfile( thinking=self._compat_status_from_bool(caps.supports_thinking), tool_calling=self._compat_status_from_bool(caps.supports_tools), - tool_streaming=CompatibilityStatus.SIMULATED if caps.supports_tools else CompatibilityStatus.UNSAFE, + tool_streaming=CompatibilityStatus.SIMULATED + if caps.supports_tools + else CompatibilityStatus.UNSAFE, mcp_tools=CompatibilityStatus.UNKNOWN, images=self._compat_status_from_bool(caps.supports_images), metadata=self._compat_status_from_bool(caps.supports_metadata), @@ -107,36 +111,48 @@ def _compat_status_from_bool(supported: bool) -> CompatibilityStatus: """将布尔能力映射为兼容性状态.""" return CompatibilityStatus.NATIVE if supported else CompatibilityStatus.UNSAFE - def make_compatibility_decision(self, request: CanonicalRequest) -> CompatibilityDecision: + def make_compatibility_decision( + self, request: CanonicalRequest + ) -> CompatibilityDecision: profile = self.get_compatibility_profile() simulation_actions: list[str] = [] unsupported: list[str] = [] - if request.thinking.enabled and profile.thinking is CompatibilityStatus.SIMULATED: + if ( + request.thinking.enabled + and profile.thinking is CompatibilityStatus.SIMULATED + ): simulation_actions.append("thinking_simulation") elif request.thinking.enabled and profile.thinking not in { - CompatibilityStatus.NATIVE, CompatibilityStatus.SIMULATED, + CompatibilityStatus.NATIVE, + CompatibilityStatus.SIMULATED, }: unsupported.append("thinking") if request.tool_names and profile.tool_calling is CompatibilityStatus.SIMULATED: simulation_actions.append("tool_calling_simulation") elif request.tool_names and profile.tool_calling not in { - CompatibilityStatus.NATIVE, CompatibilityStatus.SIMULATED, + CompatibilityStatus.NATIVE, + CompatibilityStatus.SIMULATED, }: unsupported.append("tools") if request.metadata and profile.metadata is CompatibilityStatus.SIMULATED: simulation_actions.append("metadata_projection") elif request.metadata and profile.metadata not in { - CompatibilityStatus.NATIVE, CompatibilityStatus.SIMULATED, + CompatibilityStatus.NATIVE, + CompatibilityStatus.SIMULATED, }: unsupported.append("metadata") - if request.supports_json_output and profile.json_output is CompatibilityStatus.SIMULATED: + if ( + request.supports_json_output + and profile.json_output is CompatibilityStatus.SIMULATED + ): simulation_actions.append("json_output_projection") elif request.supports_json_output and profile.json_output not in { - CompatibilityStatus.NATIVE, CompatibilityStatus.SIMULATED, + CompatibilityStatus.NATIVE, + CompatibilityStatus.SIMULATED, }: unsupported.append("response_format") @@ -166,7 +182,8 @@ def get_compat_trace(self) -> CompatibilityTrace | None: return self._compat_trace def supports_request( - self, request_caps: RequestCapabilities, + self, + request_caps: RequestCapabilities, ) -> tuple[bool, list[CapabilityLossReason]]: """判断供应商是否能无损承接该请求.""" vendor_caps = self.get_capabilities() @@ -199,7 +216,9 @@ def _get_endpoint(self) -> str: # ── 响应处理钩子 ────────────────────────────────────── - def _pre_send_check(self, request_body: dict[str, Any], headers: dict[str, str]) -> None: + def _pre_send_check( + self, request_body: dict[str, Any], headers: dict[str, str] + ) -> None: """发送前检查钩子. 子类可覆写以实现快速失败(如缺少 API key). 默认实现为空操作(no-op). @@ -229,7 +248,9 @@ def get_diagnostics(self) -> dict[str, Any]: diagnostics["compat"] = self._compat_trace.to_dict() return diagnostics - def should_trigger_failover(self, status_code: int, body: dict[str, Any] | None) -> bool: + def should_trigger_failover( + self, status_code: int, body: dict[str, Any] | None + ) -> bool: """基于 FailoverConfig 的通用故障转移判断. 无 failover_config 时返回 False(终端供应商默认行为). @@ -280,7 +301,9 @@ async def send_message_stream( error_body = await response.aread() logger.warning( "%s stream error: status=%d body=%s", - self.get_name(), response.status_code, error_body[:500], + self.get_name(), + response.status_code, + error_body[:500], ) raise httpx.HTTPStatusError( f"{self.get_name()} API error: {response.status_code}", @@ -288,7 +311,9 @@ async def send_message_stream( response=httpx.Response( response.status_code, content=error_body, - headers=_sanitize_headers_for_synthetic_response(response.headers), + headers=_sanitize_headers_for_synthetic_response( + response.headers + ), request=response.request, ), ) @@ -320,24 +345,35 @@ async def send_message( vendor_resp = VendorResponse( status_code=response.status_code, raw_body=raw_content, - error_type=resp_body.get("error", {}).get("type") if isinstance(resp_body, dict) and isinstance(resp_body.get("error"), dict) else None, + error_type=resp_body.get("error", {}).get("type") + if isinstance(resp_body, dict) + and isinstance(resp_body.get("error"), dict) + else None, error_message=_extract_error_message(response, resp_body), response_headers=dict(response.headers), ) - return self._normalize_error_response(response.status_code, response, vendor_resp) + return self._normalize_error_response( + response.status_code, response, vendor_resp + ) usage = resp_body.get("usage", {}) if isinstance(resp_body, dict) else {} return VendorResponse( status_code=response.status_code, raw_body=raw_content, usage=UsageInfo( - input_tokens=usage.get("input_tokens", 0) or usage.get("prompt_tokens", 0), - output_tokens=usage.get("output_tokens", 0) or usage.get("completion_tokens", 0), + input_tokens=usage.get("input_tokens", 0) + or usage.get("prompt_tokens", 0), + output_tokens=usage.get("output_tokens", 0) + or usage.get("completion_tokens", 0), cache_creation_tokens=usage.get("cache_creation_input_tokens", 0), cache_read_tokens=usage.get("cache_read_input_tokens", 0), - request_id=resp_body.get("id", "") if isinstance(resp_body, dict) else "", + request_id=resp_body.get("id", "") + if isinstance(resp_body, dict) + else "", ), - model_served=resp_body.get("model") if isinstance(resp_body, dict) else None, + model_served=resp_body.get("model") + if isinstance(resp_body, dict) + else None, ) async def close(self) -> None: diff --git a/src/coding/proxy/vendors/copilot.py b/src/coding/proxy/vendors/copilot.py index 5fc49b7..b159976 100644 --- a/src/coding/proxy/vendors/copilot.py +++ b/src/coding/proxy/vendors/copilot.py @@ -3,17 +3,18 @@ from __future__ import annotations import logging -from typing import Any, AsyncIterator +from collections.abc import AsyncIterator +from typing import Any from uuid import uuid4 import httpx -from ..config.schema import CopilotConfig, FailoverConfig from ..compat.canonical import CompatibilityProfile, CompatibilityStatus +from ..config.schema import CopilotConfig, FailoverConfig from ..convert.anthropic_to_openai import convert_request as convert_openai_request from ..convert.openai_to_anthropic import convert_response as convert_openai_response -from ..streaming.anthropic_compat import normalize_anthropic_compatible_stream from ..routing.model_mapper import ModelMapper +from ..streaming.anthropic_compat import normalize_anthropic_compatible_stream from .base import ( PROXY_SKIP_HEADERS, BaseVendor, @@ -44,6 +45,7 @@ build_copilot_candidate_base_urls, resolve_copilot_base_url, ) + # Copilot421RetryHandler 已从 copilot_retry.py 合并至本文件末尾 from .mixins import TokenBackendMixin @@ -77,7 +79,10 @@ async def execute_request_with_retry( self._backend._begin_request(current_base_url) response = await self._backend._get_client().request( - method, endpoint, json=json_body, headers=headers, + method, + endpoint, + json=json_body, + headers=headers, ) if response.status_code != 421: return response @@ -87,9 +92,14 @@ async def execute_request_with_retry( for retry_base_url in self._backend._retry_base_urls(current_base_url): self._backend._last_retry_base_url = retry_base_url - async with self._backend._create_fresh_client(retry_base_url) as retry_client: + async with self._backend._create_fresh_client( + retry_base_url + ) as retry_client: retry_response = await retry_client.request( - method, endpoint, json=json_body, headers=headers, + method, + endpoint, + json=json_body, + headers=headers, ) last_response = retry_response if retry_response.status_code != 421: @@ -124,7 +134,9 @@ async def execute_stream_with_retry( for retry_base_url in self._backend._retry_base_urls(current_base_url): self._backend._last_retry_base_url = retry_base_url - async with self._backend._create_fresh_client(retry_base_url) as retry_client: + async with self._backend._create_fresh_client( + retry_base_url + ) as retry_client: try: async for chunk in stream_fn(retry_client): yield chunk @@ -132,7 +144,10 @@ async def execute_stream_with_retry( return except httpx.HTTPStatusError as retry_exc: last_exc = retry_exc - if retry_exc.response is None or retry_exc.response.status_code != 421: + if ( + retry_exc.response is None + or retry_exc.response.status_code != 421 + ): raise self._backend._last_421_base_url = retry_base_url @@ -155,8 +170,12 @@ def __init__( ) -> None: self._account_type = (config.account_type or "individual").strip().lower() self._configured_base_url = config.base_url - self._candidate_base_urls = build_copilot_candidate_base_urls(self._account_type, config.base_url) - self._resolved_base_url = resolve_copilot_base_url(self._account_type, config.base_url) + self._candidate_base_urls = build_copilot_candidate_base_urls( + self._account_type, config.base_url + ) + self._resolved_base_url = resolve_copilot_base_url( + self._account_type, config.base_url + ) # 模型解析委托给 CopilotModelResolver 策略类 self._model_resolver = CopilotModelResolver( models_cache_ttl_seconds=int(config.models_cache_ttl_seconds), @@ -172,7 +191,9 @@ def __init__( # _last_model_resolution_reason / _last_request_adaptations)由 Mixin 提供 token_manager = CopilotTokenManager(config.github_token, config.token_url) TokenBackendMixin.__init__(self, token_manager) - BaseVendor.__init__(self, self._resolved_base_url, config.timeout_ms, failover_config) + BaseVendor.__init__( + self, self._resolved_base_url, config.timeout_ms, failover_config + ) def get_name(self) -> str: return "copilot" @@ -199,12 +220,17 @@ def get_compatibility_profile(self) -> CompatibilityProfile: ) def supports_request( - self, request_caps: RequestCapabilities, + self, + request_caps: RequestCapabilities, ) -> tuple[bool, list[CapabilityLossReason]]: """Copilot 可通过适配层吸收 thinking 语义,不在路由阶段直接拒绝.""" supported, reasons = super().supports_request(request_caps) if not supported: - reasons = [reason for reason in reasons if reason is not CapabilityLossReason.THINKING] + reasons = [ + reason + for reason in reasons + if reason is not CapabilityLossReason.THINKING + ] return len(reasons) == 0, reasons def _get_endpoint(self) -> str: @@ -292,7 +318,8 @@ def _retry_base_urls(self, base_url: str) -> list[str]: retry_urls = [normalized] if not self._configured_base_url.strip(): retry_urls.extend( - candidate for candidate in self._candidate_base_urls + candidate + for candidate in self._candidate_base_urls if candidate != normalized ) return retry_urls @@ -326,7 +353,9 @@ async def _request_chat_with_model_retry( ) @staticmethod - def _build_misdirected_request(response: httpx.Response, body: bytes, base_url: str) -> CopilotMisdirectedRequest: + def _build_misdirected_request( + response: httpx.Response, body: bytes, base_url: str + ) -> CopilotMisdirectedRequest: return CopilotMisdirectedRequest( base_url=_normalize_base_url(base_url), status_code=response.status_code, @@ -336,7 +365,9 @@ def _build_misdirected_request(response: httpx.Response, body: bytes, base_url: ) @staticmethod - def _build_http_status_error_from_misdirected(error: CopilotMisdirectedRequest) -> httpx.HTTPStatusError: + def _build_http_status_error_from_misdirected( + error: CopilotMisdirectedRequest, + ) -> httpx.HTTPStatusError: return httpx.HTTPStatusError( f"copilot API error: {error.status_code}", request=error.request, @@ -358,7 +389,10 @@ async def _request_with_421_retry( ) -> httpx.Response: """同步请求的 421 Misdirected 重试 — 委托给 Copilot421RetryHandler.""" return await self._421_handler.execute_request_with_retry( - method, endpoint, headers=headers, json_body=json_body, + method, + endpoint, + headers=headers, + json_body=json_body, ) async def _stream_from_client( @@ -387,7 +421,9 @@ async def _stream_from_client( error_body = await response.aread() logger.warning( "%s stream error: status=%d body=%s", - self.get_name(), response.status_code, error_body[:500], + self.get_name(), + response.status_code, + error_body[:500], ) raise httpx.HTTPStatusError( f"{self.get_name()} API error: {response.status_code}", @@ -419,7 +455,9 @@ async def _prepare_request( model_refresh_reason: str = "request_prepare", ) -> tuple[dict[str, Any], dict[str, str]]: """透传请求体,过滤 hop-by-hop 头并注入 Copilot token.""" - filtered = {k: v for k, v in headers.items() if k.lower() not in PROXY_SKIP_HEADERS} + filtered = { + k: v for k, v in headers.items() if k.lower() not in PROXY_SKIP_HEADERS + } prepared = self._build_copilot_headers() for key, value in filtered.items(): if key.lower() not in {item.lower() for item in prepared}: @@ -505,7 +543,9 @@ def get_diagnostics(self) -> dict[str, Any]: if self._model_resolver.last_normalized_model: diagnostics["normalized_model"] = self._model_resolver.last_normalized_model if self._model_resolver.last_model_refresh_reason: - diagnostics["last_model_refresh_reason"] = self._model_resolver.last_model_refresh_reason + diagnostics["last_model_refresh_reason"] = ( + self._model_resolver.last_model_refresh_reason + ) cache_age = self._model_resolver.catalog.age_seconds() if cache_age is not None: diagnostics["available_models_cache_age_seconds"] = cache_age @@ -532,7 +572,9 @@ async def send_message( requested_model=self._last_requested_model, normalized_model=self._model_resolver.last_normalized_model, resolved_model=self._last_resolved_model, - available_models=list(self._model_resolver.catalog.available_models), + available_models=list( + self._model_resolver.catalog.available_models + ), ) raw_content = response.content resp_body = _decode_json_body(response) @@ -540,7 +582,10 @@ async def send_message( return VendorResponse( status_code=response.status_code, raw_body=raw_content, - error_type=resp_body.get("error", {}).get("type") if isinstance(resp_body, dict) and isinstance(resp_body.get("error"), dict) else None, + error_type=resp_body.get("error", {}).get("type") + if isinstance(resp_body, dict) + and isinstance(resp_body.get("error"), dict) + else None, error_message=_extract_error_message(response, resp_body), response_headers=dict(response.headers), ) @@ -583,7 +628,9 @@ async def send_message_stream( # 首次尝试(含 421 重试) try: - async for chunk in self._stream_with_421_retry(body, prepared_headers, request_model): + async for chunk in self._stream_with_421_retry( + body, prepared_headers, request_model + ): yield chunk return except httpx.HTTPStatusError as exc: @@ -591,7 +638,9 @@ async def send_message_stream( raise # 模型不支持时强制刷新模型列表后重试 - async for chunk in self._retry_stream_with_fresh_model(request_body, headers, request_model): + async for chunk in self._retry_stream_with_fresh_model( + request_body, headers, request_model + ): yield chunk async def _stream_with_421_retry( @@ -636,7 +685,10 @@ async def _stream_with_421_retry( return except httpx.HTTPStatusError as retry_exc: last_exc = retry_exc - if retry_exc.response is None or retry_exc.response.status_code != 421: + if ( + retry_exc.response is None + or retry_exc.response.status_code != 421 + ): raise if last_exc: @@ -650,14 +702,22 @@ async def _retry_stream_with_fresh_model( ) -> AsyncIterator[bytes]: """模型不支持时强制刷新模型列表后重试流式请求.""" retried_body, retried_headers = await self._prepare_request( - request_body, headers, force_model_refresh=True, model_refresh_reason="model_not_supported_retry", + request_body, + headers, + force_model_refresh=True, + model_refresh_reason="model_not_supported_retry", ) try: - async for chunk in self._stream_with_421_retry(retried_body, retried_headers, request_model): + async for chunk in self._stream_with_421_retry( + retried_body, retried_headers, request_model + ): yield chunk return except httpx.HTTPStatusError as exc: - if CopilotModelResolver.is_model_not_supported_response(exc.response) and exc.response is not None: + if ( + CopilotModelResolver.is_model_not_supported_response(exc.response) + and exc.response is not None + ): raise httpx.HTTPStatusError( "copilot API error: 400", request=exc.request, @@ -666,7 +726,9 @@ async def _retry_stream_with_fresh_model( requested_model=self._last_requested_model, normalized_model=self._model_resolver.last_normalized_model, resolved_model=self._last_resolved_model, - available_models=list(self._model_resolver.catalog.available_models), + available_models=list( + self._model_resolver.catalog.available_models + ), ), ) from exc raise @@ -690,7 +752,9 @@ async def probe_models(self) -> dict[str, Any]: probe["failure_reason"] = "Copilot models probe returned empty directory" return probe probe["available_models"] = available_models - probe["has_claude_opus_4_6"] = any("opus" in model and "4.6" in model for model in available_models) + probe["has_claude_opus_4_6"] = any( + "opus" in model and "4.6" in model for model in available_models + ) return probe async def close(self) -> None: diff --git a/src/coding/proxy/vendors/copilot_models.py b/src/coding/proxy/vendors/copilot_models.py index 49d6df2..ca72e60 100644 --- a/src/coding/proxy/vendors/copilot_models.py +++ b/src/coding/proxy/vendors/copilot_models.py @@ -9,17 +9,18 @@ import logging import re import time +from collections.abc import Callable from dataclasses import dataclass, field -from typing import Any, Awaitable, Callable, Protocol +from typing import Any, Protocol import httpx # ── Copilot URL / 版本常量(SSOT: model/constants.py)────── -from ..model.constants import ( +from ..model.constants import ( # noqa: F401 _COPILOT_VERSION, - _EDITOR_VERSION, _EDITOR_PLUGIN_VERSION, + _EDITOR_VERSION, _GITHUB_API_VERSION, _USER_AGENT, ) @@ -29,7 +30,9 @@ def _normalize_base_url(url: str) -> str: return url.rstrip("/") -def build_copilot_candidate_base_urls(account_type: str, configured_base_url: str) -> list[str]: +def build_copilot_candidate_base_urls( + account_type: str, configured_base_url: str +) -> list[str]: """构建 Copilot 候选基础地址列表.""" if configured_base_url.strip(): return [_normalize_base_url(configured_base_url.strip())] @@ -91,7 +94,7 @@ def normalize_copilot_requested_model(model: str) -> str: ) for prefix, family in family_aliases: if value.startswith(prefix): - remainder = value[len(prefix):] + remainder = value[len(prefix) :] major = remainder.split("-", 1)[0].split(".", 1)[0] if major.isdigit(): return f"{family}-{major}" @@ -142,13 +145,15 @@ def select_copilot_model( requested_major = copilot_model_major(requested_model) family_candidates = [ - model for model in unique_available + model + for model in unique_available if copilot_model_family(model) == requested_family and (requested_major is None or copilot_model_major(model) == requested_major) ] if not family_candidates: family_candidates = [ - model for model in unique_available + model + for model in unique_available if copilot_model_family(model) == requested_family ] if not family_candidates: @@ -334,7 +339,9 @@ async def resolve( # 优先:配置规则显式映射 if self._model_mapper is not None: mapped = self._model_mapper.map( - requested_model, vendor="copilot", default=requested_model, + requested_model, + vendor="copilot", + default=requested_model, ) if mapped != requested_model: diagnostics["requested_model"] = requested_model @@ -353,14 +360,15 @@ async def resolve( refresh_reason=refresh_reason, ) resolved_model, resolution_reason = select_copilot_model( - requested_model, available_models, + requested_model, + available_models, ) if not resolved_model: resolved_model = normalized_model or requested_model resolution_reason = ( "catalog_unavailable_fallback_to_normalized" - if not available_models else - "no_same_family_model_fallback_to_normalized" + if not available_models + else "no_same_family_model_fallback_to_normalized" ) diagnostics["requested_model"] = requested_model diff --git a/src/coding/proxy/vendors/copilot_token_manager.py b/src/coding/proxy/vendors/copilot_token_manager.py index 52a1629..0c728ad 100644 --- a/src/coding/proxy/vendors/copilot_token_manager.py +++ b/src/coding/proxy/vendors/copilot_token_manager.py @@ -48,13 +48,18 @@ def _format_body_excerpt(data: Any) -> str: @classmethod def _build_missing_token_error( - cls, data: Any, status_code: int, + cls, + data: Any, + status_code: int, ) -> TokenAcquireError: detail = cls._format_body_excerpt(data) lowered = detail.lower() capability_keys = { - "chat_enabled", "agent_mode_auto_approval", "chat_jetbrains_enabled", - "annotations_enabled", "code_quote_enabled", + "chat_enabled", + "agent_mode_auto_approval", + "chat_jetbrains_enabled", + "annotations_enabled", + "code_quote_enabled", } if isinstance(data, dict) and capability_keys.intersection(data.keys()): return TokenAcquireError.with_kind( @@ -63,9 +68,14 @@ def _build_missing_token_error( needs_reauth=True, ) needs_reauth = status_code == 401 or any( - pattern in lowered for pattern in ("bad credentials", "invalid token", "unauthorized") + pattern in lowered + for pattern in ("bad credentials", "invalid token", "unauthorized") + ) + kind = ( + TokenErrorKind.INVALID_CREDENTIALS + if needs_reauth + else TokenErrorKind.TEMPORARY ) - kind = TokenErrorKind.INVALID_CREDENTIALS if needs_reauth else TokenErrorKind.TEMPORARY return TokenAcquireError.with_kind( f"Copilot token 交换返回非预期响应: status={status_code}, detail={detail}", kind=kind, @@ -85,10 +95,14 @@ def _extract_capabilities(data: Any) -> dict[str, Any]: ) return {key: data[key] for key in capability_keys if key in data} - def _record_exchange(self, data: dict[str, Any], token_field: str, expires_in: int) -> None: + def _record_exchange( + self, data: dict[str, Any], token_field: str, expires_in: int + ) -> None: expires_at = int(time.time()) + max(expires_in, 0) self._last_exchange = CopilotExchangeDiagnostics( - raw_shape="token_refresh_in" if "token" in data else "access_token_expires_in", + raw_shape="token_refresh_in" + if "token" in data + else "access_token_expires_in", token_field=token_field, expires_in_seconds=expires_in, expires_at_unix=expires_at, diff --git a/src/coding/proxy/vendors/token_manager.py b/src/coding/proxy/vendors/token_manager.py index 3f9c0af..a9f0437 100644 --- a/src/coding/proxy/vendors/token_manager.py +++ b/src/coding/proxy/vendors/token_manager.py @@ -104,9 +104,13 @@ def mark_error( kind: TokenErrorKind = TokenErrorKind.TEMPORARY, needs_reauth: bool = False, ) -> None: - self._record_error(TokenAcquireError.with_kind( - message, kind=kind, needs_reauth=needs_reauth, - )) + self._record_error( + TokenAcquireError.with_kind( + message, + kind=kind, + needs_reauth=needs_reauth, + ) + ) def clear_diagnostics(self) -> None: self._clear_error() diff --git a/src/coding/proxy/vendors/zhipu.py b/src/coding/proxy/vendors/zhipu.py index dd442db..152d3db 100644 --- a/src/coding/proxy/vendors/zhipu.py +++ b/src/coding/proxy/vendors/zhipu.py @@ -11,14 +11,19 @@ import copy import json import logging -from typing import Any, AsyncIterator +from collections.abc import AsyncIterator +from typing import Any import httpx from ..config.schema import ZhipuConfig from ..routing.model_mapper import ModelMapper -from .base import PROXY_SKIP_HEADERS, BaseVendor, VendorCapabilities, VendorResponse -from .base import _sanitize_headers_for_synthetic_response +from .base import ( + PROXY_SKIP_HEADERS, + BaseVendor, + VendorCapabilities, + VendorResponse, +) logger = logging.getLogger(__name__) @@ -88,7 +93,8 @@ async def _prepare_request( # 剥离原始认证头(authorization / x-api-key),由下方 new_headers 重建 filtered = { - k: v for k, v in headers.items() + k: v + for k, v in headers.items() if k.lower() not in PROXY_SKIP_HEADERS and k.lower() not in ("x-api-key", "authorization") } @@ -111,7 +117,9 @@ async def send_message( ) -> VendorResponse: """最小化覆写:API key 缺失时快速返回 401 响应,其余委托基类(含 _normalize_error_response 钩子).""" if not self._api_key: - raw = json.dumps(self._missing_api_key_payload(), ensure_ascii=False).encode() + raw = json.dumps( + self._missing_api_key_payload(), ensure_ascii=False + ).encode() return VendorResponse( status_code=401, raw_body=raw, @@ -138,8 +146,12 @@ def _normalize_error_response( return VendorResponse( status_code=status_code, raw_body=raw_body, - error_type=error.get("type") if isinstance(error, dict) else "authentication_error", - error_message=error.get("message") if isinstance(error, dict) else "Zhipu API 认证失败", + error_type=error.get("type") + if isinstance(error, dict) + else "authentication_error", + error_message=error.get("message") + if isinstance(error, dict) + else "Zhipu API 认证失败", response_headers=backend_resp.response_headers, usage=backend_resp.usage, model_served=backend_resp.model_served, @@ -160,7 +172,12 @@ async def send_message_stream( raise httpx.HTTPStatusError( "zhipu API error: 401", request=request, - response=httpx.Response(401, content=raw, headers={"content-type": "application/json"}, request=request), + response=httpx.Response( + 401, + content=raw, + headers={"content-type": "application/json"}, + request=request, + ), ) try: async for chunk in super().send_message_stream(request_body, headers): @@ -206,7 +223,10 @@ def _normalize_auth_error_payload(payload: dict[str, Any] | None) -> dict[str, A payload["error"] = { **error, "type": "authentication_error", - "message": str(error.get("message") or "Zhipu API 认证失败,请检查 api_key 或兼容端点权限"), + "message": str( + error.get("message") + or "Zhipu API 认证失败,请检查 api_key 或兼容端点权限" + ), } return payload diff --git a/tests/test_antigravity.py b/tests/test_antigravity.py index 37ab25f..478af72 100644 --- a/tests/test_antigravity.py +++ b/tests/test_antigravity.py @@ -6,12 +6,15 @@ import pytest +from coding.proxy.config.schema import ( + AntigravityConfig, + FailoverConfig, + ModelMappingRule, +) +from coding.proxy.routing.model_mapper import ModelMapper from coding.proxy.vendors.antigravity import AntigravityVendor, GoogleOAuthTokenManager from coding.proxy.vendors.base import RequestCapabilities from coding.proxy.vendors.token_manager import TokenAcquireError, TokenErrorKind -from coding.proxy.config.schema import AntigravityConfig, FailoverConfig, ModelMappingRule -from coding.proxy.routing.model_mapper import ModelMapper - # --- GoogleOAuthTokenManager --- @@ -187,20 +190,25 @@ async def test_prepare_request_converts_and_injects_token(): @pytest.mark.asyncio async def test_prepare_request_resolves_model_from_mapping(): - mapper = ModelMapper([ - ModelMappingRule( - pattern="claude-sonnet-*", - target="claude-sonnet-4-6-thinking", - vendors=["antigravity"], - ) - ]) + mapper = ModelMapper( + [ + ModelMappingRule( + pattern="claude-sonnet-*", + target="claude-sonnet-4-6-thinking", + vendors=["antigravity"], + ) + ] + ) vendor = AntigravityVendor(AntigravityConfig(), FailoverConfig(), mapper) vendor._token_manager.get_token = AsyncMock(return_value="goog_token") - prepared_body, _ = await vendor._prepare_request({ - "model": "claude-sonnet-4-20250514", - "messages": [{"role": "user", "content": "Hello"}], - }, {}) + prepared_body, _ = await vendor._prepare_request( + { + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "Hello"}], + }, + {}, + ) assert prepared_body["contents"][0]["parts"] == [{"text": "Hello"}] diagnostics = vendor.get_diagnostics() @@ -237,9 +245,9 @@ def test_inherits_failover(): assert vendor.should_trigger_failover(429, None) assert not vendor.should_trigger_failover(200, None) - assert vendor.should_trigger_failover(429, { - "error": {"type": "rate_limit_error", "message": "limited"} - }) + assert vendor.should_trigger_failover( + 429, {"error": {"type": "rate_limit_error", "message": "limited"}} + ) def test_model_endpoint_in_config(): @@ -259,11 +267,13 @@ def test_mark_scope_error_if_needed(): def test_antigravity_supports_request_with_tools_thinking_and_metadata(): vendor = AntigravityVendor(AntigravityConfig(), FailoverConfig(), ModelMapper([])) - supported, reasons = vendor.supports_request(RequestCapabilities( - has_tools=True, - has_thinking=True, - has_metadata=True, - )) + supported, reasons = vendor.supports_request( + RequestCapabilities( + has_tools=True, + has_thinking=True, + has_metadata=True, + ) + ) assert supported is True assert reasons == [] @@ -278,10 +288,13 @@ async def test_prepare_request_no_anthropic_beta_header(): vendor = AntigravityVendor(config, FailoverConfig(), ModelMapper([])) vendor._token_manager.get_token = AsyncMock(return_value="tok") - _, headers = await vendor._prepare_request({ - "model": "claude-sonnet-4-20250514", - "messages": [{"role": "user", "content": "Hi"}], - }, {}) + _, headers = await vendor._prepare_request( + { + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "Hi"}], + }, + {}, + ) assert "anthropic-beta" not in headers assert headers["authorization"] == "Bearer tok" @@ -295,10 +308,13 @@ async def test_diagnostics_include_adaptations(): vendor = AntigravityVendor(config, FailoverConfig(), ModelMapper([])) vendor._token_manager.get_token = AsyncMock(return_value="tok") - await vendor._prepare_request({ - "model": "test", - "messages": [{"role": "user", "content": ""}], - }, {}) + await vendor._prepare_request( + { + "model": "test", + "messages": [{"role": "user", "content": ""}], + }, + {}, + ) diag = vendor.get_diagnostics() assert "request_adaptations" in diag @@ -310,21 +326,26 @@ async def test_diagnostics_include_adaptations(): async def test_send_message_uses_cached_resolution(): """send_message 不重复调用 map_model,使用 _prepare_request 缓存值.""" config = AntigravityConfig() - mapper = ModelMapper([ - ModelMappingRule( - pattern="claude-*", - target="resolved-model", - vendors=["antigravity"], - ), - ]) + mapper = ModelMapper( + [ + ModelMappingRule( + pattern="claude-*", + target="resolved-model", + vendors=["antigravity"], + ), + ] + ) vendor = AntigravityVendor(config, FailoverConfig(), mapper) vendor._token_manager.get_token = AsyncMock(return_value="tok") # 先调用 _prepare_request 设置缓存 - await vendor._prepare_request({ - "model": "claude-sonnet-4-20250514", - "messages": [{"role": "user", "content": "Hi"}], - }, {}) + await vendor._prepare_request( + { + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "Hi"}], + }, + {}, + ) # 验证 _last_resolved_model 已被设置 assert vendor._last_resolved_model == "resolved-model" diff --git a/tests/test_app_routes.py b/tests/test_app_routes.py index b857ad4..6913783 100644 --- a/tests/test_app_routes.py +++ b/tests/test_app_routes.py @@ -6,13 +6,12 @@ from unittest.mock import AsyncMock, MagicMock, patch import httpx -import pytest from fastapi.testclient import TestClient -from coding.proxy.vendors.token_manager import TokenAcquireError -from coding.proxy.vendors.base import VendorResponse, UsageInfo from coding.proxy.config.schema import ProxyConfig from coding.proxy.server.app import create_app +from coding.proxy.vendors.base import UsageInfo, VendorResponse +from coding.proxy.vendors.token_manager import TokenAcquireError def _make_app(primary_enabled: bool = False) -> TestClient: @@ -51,7 +50,10 @@ def test_count_tokens_no_anthropic_returns_404(): with _make_app(primary_enabled=False) as client: resp = client.post( "/v1/messages/count_tokens", - json={"model": "claude-sonnet-4-20250514", "messages": [{"role": "user", "content": "Hi"}]}, + json={ + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "Hi"}], + }, ) assert resp.status_code == 404 data = resp.json() @@ -65,10 +67,18 @@ def test_count_tokens_proxies_to_anthropic(): mock_response.status_code = 200 with _make_app(primary_enabled=True) as client: - with patch.object(httpx.AsyncClient, "post", new_callable=AsyncMock, return_value=mock_response): + with patch.object( + httpx.AsyncClient, + "post", + new_callable=AsyncMock, + return_value=mock_response, + ): resp = client.post( "/v1/messages/count_tokens?beta=true", - json={"model": "claude-sonnet-4-20250514", "messages": [{"role": "user", "content": "Hi"}]}, + json={ + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "Hi"}], + }, headers={"authorization": "Bearer sk-test"}, ) assert resp.status_code == 200 @@ -77,29 +87,39 @@ def test_count_tokens_proxies_to_anthropic(): def test_count_tokens_upstream_timeout_returns_502(): """上游超时时 count_tokens 返回 502.""" - with _make_app(primary_enabled=True) as client: - with patch.object( - httpx.AsyncClient, "post", + with ( + _make_app(primary_enabled=True) as client, + patch.object( + httpx.AsyncClient, + "post", new_callable=AsyncMock, side_effect=httpx.TimeoutException("timeout"), - ): - resp = client.post( - "/v1/messages/count_tokens", - json={"model": "claude-sonnet-4-20250514", "messages": []}, - headers={"authorization": "Bearer sk-test"}, - ) - assert resp.status_code == 502 - assert "unreachable" in resp.json()["error"]["message"] + ), + ): + resp = client.post( + "/v1/messages/count_tokens", + json={"model": "claude-sonnet-4-20250514", "messages": []}, + headers={"authorization": "Bearer sk-test"}, + ) + assert resp.status_code == 502 + assert "unreachable" in resp.json()["error"]["message"] def test_count_tokens_upstream_error_passthrough(): """上游返回 4xx/5xx 时原样透传.""" mock_response = MagicMock() - mock_response.content = b'{"error":{"type":"rate_limit_error","message":"Too many requests"}}' + mock_response.content = ( + b'{"error":{"type":"rate_limit_error","message":"Too many requests"}}' + ) mock_response.status_code = 429 with _make_app(primary_enabled=True) as client: - with patch.object(httpx.AsyncClient, "post", new_callable=AsyncMock, return_value=mock_response): + with patch.object( + httpx.AsyncClient, + "post", + new_callable=AsyncMock, + return_value=mock_response, + ): resp = client.post( "/v1/messages/count_tokens", json={"model": "claude-sonnet-4-20250514", "messages": []}, @@ -165,11 +185,13 @@ def test_copilot_models_endpoint_returns_probe_data(): for tier in app.state.router.tiers: if tier.name == "copilot": - tier.vendor.probe_models = AsyncMock(return_value={ # type: ignore[method-assign] - "probe_status": "ok", - "available_models": ["claude-opus-4.6"], - "has_claude_opus_4_6": True, - }) + tier.vendor.probe_models = AsyncMock( + return_value={ # type: ignore[method-assign] + "probe_status": "ok", + "available_models": ["claude-opus-4.6"], + "has_claude_opus_4_6": True, + } + ) break with TestClient(app) as client: @@ -199,32 +221,36 @@ def test_incompatible_request_returns_400(): supports_images=True, supports_metadata=True, ) - with patch.object( - type(app.state.router.tiers[0].vendor), - "get_capabilities", - return_value=restrictive_caps, + with ( + patch.object( + type(app.state.router.tiers[0].vendor), + "get_capabilities", + return_value=restrictive_caps, + ), + TestClient(app) as client, ): - with TestClient(app) as client: - resp = client.post( - "/v1/messages", - json={ - "model": "claude-sonnet-4-20250514", - "messages": [{"role": "user", "content": "Hi"}], - "thinking": {"type": "enabled", "budget_tokens": 1000}, - }, - ) - assert resp.status_code == 400 - data = resp.json() - assert data["error"]["type"] == "invalid_request_error" + resp = client.post( + "/v1/messages", + json={ + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "Hi"}], + "thinking": {"type": "enabled", "budget_tokens": 1000}, + }, + ) + assert resp.status_code == 400 + data = resp.json() + assert data["error"]["type"] == "invalid_request_error" def test_stream_http_status_error_returns_anthropic_sse_error(): """流式上游 HTTP 错误应转换为 Anthropic SSE error,而不是抛出 ASGI 异常.""" - app = create_app(ProxyConfig( - primary={"enabled": False}, - fallback={"enabled": True}, - database={"path": "/tmp/test-coding-proxy-routes.db"}, - )) + app = create_app( + ProxyConfig( + primary={"enabled": False}, + fallback={"enabled": True}, + database={"path": "/tmp/test-coding-proxy-routes.db"}, + ) + ) async def failing_route_stream(body, headers): request = httpx.Request("POST", "https://api.example.com/v1/messages") @@ -234,13 +260,16 @@ async def failing_route_stream(body, headers): headers={"content-type": "application/json"}, request=request, ) - raise httpx.HTTPStatusError("anthropic API error: 429", request=request, response=response) + raise httpx.HTTPStatusError( + "anthropic API error: 429", request=request, response=response + ) yield # pragma: no cover app.state.router.route_stream = failing_route_stream - with TestClient(app) as client: - with client.stream( + with ( + TestClient(app) as client, + client.stream( "POST", "/v1/messages", json={ @@ -248,8 +277,9 @@ async def failing_route_stream(body, headers): "messages": [{"role": "user", "content": "Hi"}], "stream": True, }, - ) as resp: - body = b"".join(resp.iter_bytes()).decode() + ) as resp, + ): + body = b"".join(resp.iter_bytes()).decode() assert resp.status_code == 200 assert "event: error" in body @@ -259,11 +289,13 @@ async def failing_route_stream(body, headers): def test_stream_read_error_returns_anthropic_sse_error(): """流式 ReadError 应收口为 Anthropic SSE error,不应冒泡为 500.""" - app = create_app(ProxyConfig( - primary={"enabled": False}, - fallback={"enabled": True}, - database={"path": "/tmp/test-coding-proxy-routes.db"}, - )) + app = create_app( + ProxyConfig( + primary={"enabled": False}, + fallback={"enabled": True}, + database={"path": "/tmp/test-coding-proxy-routes.db"}, + ) + ) async def failing_route_stream(body, headers): raise httpx.ReadError("socket closed") @@ -271,8 +303,9 @@ async def failing_route_stream(body, headers): app.state.router.route_stream = failing_route_stream - with TestClient(app) as client: - with client.stream( + with ( + TestClient(app) as client, + client.stream( "POST", "/v1/messages", json={ @@ -280,8 +313,9 @@ async def failing_route_stream(body, headers): "messages": [{"role": "user", "content": "Hi"}], "stream": True, }, - ) as resp: - body = b"".join(resp.iter_bytes()).decode() + ) as resp, + ): + body = b"".join(resp.iter_bytes()).decode() assert resp.status_code == 200 assert "event: error" in body @@ -291,11 +325,13 @@ async def failing_route_stream(body, headers): def test_message_read_error_returns_502(): """非流式 ReadError 应返回 502,而不是框架级 500.""" - app = create_app(ProxyConfig( - primary={"enabled": False}, - fallback={"enabled": True}, - database={"path": "/tmp/test-coding-proxy-routes.db"}, - )) + app = create_app( + ProxyConfig( + primary={"enabled": False}, + fallback={"enabled": True}, + database={"path": "/tmp/test-coding-proxy-routes.db"}, + ) + ) async def failing_route_message(body, headers): raise httpx.ReadError("socket closed") @@ -323,11 +359,13 @@ def test_stream_unexpected_exception_returns_sse_error_not_500(): 验证 catch-all Exception 处理器将未知异常转换为结构化 SSE 错误事件, 客户端可正常解析错误信息而非收到裸 HTTP 500。 """ - app = create_app(ProxyConfig( - primary={"enabled": False}, - fallback={"enabled": True}, - database={"path": "/tmp/test-coding-proxy-unexpected.db"}, - )) + app = create_app( + ProxyConfig( + primary={"enabled": False}, + fallback={"enabled": True}, + database={"path": "/tmp/test-coding-proxy-unexpected.db"}, + ) + ) async def failing_route_stream(body, headers): raise ValueError("unexpected parsing error") @@ -335,8 +373,9 @@ async def failing_route_stream(body, headers): app.state.router.route_stream = failing_route_stream - with TestClient(app) as client: - with client.stream( + with ( + TestClient(app) as client, + client.stream( "POST", "/v1/messages", json={ @@ -344,8 +383,9 @@ async def failing_route_stream(body, headers): "messages": [{"role": "user", "content": "Hi"}], "stream": True, }, - ) as resp: - body = b"".join(resp.iter_bytes()).decode() + ) as resp, + ): + body = b"".join(resp.iter_bytes()).decode() # 关键断言:必须是 200(SSE 流正常关闭),不能是 500 assert resp.status_code == 200 @@ -361,11 +401,13 @@ def test_non_stream_unexpected_exception_returns_500_json_not_raw_500(): 验证 catch-all Exception 处理器将未知异常转换为 JSON 格式的 api_error 响应, 服务端日志记录完整堆栈(exc_info=True),客户端可从响应体获取错误类型。 """ - app = create_app(ProxyConfig( - primary={"enabled": False}, - fallback={"enabled": True}, - database={"path": "/tmp/test-coding-proxy-nonstream-unexpected.db"}, - )) + app = create_app( + ProxyConfig( + primary={"enabled": False}, + fallback={"enabled": True}, + database={"path": "/tmp/test-coding-proxy-nonstream-unexpected.db"}, + ) + ) async def failing_route_message(body, headers): raise RuntimeError("internal state corruption") @@ -389,11 +431,13 @@ async def failing_route_message(body, headers): def test_messages_normalizes_vendor_tool_blocks_before_routing(): """入口应先规范化 server_tool_use,再交给高优先级 tier.""" - app = create_app(ProxyConfig( - primary={"enabled": True}, - fallback={"enabled": True}, - database={"path": "/tmp/test-coding-proxy-routes.db"}, - )) + app = create_app( + ProxyConfig( + primary={"enabled": True}, + fallback={"enabled": True}, + database={"path": "/tmp/test-coding-proxy-routes.db"}, + ) + ) captured_body = {} @@ -446,7 +490,11 @@ def test_reset_keeps_tier_order_and_next_request_hits_primary_first(): """reset 只清状态,不改 tier 顺序;下一次请求仍先尝试首层.""" config = ProxyConfig( tiers=[ - {"vendor": "anthropic", "enabled": True, "circuit_breaker": {"failure_threshold": 3}}, + { + "vendor": "anthropic", + "enabled": True, + "circuit_breaker": {"failure_threshold": 3}, + }, {"vendor": "zhipu", "enabled": True, "api_key": "sk-test"}, ], database={"path": "/tmp/test-coding-proxy-routes.db"}, @@ -456,11 +504,15 @@ def test_reset_keeps_tier_order_and_next_request_hits_primary_first(): async def primary_route_message(body, headers): call_order.append("anthropic") - return VendorResponse(status_code=200, raw_body=b"{}", usage=UsageInfo(input_tokens=1)) + return VendorResponse( + status_code=200, raw_body=b"{}", usage=UsageInfo(input_tokens=1) + ) async def fallback_route_message(body, headers): call_order.append("zhipu") - return VendorResponse(status_code=200, raw_body=b"{}", usage=UsageInfo(input_tokens=1)) + return VendorResponse( + status_code=200, raw_body=b"{}", usage=UsageInfo(input_tokens=1) + ) app.state.router.tiers[0].vendor.send_message = primary_route_message app.state.router.tiers[1].vendor.send_message = fallback_route_message @@ -470,7 +522,10 @@ async def fallback_route_message(body, headers): assert reset_resp.status_code == 200 resp = client.post( "/v1/messages", - json={"model": "claude-sonnet-4-20250514", "messages": [{"role": "user", "content": "Hi"}]}, + json={ + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "Hi"}], + }, ) assert resp.status_code == 200 @@ -484,11 +539,13 @@ def test_normalization_adaptations_logged_at_debug_level(caplog): 和 tool_result_tool_use_id_rewritten 适配不会出现在 INFO 级别日志中, 但规范化功能本身仍正确工作(ID 被重写且配对一致)。 """ - app = create_app(ProxyConfig( - primary={"enabled": False}, - fallback={"enabled": True}, - database={"path": "/tmp/test-coding-proxy-normalization-log.db"}, - )) + app = create_app( + ProxyConfig( + primary={"enabled": False}, + fallback={"enabled": True}, + database={"path": "/tmp/test-coding-proxy-normalization-log.db"}, + ) + ) captured_body = {} @@ -507,20 +564,24 @@ async def fake_route_message(body, headers): "messages": [ { "role": "assistant", - "content": [{ - "type": "tool_use", - "id": "zhipu_nonstandard_id_123", - "name": "bash", - "input": {"cmd": "pwd"}, - }], + "content": [ + { + "type": "tool_use", + "id": "zhipu_nonstandard_id_123", + "name": "bash", + "input": {"cmd": "pwd"}, + } + ], }, { "role": "user", - "content": [{ - "type": "tool_result", - "tool_use_id": "zhipu_nonstandard_id_123", - "content": "ok", - }], + "content": [ + { + "type": "tool_result", + "tool_use_id": "zhipu_nonstandard_id_123", + "content": "ok", + } + ], }, ], }, @@ -543,11 +604,13 @@ def test_non_standard_error_format_logged_at_debug(caplog): 模拟 Zhipu 返回 ``{"error":{"code":"500","message":"..."}}`` 格式, 验证 routes.py 在透传前记录格式差异信息。 """ - app = create_app(ProxyConfig( - primary={"enabled": False}, - fallback={"enabled": True}, - database={"path": "/tmp/test-coding-proxy-nonstandard-error.db"}, - )) + app = create_app( + ProxyConfig( + primary={"enabled": False}, + fallback={"enabled": True}, + database={"path": "/tmp/test-coding-proxy-nonstandard-error.db"}, + ) + ) async def zhipu_500_response(body, headers): return VendorResponse( @@ -580,11 +643,13 @@ def test_standard_error_format_no_debug_log(caplog): 确保仅对 ``code`` 非 ``type`` 的异常格式触发诊断,避免误报。 """ - app = create_app(ProxyConfig( - primary={"enabled": False}, - fallback={"enabled": True}, - database={"path": "/tmp/test-coding-proxy-standard-error.db"}, - )) + app = create_app( + ProxyConfig( + primary={"enabled": False}, + fallback={"enabled": True}, + database={"path": "/tmp/test-coding-proxy-standard-error.db"}, + ) + ) async def standard_500_response(body, headers): return VendorResponse( @@ -616,11 +681,13 @@ def test_vendor_500_passthrough_preserves_raw_body(): 验证最小化原则:proxy 不修改上游错误响应体, 客户端收到的与 vendor 返回的完全一致。 """ - app = create_app(ProxyConfig( - primary={"enabled": False}, - fallback={"enabled": True}, - database={"path": "/tmp/test-coding-proxy-passthrough.db"}, - )) + app = create_app( + ProxyConfig( + primary={"enabled": False}, + fallback={"enabled": True}, + database={"path": "/tmp/test-coding-proxy-passthrough.db"}, + ) + ) original_body = b'{"error":{"code":"500","message":"test upstream error"}}' @@ -636,7 +703,10 @@ async def upstream_500(body, headers): with TestClient(app) as client: resp = client.post( "/v1/messages", - json={"model": "claude-opus-4-6", "messages": [{"role": "user", "content": "hi"}]}, + json={ + "model": "claude-opus-4-6", + "messages": [{"role": "user", "content": "hi"}], + }, ) assert resp.status_code == 500 diff --git a/tests/test_auto_login.py b/tests/test_auto_login.py index 7561b4c..20688ff 100644 --- a/tests/test_auto_login.py +++ b/tests/test_auto_login.py @@ -28,9 +28,7 @@ def test_unexpanded_env_var_becomes_empty(tmp_path: Path): cfg_file = tmp_path / "config.yaml" cfg_file.write_text( - "copilot:\n" - " enabled: true\n" - ' github_token: "${NONEXISTENT_VAR_12345}"\n' + 'copilot:\n enabled: true\n github_token: "${NONEXISTENT_VAR_12345}"\n' ) cfg = load_config(cfg_file) assert cfg.copilot.github_token == "" @@ -65,8 +63,7 @@ async def test_skip_login_when_tier_disabled(tmp_path: Path): cfg_file = tmp_path / "config.yaml" cfg_file.write_text( - "copilot:\n enabled: false\n" - "antigravity:\n refresh_token: skip\n" + "copilot:\n enabled: false\nantigravity:\n refresh_token: skip\n" ) mock_prov = AsyncMock() @@ -76,7 +73,10 @@ async def test_skip_login_when_tier_disabled(tmp_path: Path): empty_store = _make_store() with ( - patch("coding.proxy.auth.providers.github.GitHubDeviceFlowProvider", return_value=mock_prov), + patch( + "coding.proxy.auth.providers.github.GitHubDeviceFlowProvider", + return_value=mock_prov, + ), patch("coding.proxy.auth.store.TokenStoreManager", return_value=empty_store), ): await _auto_login_if_needed(cfg_file) @@ -92,8 +92,7 @@ async def test_skip_login_when_store_has_credentials(tmp_path: Path): cfg_file = tmp_path / "config.yaml" cfg_file.write_text( - "copilot:\n enabled: true\n" - "antigravity:\n refresh_token: skip\n" + "copilot:\n enabled: true\nantigravity:\n refresh_token: skip\n" ) mock_prov = AsyncMock() @@ -103,7 +102,10 @@ async def test_skip_login_when_store_has_credentials(tmp_path: Path): valid_store = _make_store({"github": {"access_token": "ghp_valid"}}) with ( - patch("coding.proxy.auth.providers.github.GitHubDeviceFlowProvider", return_value=mock_prov), + patch( + "coding.proxy.auth.providers.github.GitHubDeviceFlowProvider", + return_value=mock_prov, + ), patch("coding.proxy.auth.store.TokenStoreManager", return_value=valid_store), ): await _auto_login_if_needed(cfg_file) @@ -126,7 +128,9 @@ async def test_skip_when_config_has_token(tmp_path: Path, monkeypatch): " refresh_token: skip\n" ) - with patch("coding.proxy.auth.providers.github.GitHubDeviceFlowProvider") as mock_cls: + with patch( + "coding.proxy.auth.providers.github.GitHubDeviceFlowProvider" + ) as mock_cls: await _auto_login_if_needed(cfg_file) mock_cls.assert_not_called() @@ -137,7 +141,9 @@ async def test_trigger_login_when_no_token(tmp_path: Path): from coding.proxy.cli import _auto_login_if_needed cfg_file = tmp_path / "config.yaml" - cfg_file.write_text("copilot:\n enabled: true\nantigravity:\n refresh_token: skip\n") + cfg_file.write_text( + "copilot:\n enabled: true\nantigravity:\n refresh_token: skip\n" + ) mock_prov = AsyncMock() mock_prov.needs_login.return_value = True @@ -146,7 +152,10 @@ async def test_trigger_login_when_no_token(tmp_path: Path): empty_store = _make_store() with ( - patch("coding.proxy.auth.providers.github.GitHubDeviceFlowProvider", return_value=mock_prov), + patch( + "coding.proxy.auth.providers.github.GitHubDeviceFlowProvider", + return_value=mock_prov, + ), patch("coding.proxy.auth.store.TokenStoreManager", return_value=empty_store), ): await _auto_login_if_needed(cfg_file) @@ -161,7 +170,9 @@ async def test_validate_stale_token_triggers_login(tmp_path: Path): from coding.proxy.cli import _auto_login_if_needed cfg_file = tmp_path / "config.yaml" - cfg_file.write_text("copilot:\n enabled: true\nantigravity:\n refresh_token: skip\n") + cfg_file.write_text( + "copilot:\n enabled: true\nantigravity:\n refresh_token: skip\n" + ) mock_prov = AsyncMock() mock_prov.needs_login = MagicMock(return_value=False) # 同步方法需用 MagicMock @@ -171,7 +182,10 @@ async def test_validate_stale_token_triggers_login(tmp_path: Path): stale_store = _make_store({"github": {"access_token": "ghp_stale"}}) with ( - patch("coding.proxy.auth.providers.github.GitHubDeviceFlowProvider", return_value=mock_prov), + patch( + "coding.proxy.auth.providers.github.GitHubDeviceFlowProvider", + return_value=mock_prov, + ), patch("coding.proxy.auth.store.TokenStoreManager", return_value=stale_store), ): await _auto_login_if_needed(cfg_file) @@ -186,7 +200,9 @@ async def test_skip_login_when_token_valid(tmp_path: Path): from coding.proxy.cli import _auto_login_if_needed cfg_file = tmp_path / "config.yaml" - cfg_file.write_text("copilot:\n enabled: true\nantigravity:\n refresh_token: skip\n") + cfg_file.write_text( + "copilot:\n enabled: true\nantigravity:\n refresh_token: skip\n" + ) mock_prov = AsyncMock() mock_prov.needs_login = MagicMock(return_value=False) # 同步方法需用 MagicMock @@ -195,7 +211,10 @@ async def test_skip_login_when_token_valid(tmp_path: Path): valid_store = _make_store({"github": {"access_token": "ghp_valid"}}) with ( - patch("coding.proxy.auth.providers.github.GitHubDeviceFlowProvider", return_value=mock_prov), + patch( + "coding.proxy.auth.providers.github.GitHubDeviceFlowProvider", + return_value=mock_prov, + ), patch("coding.proxy.auth.store.TokenStoreManager", return_value=valid_store), ): await _auto_login_if_needed(cfg_file) @@ -210,7 +229,9 @@ async def test_network_failure_does_not_block_startup(tmp_path: Path): from coding.proxy.cli import _auto_login_if_needed cfg_file = tmp_path / "config.yaml" - cfg_file.write_text("copilot:\n enabled: true\nantigravity:\n refresh_token: skip\n") + cfg_file.write_text( + "copilot:\n enabled: true\nantigravity:\n refresh_token: skip\n" + ) mock_prov = AsyncMock() mock_prov.needs_login = MagicMock(return_value=False) # 同步方法需用 MagicMock @@ -219,7 +240,10 @@ async def test_network_failure_does_not_block_startup(tmp_path: Path): store = _make_store({"github": {"access_token": "ghp_maybe_valid"}}) with ( - patch("coding.proxy.auth.providers.github.GitHubDeviceFlowProvider", return_value=mock_prov), + patch( + "coding.proxy.auth.providers.github.GitHubDeviceFlowProvider", + return_value=mock_prov, + ), patch("coding.proxy.auth.store.TokenStoreManager", return_value=store), ): await _auto_login_if_needed(cfg_file) @@ -233,7 +257,9 @@ async def test_login_failure_closes_provider(tmp_path: Path): from coding.proxy.cli import _auto_login_if_needed cfg_file = tmp_path / "config.yaml" - cfg_file.write_text("copilot:\n enabled: true\nantigravity:\n refresh_token: skip\n") + cfg_file.write_text( + "copilot:\n enabled: true\nantigravity:\n refresh_token: skip\n" + ) mock_prov = AsyncMock() mock_prov.needs_login.return_value = True @@ -242,7 +268,10 @@ async def test_login_failure_closes_provider(tmp_path: Path): store = _make_store() with ( - patch("coding.proxy.auth.providers.github.GitHubDeviceFlowProvider", return_value=mock_prov), + patch( + "coding.proxy.auth.providers.github.GitHubDeviceFlowProvider", + return_value=mock_prov, + ), patch("coding.proxy.auth.store.TokenStoreManager", return_value=store), ): await _auto_login_if_needed(cfg_file) @@ -256,18 +285,24 @@ async def test_antigravity_trigger_login_when_no_refresh_token(tmp_path: Path): from coding.proxy.cli import _auto_login_if_needed cfg_file = tmp_path / "config.yaml" - cfg_file.write_text("copilot:\n github_token: skip\nantigravity:\n enabled: true\n") + cfg_file.write_text( + "copilot:\n github_token: skip\nantigravity:\n enabled: true\n" + ) mock_prov = AsyncMock() mock_prov.needs_login.return_value = True mock_prov.login.return_value = ProviderTokens( - access_token="goog_access", refresh_token="goog_refresh", + access_token="goog_access", + refresh_token="goog_refresh", ) empty_store = _make_store() with ( - patch("coding.proxy.auth.providers.google.GoogleOAuthProvider", return_value=mock_prov), + patch( + "coding.proxy.auth.providers.google.GoogleOAuthProvider", + return_value=mock_prov, + ), patch("coding.proxy.auth.store.TokenStoreManager", return_value=empty_store), ): await _auto_login_if_needed(cfg_file) @@ -282,15 +317,19 @@ async def test_antigravity_refresh_expired_access_token_before_login(tmp_path: P from coding.proxy.cli import _auto_login_if_needed cfg_file = tmp_path / "config.yaml" - cfg_file.write_text("copilot:\n github_token: skip\nantigravity:\n enabled: true\n") + cfg_file.write_text( + "copilot:\n github_token: skip\nantigravity:\n enabled: true\n" + ) - expired_store = _make_store({ - "google": { - "access_token": "goog_old", - "refresh_token": "goog_refresh", - "expires_at": 1, - }, - }) + expired_store = _make_store( + { + "google": { + "access_token": "goog_old", + "refresh_token": "goog_refresh", + "expires_at": 1, + }, + } + ) mock_prov = AsyncMock() mock_prov.needs_login = MagicMock(return_value=False) @@ -301,7 +340,10 @@ async def test_antigravity_refresh_expired_access_token_before_login(tmp_path: P ) with ( - patch("coding.proxy.auth.providers.google.GoogleOAuthProvider", return_value=mock_prov), + patch( + "coding.proxy.auth.providers.google.GoogleOAuthProvider", + return_value=mock_prov, + ), patch("coding.proxy.auth.store.TokenStoreManager", return_value=expired_store), ): await _auto_login_if_needed(cfg_file) @@ -317,15 +359,19 @@ async def test_antigravity_refresh_failure_falls_back_to_login(tmp_path: Path): from coding.proxy.cli import _auto_login_if_needed cfg_file = tmp_path / "config.yaml" - cfg_file.write_text("copilot:\n github_token: skip\nantigravity:\n enabled: true\n") + cfg_file.write_text( + "copilot:\n github_token: skip\nantigravity:\n enabled: true\n" + ) - expired_store = _make_store({ - "google": { - "access_token": "goog_old", - "refresh_token": "goog_refresh", - "expires_at": 1, - }, - }) + expired_store = _make_store( + { + "google": { + "access_token": "goog_old", + "refresh_token": "goog_refresh", + "expires_at": 1, + }, + } + ) mock_prov = AsyncMock() mock_prov.needs_login = MagicMock(return_value=False) @@ -336,7 +382,10 @@ async def test_antigravity_refresh_failure_falls_back_to_login(tmp_path: Path): ) with ( - patch("coding.proxy.auth.providers.google.GoogleOAuthProvider", return_value=mock_prov), + patch( + "coding.proxy.auth.providers.google.GoogleOAuthProvider", + return_value=mock_prov, + ), patch("coding.proxy.auth.store.TokenStoreManager", return_value=expired_store), ): await _auto_login_if_needed(cfg_file) @@ -352,10 +401,7 @@ def test_build_token_store_uses_configured_path(tmp_path: Path): cfg_file = tmp_path / "config.yaml" store_path = tmp_path / "nested" / "tokens.json" - cfg_file.write_text( - "auth:\n" - f' token_store_path: "{store_path}"\n' - ) + cfg_file.write_text(f'auth:\n token_store_path: "{store_path}"\n') _, store = _build_token_store(cfg_file) diff --git a/tests/test_cli_usage.py b/tests/test_cli_usage.py index a99bca2..0f71039 100644 --- a/tests/test_cli_usage.py +++ b/tests/test_cli_usage.py @@ -105,6 +105,6 @@ def test_vendor_with_days_and_model(self, _isolate_cli_deps): assert result.exit_code == 0 call_args = mock_show.call_args.args # positional: (logger, days, vendor, model, pricing_table) - assert call_args[1] == 30 # days + assert call_args[1] == 30 # days assert call_args[2] == "copilot" # vendor assert call_args[3] == "claude-*" # model diff --git a/tests/test_compat.py b/tests/test_compat.py index 7423657..d36fd74 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -25,7 +25,14 @@ def test_build_canonical_request_extracts_session_and_semantics(): {"role": "user", "content": [{"type": "text", "text": "hello"}]}, { "role": "assistant", - "content": [{"type": "tool_use", "id": "toolu_1", "name": "read_file", "input": {"path": "a"}}], + "content": [ + { + "type": "tool_use", + "id": "toolu_1", + "name": "read_file", + "input": {"path": "a"}, + } + ], }, ], }, @@ -52,7 +59,9 @@ async def test_compat_session_store_roundtrip(tmp_path: Path): trace_id="trace_1", tool_call_map={"toolu_1": "call_1"}, thought_signature_map={"sig_1": "provider_sig_1"}, - provider_state={"copilot": {"compat_mode": CompatibilityStatus.SIMULATED.value}}, + provider_state={ + "copilot": {"compat_mode": CompatibilityStatus.SIMULATED.value} + }, ) await store.upsert(record) @@ -62,7 +71,10 @@ async def test_compat_session_store_roundtrip(tmp_path: Path): assert loaded.trace_id == "trace_1" assert loaded.tool_call_map == {"toolu_1": "call_1"} assert loaded.thought_signature_map == {"sig_1": "provider_sig_1"} - assert loaded.provider_state["copilot"]["compat_mode"] == CompatibilityStatus.SIMULATED.value + assert ( + loaded.provider_state["copilot"]["compat_mode"] + == CompatibilityStatus.SIMULATED.value + ) await store.close() diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py index f652335..8934bf7 100644 --- a/tests/test_config_loader.py +++ b/tests/test_config_loader.py @@ -2,7 +2,11 @@ from pathlib import Path -from coding.proxy.config.loader import _deep_merge, _get_example_config_path, load_config +from coding.proxy.config.loader import ( + _deep_merge, + _get_example_config_path, + load_config, +) def test_load_from_explicit_path(tmp_path: Path): @@ -127,7 +131,9 @@ def test_antigravity_config_defaults(): assert cfg.antigravity.client_id == "" assert cfg.antigravity.client_secret == "" assert cfg.antigravity.refresh_token == "" - assert cfg.antigravity.base_url == "https://generativelanguage.googleapis.com/v1beta" + assert ( + cfg.antigravity.base_url == "https://generativelanguage.googleapis.com/v1beta" + ) assert cfg.antigravity.model_endpoint == "models/claude-sonnet-4-20250514" @@ -212,11 +218,11 @@ def test_vendors_custom_order(tmp_path: Path, monkeypatch): cfg_file = tmp_path / "config.yaml" cfg_file.write_text( "vendors:\n" - ' - vendor: zhipu\n' + " - vendor: zhipu\n" ' api_key: "${ZHIPU_KEY}"\n' - ' circuit_breaker:\n' - ' failure_threshold: 5\n' - ' - vendor: anthropic\n' + " circuit_breaker:\n" + " failure_threshold: 5\n" + " - vendor: anthropic\n" ' base_url: "https://api.anthropic.com"\n' ) cfg = load_config(cfg_file) @@ -376,6 +382,7 @@ def test_get_example_config_path_returns_path(): def test_get_example_config_path_missing_returns_none(monkeypatch): """_get_example_config_path 被mock为返回None时正确降级.""" import coding.proxy.config.loader as loader_module + monkeypatch.setattr(loader_module, "_get_example_config_path", lambda: None) assert loader_module._get_example_config_path() is None @@ -432,11 +439,7 @@ def test_load_config_full_user_override(tmp_path: Path): """用户提供完整配置时,example 默认被完全覆盖.""" cfg_file = tmp_path / "config.yaml" cfg_file.write_text( - "server:\n" - " host: '0.0.0.0'\n" - " port: 8000\n" - "vendors: []\n" - "pricing: []\n" + "server:\n host: '0.0.0.0'\n port: 8000\nvendors: []\npricing: []\n" ) cfg = load_config(cfg_file) assert cfg.server.host == "0.0.0.0" diff --git a/tests/test_convert_request.py b/tests/test_convert_request.py index f9e3ce8..005ffad 100644 --- a/tests/test_convert_request.py +++ b/tests/test_convert_request.py @@ -10,80 +10,115 @@ def _body(result): def test_simple_text_message(): - result = _body(convert_request({ - "model": "claude-sonnet-4-20250514", - "messages": [{"role": "user", "content": "Hello"}], - "max_tokens": 100, - })) + result = _body( + convert_request( + { + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 100, + } + ) + ) assert result["contents"] == [{"role": "user", "parts": [{"text": "Hello"}]}] assert result["generationConfig"]["maxOutputTokens"] == 100 assert "model" not in result def test_multi_turn_conversation(): - result = _body(convert_request({ - "messages": [ - {"role": "user", "content": "What is 2+2?"}, - {"role": "assistant", "content": "4"}, - {"role": "user", "content": "And 3+3?"}, - ], - })) + result = _body( + convert_request( + { + "messages": [ + {"role": "user", "content": "What is 2+2?"}, + {"role": "assistant", "content": "4"}, + {"role": "user", "content": "And 3+3?"}, + ], + } + ) + ) assert len(result["contents"]) == 3 assert result["contents"][1]["role"] == "model" assert result["contents"][1]["parts"] == [{"text": "4"}] def test_system_and_image_blocks(): - result = _body(convert_request({ - "system": [{"type": "text", "text": "Rule 1"}], - "messages": [{ - "role": "user", - "content": [ - {"type": "text", "text": "What is this?"}, - {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "xxx"}}, - ], - }], - })) + result = _body( + convert_request( + { + "system": [{"type": "text", "text": "Rule 1"}], + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is this?"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "xxx", + }, + }, + ], + } + ], + } + ) + ) assert result["systemInstruction"]["parts"] == [{"text": "Rule 1"}] assert result["contents"][0]["parts"][1]["inlineData"]["mimeType"] == "image/png" def test_tool_use_and_tool_result_blocks(): - result = _body(convert_request({ - "messages": [ - { - "role": "assistant", - "content": [{ - "type": "tool_use", - "id": "toolu_123", - "name": "get_weather", - "input": {"city": "Tokyo"}, - }], - }, + result = _body( + convert_request( { - "role": "user", - "content": [{ - "type": "tool_result", - "tool_use_id": "toolu_123", - "content": "Sunny, 25°C", - }], - }, - ], - })) + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_123", + "name": "get_weather", + "input": {"city": "Tokyo"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_123", + "content": "Sunny, 25°C", + } + ], + }, + ], + } + ) + ) assert result["contents"][0]["parts"][0]["functionCall"]["name"] == "get_weather" - assert result["contents"][1]["parts"][0]["functionResponse"]["name"] == "get_weather" + assert ( + result["contents"][1]["parts"][0]["functionResponse"]["name"] == "get_weather" + ) def test_generation_config_and_thinking_mapping(): - result = _body(convert_request({ - "messages": [{"role": "user", "content": "Hi"}], - "max_tokens": 2048, - "temperature": 0.7, - "top_p": 0.9, - "top_k": 40, - "stop_sequences": ["END", "STOP"], - "extended_thinking": {"budget_tokens": 1000, "effort": "medium"}, - })) + result = _body( + convert_request( + { + "messages": [{"role": "user", "content": "Hi"}], + "max_tokens": 2048, + "temperature": 0.7, + "top_p": 0.9, + "top_k": 40, + "stop_sequences": ["END", "STOP"], + "extended_thinking": {"budget_tokens": 1000, "effort": "medium"}, + } + ) + ) gc = result["generationConfig"] assert gc["maxOutputTokens"] == 2048 assert gc["temperature"] == 0.7 @@ -95,42 +130,64 @@ def test_generation_config_and_thinking_mapping(): def test_tools_and_tool_choice_are_mapped(): - result = _body(convert_request({ - "messages": [{"role": "user", "content": "Hi"}], - "tools": [{"name": "tool1", "description": "desc", "input_schema": {"type": "object"}}], - "tool_choice": {"type": "tool", "name": "tool1"}, - })) + result = _body( + convert_request( + { + "messages": [{"role": "user", "content": "Hi"}], + "tools": [ + { + "name": "tool1", + "description": "desc", + "input_schema": {"type": "object"}, + } + ], + "tool_choice": {"type": "tool", "name": "tool1"}, + } + ) + ) assert result["tools"][0]["functionDeclarations"][0]["name"] == "tool1" - assert result["toolConfig"]["functionCallingConfig"]["allowedFunctionNames"] == ["tool1"] + assert result["toolConfig"]["functionCallingConfig"]["allowedFunctionNames"] == [ + "tool1" + ] def test_search_tool_maps_to_google_search(): - result = convert_request({ - "messages": [{"role": "user", "content": "Hi"}], - "tools": [{"name": "web_search_20250305"}], - }) + result = convert_request( + { + "messages": [{"role": "user", "content": "Hi"}], + "tools": [{"name": "web_search_20250305"}], + } + ) assert result.body["tools"][0] == {"googleSearch": {}} assert "search_tool_mapped_to_google_search" in result.adaptations def test_metadata_is_recorded_as_adaptation(): - result = convert_request({ - "messages": [{"role": "user", "content": "Hi"}], - "metadata": {"user_id": "u-1"}, - }) + result = convert_request( + { + "messages": [{"role": "user", "content": "Hi"}], + "metadata": {"user_id": "u-1"}, + } + ) assert "metadata_user_id_not_forwarded" in result.adaptations def test_unknown_content_block_type_skipped(): - result = _body(convert_request({ - "messages": [{ - "role": "user", - "content": [ - {"type": "text", "text": "Hello"}, - {"type": "unknown_type", "data": "???"}, - ], - }], - })) + result = _body( + convert_request( + { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Hello"}, + {"type": "unknown_type", "data": "???"}, + ], + } + ], + } + ) + ) assert result["contents"][0]["parts"] == [{"text": "Hello"}] @@ -139,30 +196,46 @@ def test_unknown_content_block_type_skipped(): def test_tool_choice_none_maps_to_NONE(): """tool_choice {type: "none"} → Gemini mode NONE.""" - result = _body(convert_request({ - "messages": [{"role": "user", "content": "Hi"}], - "tools": [{"name": "tool1", "input_schema": {"type": "object"}}], - "tool_choice": {"type": "none"}, - })) + result = _body( + convert_request( + { + "messages": [{"role": "user", "content": "Hi"}], + "tools": [{"name": "tool1", "input_schema": {"type": "object"}}], + "tool_choice": {"type": "none"}, + } + ) + ) assert result["toolConfig"]["functionCallingConfig"]["mode"] == "NONE" def test_tool_choice_string_none(): """tool_choice "none" 字符串 → mode NONE.""" - result = _body(convert_request({ - "messages": [{"role": "user", "content": "Hi"}], - "tools": [{"name": "tool1", "input_schema": {"type": "object"}}], - "tool_choice": "none", - })) + result = _body( + convert_request( + { + "messages": [{"role": "user", "content": "Hi"}], + "tools": [{"name": "tool1", "input_schema": {"type": "object"}}], + "tool_choice": "none", + } + ) + ) assert result["toolConfig"]["functionCallingConfig"]["mode"] == "NONE" def test_cache_control_stripped_from_system(): """system 中的 cache_control 被剥离并记录.""" - result = convert_request({ - "system": [{"type": "text", "text": "Rule 1", "cache_control": {"type": "ephemeral"}}], - "messages": [{"role": "user", "content": "Hi"}], - }) + result = convert_request( + { + "system": [ + { + "type": "text", + "text": "Rule 1", + "cache_control": {"type": "ephemeral"}, + } + ], + "messages": [{"role": "user", "content": "Hi"}], + } + ) assert "cache_control_stripped_from_system" in result.adaptations si_parts = result.body["systemInstruction"]["parts"] assert "cache_control" not in si_parts[0] @@ -170,22 +243,32 @@ def test_cache_control_stripped_from_system(): def test_cache_control_stripped_from_content(): """message content 中的 cache_control 被剥离.""" - result = convert_request({ - "messages": [{ - "role": "user", - "content": [ - {"type": "text", "text": "Hello", "cache_control": {"type": "ephemeral"}}, + result = convert_request( + { + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Hello", + "cache_control": {"type": "ephemeral"}, + }, + ], + } ], - }], - }) + } + ) assert "cache_control_stripped_from_content" in result.adaptations def test_empty_user_message_produces_padding(): """空 user message 触发 contents padding.""" - result = convert_request({ - "messages": [{"role": "user", "content": ""}], - }) + result = convert_request( + { + "messages": [{"role": "user", "content": ""}], + } + ) assert "empty_contents_padded" in result.adaptations assert len(result.body["contents"]) == 1 assert result.body["contents"][0]["parts"][0]["text"] == " " @@ -193,79 +276,122 @@ def test_empty_user_message_produces_padding(): def test_all_messages_empty_produces_padding(): """所有消息内容为空时仍保证 contents 非空.""" - result = convert_request({ - "messages": [ - {"role": "user", "content": ""}, - {"role": "assistant", "content": ""}, - ], - }) + result = convert_request( + { + "messages": [ + {"role": "user", "content": ""}, + {"role": "assistant", "content": ""}, + ], + } + ) assert "empty_contents_padded" in result.adaptations assert len(result.body["contents"]) >= 1 def test_response_format_json_object(): """response_format json_object → responseMimeType application/json.""" - result = _body(convert_request({ - "messages": [{"role": "user", "content": "Hi"}], - "response_format": {"type": "json_object"}, - })) + result = _body( + convert_request( + { + "messages": [{"role": "user", "content": "Hi"}], + "response_format": {"type": "json_object"}, + } + ) + ) assert result["generationConfig"]["responseMimeType"] == "application/json" def test_response_format_json_schema(): """response_format json_schema type → responseMimeType application/json.""" - result = _body(convert_request({ - "messages": [{"role": "user", "content": "Hi"}], - "response_format": {"type": "json_schema", "json_schema": {"type": "object"}}, - })) + result = _body( + convert_request( + { + "messages": [{"role": "user", "content": "Hi"}], + "response_format": { + "type": "json_schema", + "json_schema": {"type": "object"}, + }, + } + ) + ) assert result["generationConfig"]["responseMimeType"] == "application/json" - assert "response_format_json_mode" in convert_request( - {"messages": [{"role": "user", "content": "Hi"}], - "response_format": {"type": "json_schema", "json_schema": {"type": "object"}}, - }).adaptations + assert ( + "response_format_json_mode" + in convert_request( + { + "messages": [{"role": "user", "content": "Hi"}], + "response_format": { + "type": "json_schema", + "json_schema": {"type": "object"}, + }, + } + ).adaptations + ) def test_thinking_without_budget_defaults(): """thinking 启用但无 budget_tokens 时默认 10000.""" - result = _body(convert_request({ - "messages": [{"role": "user", "content": "Hi"}], - "thinking": {"type": "enabled"}, - })) + result = _body( + convert_request( + { + "messages": [{"role": "user", "content": "Hi"}], + "thinking": {"type": "enabled"}, + } + ) + ) assert result["generationConfig"]["thinkingConfig"]["thinkingBudget"] == 10000 - assert "thinking_budget_defaulted_to_10k" in convert_request( - {"messages": [{"role": "user", "content": "Hi"}], "thinking": {"type": "enabled"}}, - ).adaptations + assert ( + "thinking_budget_defaulted_to_10k" + in convert_request( + { + "messages": [{"role": "user", "content": "Hi"}], + "thinking": {"type": "enabled"}, + }, + ).adaptations + ) def test_large_tool_set_warning_logged(): """超过 100 个工具时记录 adaptation.""" tools = [ - {"name": f"tool_{i}", "input_schema": {"type": "object"}} - for i in range(150) + {"name": f"tool_{i}", "input_schema": {"type": "object"}} for i in range(150) ] - result = convert_request({ - "messages": [{"role": "user", "content": "Hi"}], - "tools": tools, - }) + result = convert_request( + { + "messages": [{"role": "user", "content": "Hi"}], + "tools": tools, + } + ) assert any("large_tool_set_" in a for a in result.adaptations) assert "150" in [a for a in result.adaptations if "large_tool_set_" in a][0] def test_tool_result_with_image_content(): """tool_result 含图片块时降级为占位符.""" - result = _body(convert_request({ - "messages": [{ - "role": "user", - "content": [{ - "type": "tool_result", - "tool_use_id": "toolu_1", - "content": [ - {"type": "text", "text": "result text"}, - {"type": "image", "source": {"type": "base64", "data": "abc123"}}, + result = _body( + convert_request( + { + "messages": [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_1", + "content": [ + {"type": "text", "text": "result text"}, + { + "type": "image", + "source": {"type": "base64", "data": "abc123"}, + }, + ], + } + ], + } ], - }], - }], - })) + } + ) + ) fr = result["contents"][0]["parts"][0]["functionResponse"]["response"]["result"] assert "[image]" in fr assert "result text" in fr @@ -273,9 +399,13 @@ def test_tool_result_with_image_content(): def test_safety_settings_in_result(): """默认 safetySettings 存在于转换结果中.""" - result = _body(convert_request({ - "messages": [{"role": "user", "content": "Hi"}], - })) + result = _body( + convert_request( + { + "messages": [{"role": "user", "content": "Hi"}], + } + ) + ) assert "safetySettings" in result assert len(result["safetySettings"]) == 4 categories = {s["category"] for s in result["safetySettings"]} @@ -286,33 +416,41 @@ def test_safety_settings_in_result(): def test_custom_safety_settings_override(): """自定义 safety_settings 覆盖默认值.""" custom = {"HARM_CATEGORY_HARASSMENT": "BLOCK_MEDIUM_AND_ABOVE"} - result = _body(convert_request( - {"messages": [{"role": "user", "content": "Hi"}]}, - safety_settings=custom, - )) + result = _body( + convert_request( + {"messages": [{"role": "user", "content": "Hi"}]}, + safety_settings=custom, + ) + ) assert len(result["safetySettings"]) == 1 assert result["safetySettings"][0]["threshold"] == "BLOCK_MEDIUM_AND_ABOVE" def test_metadata_empty_dict(): """空 metadata dict 记录为 adaptation.""" - result = convert_request({ - "messages": [{"role": "user", "content": "Hi"}], - "metadata": {}, - }) + result = convert_request( + { + "messages": [{"role": "user", "content": "Hi"}], + "metadata": {}, + } + ) assert "metadata_ignored" in result.adaptations def test_multiple_search_tools(): """多个搜索工具映射为单一 googleSearch.""" - result = _body(convert_request({ - "messages": [{"role": "user", "content": "Hi"}], - "tools": [ - {"name": "web_search"}, - {"name": "google_search"}, - {"name": "tool_normal", "input_schema": {"type": "object"}}, - ], - })) + result = _body( + convert_request( + { + "messages": [{"role": "user", "content": "Hi"}], + "tools": [ + {"name": "web_search"}, + {"name": "google_search"}, + {"name": "tool_normal", "input_schema": {"type": "object"}}, + ], + } + ) + ) search_tools = [t for t in result["tools"] if "googleSearch" in t] assert len(search_tools) == 1 func_decls = result["tools"][0].get("functionDeclarations", []) diff --git a/tests/test_convert_response.py b/tests/test_convert_response.py index a618856..cf0ba20 100644 --- a/tests/test_convert_response.py +++ b/tests/test_convert_response.py @@ -7,20 +7,21 @@ extract_usage, ) - # --- convert_response --- def test_simple_text_response(): """简单文本响应转换.""" gemini = { - "candidates": [{ - "content": { - "parts": [{"text": "Hello, world!"}], - "role": "model", - }, - "finishReason": "STOP", - }], + "candidates": [ + { + "content": { + "parts": [{"text": "Hello, world!"}], + "role": "model", + }, + "finishReason": "STOP", + } + ], "usageMetadata": { "promptTokenCount": 10, "candidatesTokenCount": 5, @@ -40,10 +41,12 @@ def test_simple_text_response(): def test_max_tokens_finish_reason(): """MAX_TOKENS finishReason 映射.""" gemini = { - "candidates": [{ - "content": {"parts": [{"text": "partial"}], "role": "model"}, - "finishReason": "MAX_TOKENS", - }], + "candidates": [ + { + "content": {"parts": [{"text": "partial"}], "role": "model"}, + "finishReason": "MAX_TOKENS", + } + ], "usageMetadata": {"promptTokenCount": 5, "candidatesTokenCount": 100}, } result = convert_response(gemini) @@ -53,10 +56,12 @@ def test_max_tokens_finish_reason(): def test_safety_finish_reason(): """SAFETY finishReason → end_turn.""" gemini = { - "candidates": [{ - "content": {"parts": [{"text": ""}], "role": "model"}, - "finishReason": "SAFETY", - }], + "candidates": [ + { + "content": {"parts": [{"text": ""}], "role": "model"}, + "finishReason": "SAFETY", + } + ], "usageMetadata": {}, } result = convert_response(gemini) @@ -66,10 +71,12 @@ def test_safety_finish_reason(): def test_unknown_finish_reason(): """未知 finishReason 默认 end_turn.""" gemini = { - "candidates": [{ - "content": {"parts": [{"text": "ok"}], "role": "model"}, - "finishReason": "UNKNOWN_NEW_VALUE", - }], + "candidates": [ + { + "content": {"parts": [{"text": "ok"}], "role": "model"}, + "finishReason": "UNKNOWN_NEW_VALUE", + } + ], "usageMetadata": {}, } result = convert_response(gemini) @@ -79,18 +86,22 @@ def test_unknown_finish_reason(): def test_function_call_response(): """functionCall 响应 → tool_use 内容块.""" gemini = { - "candidates": [{ - "content": { - "parts": [{ - "functionCall": { - "name": "get_weather", - "args": {"city": "Paris"}, - } - }], - "role": "model", - }, - "finishReason": "STOP", - }], + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "name": "get_weather", + "args": {"city": "Paris"}, + } + } + ], + "role": "model", + }, + "finishReason": "STOP", + } + ], "usageMetadata": {"promptTokenCount": 20, "candidatesTokenCount": 10}, } result = convert_response(gemini) @@ -105,16 +116,18 @@ def test_function_call_response(): def test_multi_part_response(): """多 parts 响应.""" gemini = { - "candidates": [{ - "content": { - "parts": [ - {"text": "Here's the result: "}, - {"text": "42"}, - ], - "role": "model", - }, - "finishReason": "STOP", - }], + "candidates": [ + { + "content": { + "parts": [ + {"text": "Here's the result: "}, + {"text": "42"}, + ], + "role": "model", + }, + "finishReason": "STOP", + } + ], "usageMetadata": {}, } result = convert_response(gemini) @@ -134,10 +147,12 @@ def test_empty_candidates(): def test_custom_request_id(): """自定义 request_id.""" gemini = { - "candidates": [{ - "content": {"parts": [{"text": "hi"}], "role": "model"}, - "finishReason": "STOP", - }], + "candidates": [ + { + "content": {"parts": [{"text": "hi"}], "role": "model"}, + "finishReason": "STOP", + } + ], "usageMetadata": {}, } result = convert_response(gemini, request_id="msg_custom_123") @@ -147,10 +162,12 @@ def test_custom_request_id(): def test_auto_generated_id(): """自动生成 msg_id.""" gemini = { - "candidates": [{ - "content": {"parts": [{"text": "hi"}], "role": "model"}, - "finishReason": "STOP", - }], + "candidates": [ + { + "content": {"parts": [{"text": "hi"}], "role": "model"}, + "finishReason": "STOP", + } + ], "usageMetadata": {}, } result = convert_response(gemini) @@ -198,13 +215,21 @@ def test_extract_usage_partial(): def test_thinking_response_with_signature(): """thoughtSignature 正确映射为 signature 字段.""" gemini = { - "candidates": [{ - "content": { - "parts": [{"text": "Let me think", "thought": True, "thoughtSignature": "sig_abc"}], - "role": "model", - }, - "finishReason": "STOP", - }], + "candidates": [ + { + "content": { + "parts": [ + { + "text": "Let me think", + "thought": True, + "thoughtSignature": "sig_abc", + } + ], + "role": "model", + }, + "finishReason": "STOP", + } + ], "usageMetadata": {}, } result = convert_response(gemini) @@ -216,19 +241,23 @@ def test_thinking_response_with_signature(): def test_function_call_with_id_preserved(): """functionCall.id 被保留到 tool_use.id.""" gemini = { - "candidates": [{ - "content": { - "parts": [{ - "functionCall": { - "id": "fc_custom_1", - "name": "search", - "args": {"q": "test"}, - } - }], - "role": "model", - }, - "finishReason": "STOP", - }], + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "id": "fc_custom_1", + "name": "search", + "args": {"q": "test"}, + } + } + ], + "role": "model", + }, + "finishReason": "STOP", + } + ], "usageMetadata": {}, } result = convert_response(gemini) @@ -238,13 +267,15 @@ def test_function_call_with_id_preserved(): def test_text_part_with_signature_only(): """text 为空但有 signature 时生成 thinking 块.""" gemini = { - "candidates": [{ - "content": { - "parts": [{"text": "", "thoughtSignature": "sig_only"}], - "role": "model", - }, - "finishReason": "STOP", - }], + "candidates": [ + { + "content": { + "parts": [{"text": "", "thoughtSignature": "sig_only"}], + "role": "model", + }, + "finishReason": "STOP", + } + ], "usageMetadata": {}, } result = convert_response(gemini) @@ -256,16 +287,18 @@ def test_text_part_with_signature_only(): def test_mixed_text_and_function_call(): """文本 + functionCall 混合响应生成两个 content blocks.""" gemini = { - "candidates": [{ - "content": { - "parts": [ - {"text": "I'll search for you."}, - {"functionCall": {"name": "search", "args": {"q": "gemini"}}}, - ], - "role": "model", - }, - "finishReason": "STOP", - }], + "candidates": [ + { + "content": { + "parts": [ + {"text": "I'll search for you."}, + {"functionCall": {"name": "search", "args": {"q": "gemini"}}}, + ], + "role": "model", + }, + "finishReason": "STOP", + } + ], "usageMetadata": {}, } result = convert_response(gemini) diff --git a/tests/test_convert_sse.py b/tests/test_convert_sse.py index f799fd8..dd4f02d 100644 --- a/tests/test_convert_sse.py +++ b/tests/test_convert_sse.py @@ -40,13 +40,17 @@ def _parse_events(raw_bytes_list: list[bytes]) -> list[dict]: @pytest.mark.asyncio async def test_single_chunk_with_finish(): """单个包含文本和 finishReason 的 chunk.""" - gemini_data = json.dumps({ - "candidates": [{ - "content": {"parts": [{"text": "Hello!"}], "role": "model"}, - "finishReason": "STOP", - }], - "usageMetadata": {"promptTokenCount": 5, "candidatesTokenCount": 2}, - }) + gemini_data = json.dumps( + { + "candidates": [ + { + "content": {"parts": [{"text": "Hello!"}], "role": "model"}, + "finishReason": "STOP", + } + ], + "usageMetadata": {"promptTokenCount": 5, "candidatesTokenCount": 2}, + } + ) collected = [] async for chunk in adapt_sse_stream( @@ -85,24 +89,36 @@ async def test_single_chunk_with_finish(): @pytest.mark.asyncio async def test_multi_chunk_stream(): """多个 chunk 的流式响应.""" - chunk1 = json.dumps({ - "candidates": [{ - "content": {"parts": [{"text": "Hello"}], "role": "model"}, - }], - "usageMetadata": {"promptTokenCount": 10}, - }) - chunk2 = json.dumps({ - "candidates": [{ - "content": {"parts": [{"text": " World"}], "role": "model"}, - }], - }) - chunk3 = json.dumps({ - "candidates": [{ - "content": {"parts": [{"text": "!"}], "role": "model"}, - "finishReason": "STOP", - }], - "usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 8}, - }) + chunk1 = json.dumps( + { + "candidates": [ + { + "content": {"parts": [{"text": "Hello"}], "role": "model"}, + } + ], + "usageMetadata": {"promptTokenCount": 10}, + } + ) + chunk2 = json.dumps( + { + "candidates": [ + { + "content": {"parts": [{"text": " World"}], "role": "model"}, + } + ], + } + ) + chunk3 = json.dumps( + { + "candidates": [ + { + "content": {"parts": [{"text": "!"}], "role": "model"}, + "finishReason": "STOP", + } + ], + "usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 8}, + } + ) collected = [] async for chunk in adapt_sse_stream( @@ -128,13 +144,17 @@ async def test_multi_chunk_stream(): @pytest.mark.asyncio async def test_max_tokens_finish_reason(): """MAX_TOKENS finishReason 映射为 max_tokens.""" - gemini_data = json.dumps({ - "candidates": [{ - "content": {"parts": [{"text": "partial..."}], "role": "model"}, - "finishReason": "MAX_TOKENS", - }], - "usageMetadata": {"candidatesTokenCount": 100}, - }) + gemini_data = json.dumps( + { + "candidates": [ + { + "content": {"parts": [{"text": "partial..."}], "role": "model"}, + "finishReason": "MAX_TOKENS", + } + ], + "usageMetadata": {"candidatesTokenCount": 100}, + } + ) collected = [] async for chunk in adapt_sse_stream( @@ -151,17 +171,25 @@ async def test_max_tokens_finish_reason(): @pytest.mark.asyncio async def test_empty_text_chunk_skipped(): """空文本 chunk 不产生 delta.""" - chunk1 = json.dumps({ - "candidates": [{ - "content": {"parts": [{"text": ""}], "role": "model"}, - }], - }) - chunk2 = json.dumps({ - "candidates": [{ - "content": {"parts": [{"text": "Hi"}], "role": "model"}, - "finishReason": "STOP", - }], - }) + chunk1 = json.dumps( + { + "candidates": [ + { + "content": {"parts": [{"text": ""}], "role": "model"}, + } + ], + } + ) + chunk2 = json.dumps( + { + "candidates": [ + { + "content": {"parts": [{"text": "Hi"}], "role": "model"}, + "finishReason": "STOP", + } + ], + } + ) collected = [] async for chunk in adapt_sse_stream( @@ -180,11 +208,15 @@ async def test_empty_text_chunk_skipped(): @pytest.mark.asyncio async def test_stream_without_finish_reason(): """流正常结束但未收到 finishReason → 补发关闭事件.""" - chunk = json.dumps({ - "candidates": [{ - "content": {"parts": [{"text": "Hello"}], "role": "model"}, - }], - }) + chunk = json.dumps( + { + "candidates": [ + { + "content": {"parts": [{"text": "Hello"}], "role": "model"}, + } + ], + } + ) collected = [] async for c in adapt_sse_stream(_chunks_from([chunk]), model="test"): @@ -206,13 +238,17 @@ async def test_stream_without_finish_reason(): async def test_no_candidates_chunk_ignored(): """无 candidates 的 chunk 被忽略.""" chunk1 = json.dumps({"usageMetadata": {"promptTokenCount": 5}}) - chunk2 = json.dumps({ - "candidates": [{ - "content": {"parts": [{"text": "OK"}], "role": "model"}, - "finishReason": "STOP", - }], - "usageMetadata": {"candidatesTokenCount": 1}, - }) + chunk2 = json.dumps( + { + "candidates": [ + { + "content": {"parts": [{"text": "OK"}], "role": "model"}, + "finishReason": "STOP", + } + ], + "usageMetadata": {"candidatesTokenCount": 1}, + } + ) collected = [] async for c in adapt_sse_stream(_chunks_from([chunk1, chunk2]), model="test"): @@ -227,13 +263,17 @@ async def test_no_candidates_chunk_ignored(): @pytest.mark.asyncio async def test_auto_generated_request_id(): """未指定 request_id 时自动生成.""" - chunk = json.dumps({ - "candidates": [{ - "content": {"parts": [{"text": "Hi"}], "role": "model"}, - "finishReason": "STOP", - }], - "usageMetadata": {}, - }) + chunk = json.dumps( + { + "candidates": [ + { + "content": {"parts": [{"text": "Hi"}], "role": "model"}, + "finishReason": "STOP", + } + ], + "usageMetadata": {}, + } + ) collected = [] async for c in adapt_sse_stream(_chunks_from([chunk]), model="test"): @@ -246,18 +286,33 @@ async def test_auto_generated_request_id(): @pytest.mark.asyncio async def test_stream_function_call_maps_to_tool_use(): - gemini_data = json.dumps({ - "candidates": [{ - "content": {"parts": [{ - "functionCall": {"id": "fc_1", "name": "search_docs", "args": {"query": "gemini"}}, - }], "role": "model"}, - "finishReason": "STOP", - }], - "usageMetadata": {"candidatesTokenCount": 3}, - }) + gemini_data = json.dumps( + { + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "id": "fc_1", + "name": "search_docs", + "args": {"query": "gemini"}, + }, + } + ], + "role": "model", + }, + "finishReason": "STOP", + } + ], + "usageMetadata": {"candidatesTokenCount": 3}, + } + ) collected = [] - async for chunk in adapt_sse_stream(_chunks_from([gemini_data]), model="claude-sonnet-4"): + async for chunk in adapt_sse_stream( + _chunks_from([gemini_data]), model="claude-sonnet-4" + ): collected.append(chunk) events = _parse_events(collected) @@ -272,15 +327,24 @@ async def test_stream_function_call_maps_to_tool_use(): @pytest.mark.asyncio async def test_stream_thought_part_maps_to_thinking_delta(): - gemini_data = json.dumps({ - "candidates": [{ - "content": {"parts": [{"text": "先分析", "thought": True}], "role": "model"}, - "finishReason": "STOP", - }], - }) + gemini_data = json.dumps( + { + "candidates": [ + { + "content": { + "parts": [{"text": "先分析", "thought": True}], + "role": "model", + }, + "finishReason": "STOP", + } + ], + } + ) collected = [] - async for chunk in adapt_sse_stream(_chunks_from([gemini_data]), model="claude-sonnet-4"): + async for chunk in adapt_sse_stream( + _chunks_from([gemini_data]), model="claude-sonnet-4" + ): collected.append(chunk) events = _parse_events(collected) @@ -302,9 +366,16 @@ async def test_multi_block_text_then_thinking(): c3 = _make_candidate([{"text": "done thinking", "thought": True}], finish="STOP") collected = [] - async for c in adapt_sse_stream(_chunks_from([ - _dump(c1), _dump(c2), _dump(c3), - ]), model="test"): + async for c in adapt_sse_stream( + _chunks_from( + [ + _dump(c1), + _dump(c2), + _dump(c3), + ] + ), + model="test", + ): collected.append(c) events = _parse_events(collected) @@ -319,14 +390,30 @@ async def test_complex_three_block_sequence(): """思考 → 文本 → 工具调用的三块序列.""" c1 = _make_candidate([{"text": "thinking...", "thought": True}]) c2 = _make_candidate([{"text": "I'll call the tool."}]) - c3 = _make_candidate([{ - "functionCall": {"id": "fc_1", "name": "read_file", "args": {"path": "/tmp"}}, - }], finish="STOP") + c3 = _make_candidate( + [ + { + "functionCall": { + "id": "fc_1", + "name": "read_file", + "args": {"path": "/tmp"}, + }, + } + ], + finish="STOP", + ) collected = [] - async for c in adapt_sse_stream(_chunks_from([ - _dump(c1), _dump(c2), _dump(c3), - ]), model="test"): + async for c in adapt_sse_stream( + _chunks_from( + [ + _dump(c1), + _dump(c2), + _dump(c3), + ] + ), + model="test", + ): collected.append(c) events = _parse_events(collected) @@ -346,9 +433,16 @@ async def test_usage_across_multiple_chunks(): c3 = _make_candidate([{"text": "C"}], finish="STOP", cand_tokens=5) collected = [] - async for c in adapt_sse_stream(_chunks_from([ - _dump(c1), _dump(c2), _dump(c3), - ]), model="test"): + async for c in adapt_sse_stream( + _chunks_from( + [ + _dump(c1), + _dump(c2), + _dump(c3), + ] + ), + model="test", + ): collected.append(c) events = _parse_events(collected) @@ -394,9 +488,16 @@ async def test_consecutive_text_chunks_merge(): c3 = _make_candidate([{"text": "!"}], finish="STOP") collected = [] - async for c in adapt_sse_stream(_chunks_from([ - _dump(c1), _dump(c2), _dump(c3), - ]), model="test"): + async for c in adapt_sse_stream( + _chunks_from( + [ + _dump(c1), + _dump(c2), + _dump(c3), + ] + ), + model="test", + ): collected.append(c) events = _parse_events(collected) @@ -415,7 +516,8 @@ async def test_malformed_json_chunk_skipped(): collected = [] async for c in adapt_sse_stream( - _chunks_from(["{broken json}", _dump(good)]), model="test", + _chunks_from(["{broken json}", _dump(good)]), + model="test", ): collected.append(c) @@ -441,13 +543,18 @@ async def test_empty_stream_emits_close_events(): @pytest.mark.asyncio async def test_function_call_with_preexisting_id(): """functionCall 带 id 时保留该 id 作为 tool_use id.""" - data = _make_candidate([{ - "functionCall": { - "id": "my_custom_id_42", - "name": "exec_cmd", - "args": {"cmd": "ls"}, - } - }], finish="STOP") + data = _make_candidate( + [ + { + "functionCall": { + "id": "my_custom_id_42", + "name": "exec_cmd", + "args": {"cmd": "ls"}, + } + } + ], + finish="STOP", + ) collected = [] async for c in adapt_sse_stream(_chunks_from([_dump(data)]), model="test"): diff --git a/tests/test_copilot.py b/tests/test_copilot.py index db6f766..423c19e 100644 --- a/tests/test_copilot.py +++ b/tests/test_copilot.py @@ -4,23 +4,23 @@ import json import time -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import httpx import pytest +from coding.proxy.config.schema import CopilotConfig, FailoverConfig, ModelMappingRule +from coding.proxy.routing.model_mapper import ModelMapper from coding.proxy.vendors.base import CapabilityLossReason, RequestCapabilities from coding.proxy.vendors.copilot import ( - CopilotVendor, CopilotTokenManager, + CopilotVendor, + _select_copilot_model, build_copilot_candidate_base_urls, normalize_copilot_requested_model, resolve_copilot_base_url, - _select_copilot_model, ) from coding.proxy.vendors.token_manager import TokenAcquireError, TokenErrorKind -from coding.proxy.config.schema import CopilotConfig, FailoverConfig, ModelMappingRule -from coding.proxy.routing.model_mapper import ModelMapper class _AsyncRequestClientStub: @@ -34,7 +34,7 @@ async def request(self, *args, **kwargs) -> httpx.Response: async def aclose(self) -> None: self.is_closed = True - async def __aenter__(self) -> "_AsyncRequestClientStub": + async def __aenter__(self) -> _AsyncRequestClientStub: return self async def __aexit__(self, exc_type, exc, tb) -> None: @@ -72,7 +72,9 @@ async def test_token_manager_exchange(): @pytest.mark.asyncio async def test_token_manager_exchange_supports_token_refresh_in_shape(): """兼容 Copilot 当前返回的 token/refresh_in 形态.""" - tm = CopilotTokenManager("ghp_test", "https://api.github.com/copilot_internal/v2/token") + tm = CopilotTokenManager( + "ghp_test", "https://api.github.com/copilot_internal/v2/token" + ) mock_response = MagicMock() mock_response.status_code = 200 @@ -98,7 +100,10 @@ async def test_token_manager_caching(): mock_response = MagicMock() mock_response.status_code = 200 - mock_response.json.return_value = {"access_token": "cached_token", "expires_in": 1800} + mock_response.json.return_value = { + "access_token": "cached_token", + "expires_in": 1800, + } mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() @@ -283,10 +288,22 @@ def test_copilot_get_name(): def test_resolve_copilot_base_url(): - assert resolve_copilot_base_url("individual", "") == "https://api.individual.githubcopilot.com" - assert resolve_copilot_base_url("business", "") == "https://api.business.githubcopilot.com" - assert resolve_copilot_base_url("enterprise", "") == "https://api.enterprise.githubcopilot.com" - assert resolve_copilot_base_url("individual", "https://custom.example.com") == "https://custom.example.com" + assert ( + resolve_copilot_base_url("individual", "") + == "https://api.individual.githubcopilot.com" + ) + assert ( + resolve_copilot_base_url("business", "") + == "https://api.business.githubcopilot.com" + ) + assert ( + resolve_copilot_base_url("enterprise", "") + == "https://api.enterprise.githubcopilot.com" + ) + assert ( + resolve_copilot_base_url("individual", "https://custom.example.com") + == "https://custom.example.com" + ) def test_build_copilot_candidate_base_urls(): @@ -298,15 +315,24 @@ def test_build_copilot_candidate_base_urls(): "https://api.business.githubcopilot.com", "https://api.githubcopilot.com", ] - assert build_copilot_candidate_base_urls("individual", "https://custom.example.com/") == [ + assert build_copilot_candidate_base_urls( + "individual", "https://custom.example.com/" + ) == [ "https://custom.example.com", ] def test_normalize_copilot_requested_model(): - assert normalize_copilot_requested_model("claude-sonnet-4-20250514") == "claude-sonnet-4" - assert normalize_copilot_requested_model("claude-opus-4-20250514") == "claude-opus-4" - assert normalize_copilot_requested_model("claude-haiku-4-20250514") == "claude-haiku-4" + assert ( + normalize_copilot_requested_model("claude-sonnet-4-20250514") + == "claude-sonnet-4" + ) + assert ( + normalize_copilot_requested_model("claude-opus-4-20250514") == "claude-opus-4" + ) + assert ( + normalize_copilot_requested_model("claude-haiku-4-20250514") == "claude-haiku-4" + ) assert normalize_copilot_requested_model("gpt-5.2") == "gpt-5.2" @@ -336,7 +362,9 @@ async def test_copilot_prepare_request_filters_and_injects_token(): # Mock token manager vendor._token_manager.get_token = AsyncMock(return_value="cop_injected") - vendor._model_resolver.fetch_available = AsyncMock(return_value=["claude-sonnet-4.6"]) # type: ignore[method-assign] + vendor._model_resolver.fetch_available = AsyncMock( + return_value=["claude-sonnet-4.6"] + ) # type: ignore[method-assign] body = {"model": "claude-sonnet-4-20250514", "messages": []} headers = { @@ -365,9 +393,9 @@ def test_copilot_inherits_failover(): assert vendor.should_trigger_failover(429, None) assert not vendor.should_trigger_failover(200, None) - assert vendor.should_trigger_failover(429, { - "error": {"type": "rate_limit_error", "message": "limited"} - }) + assert vendor.should_trigger_failover( + 429, {"error": {"type": "rate_limit_error", "message": "limited"}} + ) def test_copilot_capabilities_enable_thinking(): @@ -394,18 +422,24 @@ async def test_copilot_prepare_request_records_thinking_adaptations(): config = CopilotConfig(github_token="ghp_test") vendor = CopilotVendor(config, FailoverConfig()) vendor._token_manager.get_token = AsyncMock(return_value="cop_injected") - vendor._model_resolver.fetch_available = AsyncMock(return_value=["claude-sonnet-4.6"]) # type: ignore[method-assign] + vendor._model_resolver.fetch_available = AsyncMock( + return_value=["claude-sonnet-4.6"] + ) # type: ignore[method-assign] body = { "model": "claude-sonnet-4-20250514", - "messages": [{ - "role": "assistant", - "content": [{"type": "thinking", "thinking": "先分析"}], - }], + "messages": [ + { + "role": "assistant", + "content": [{"type": "thinking", "thinking": "先分析"}], + } + ], "thinking": {"budget_tokens": 1024}, } - prepared_body, _ = await vendor._prepare_request(body, {"anthropic-version": "2023-06-01"}) + prepared_body, _ = await vendor._prepare_request( + body, {"anthropic-version": "2023-06-01"} + ) # thinking/extended_thinking 已被映射为 reasoning_effort,不应保留原始字段 assert "thinking" not in prepared_body @@ -414,8 +448,14 @@ async def test_copilot_prepare_request_records_thinking_adaptations(): assert prepared_body.get("reasoning_effort") == "medium" diagnostics = vendor.get_diagnostics() # 适配标签应反映新的映射策略(thinking dict 触发) - assert any("thinking_mapped_to_reasoning_effort" in a for a in diagnostics["request_adaptations"]) - assert any("thinking_block_used_as_content_fallback" in a for a in diagnostics["request_adaptations"]) + assert any( + "thinking_mapped_to_reasoning_effort" in a + for a in diagnostics["request_adaptations"] + ) + assert any( + "thinking_block_used_as_content_fallback" in a + for a in diagnostics["request_adaptations"] + ) assert diagnostics["resolved_model"] == "claude-sonnet-4.6" @@ -426,7 +466,9 @@ async def test_copilot_prepare_request_uses_cached_models_without_refetch(): vendor._token_manager.get_token = AsyncMock(return_value="cop_injected") vendor._model_resolver.catalog.available_models = ["claude-sonnet-4.6"] vendor._model_resolver.catalog.fetched_at_unix = int(time.time()) - vendor._model_resolver.fetch_available = AsyncMock(side_effect=AssertionError("should not refetch")) # type: ignore[method-assign] + vendor._model_resolver.fetch_available = AsyncMock( + side_effect=AssertionError("should not refetch") + ) # type: ignore[method-assign] prepared_body, _ = await vendor._prepare_request( {"model": "claude-sonnet-4-20250514", "messages": []}, @@ -441,19 +483,25 @@ async def test_copilot_request_with_421_retries_fresh_connection(): config = CopilotConfig(github_token="ghp_test") vendor = CopilotVendor(config, FailoverConfig()) - initial_request = httpx.Request("POST", "https://api.individual.githubcopilot.com/chat/completions") - vendor._client = _AsyncRequestClientStub(httpx.Response( # type: ignore[assignment] - 421, - content=b"Misdirected Request\n", - request=initial_request, - )) + initial_request = httpx.Request( + "POST", "https://api.individual.githubcopilot.com/chat/completions" + ) + vendor._client = _AsyncRequestClientStub( + httpx.Response( # type: ignore[assignment] + 421, + content=b"Misdirected Request\n", + request=initial_request, + ) + ) - retry_client = _AsyncRequestClientStub(httpx.Response( - 200, - content=b'{"ok":true}', - headers={"content-type": "application/json"}, - request=initial_request, - )) + retry_client = _AsyncRequestClientStub( + httpx.Response( + 200, + content=b'{"ok":true}', + headers={"content-type": "application/json"}, + request=initial_request, + ) + ) vendor._create_fresh_client = lambda base_url: retry_client # type: ignore[method-assign] response = await vendor._request_with_421_retry( @@ -465,9 +513,16 @@ async def test_copilot_request_with_421_retries_fresh_connection(): assert response.status_code == 200 diagnostics = vendor.get_diagnostics() - assert diagnostics["last_request_base_url"] == "https://api.individual.githubcopilot.com" - assert diagnostics["last_421_base_url"] == "https://api.individual.githubcopilot.com" - assert diagnostics["last_retry_base_url"] == "https://api.individual.githubcopilot.com" + assert ( + diagnostics["last_request_base_url"] + == "https://api.individual.githubcopilot.com" + ) + assert ( + diagnostics["last_421_base_url"] == "https://api.individual.githubcopilot.com" + ) + assert ( + diagnostics["last_retry_base_url"] == "https://api.individual.githubcopilot.com" + ) @pytest.mark.asyncio @@ -475,25 +530,35 @@ async def test_copilot_request_with_421_falls_back_to_public_domain(): config = CopilotConfig(github_token="ghp_test") vendor = CopilotVendor(config, FailoverConfig()) - initial_request = httpx.Request("POST", "https://api.individual.githubcopilot.com/chat/completions") - vendor._client = _AsyncRequestClientStub(httpx.Response( # type: ignore[assignment] - 421, - content=b"Misdirected Request\n", - request=initial_request, - )) - - retry_clients = [ - _AsyncRequestClientStub(httpx.Response( + initial_request = httpx.Request( + "POST", "https://api.individual.githubcopilot.com/chat/completions" + ) + vendor._client = _AsyncRequestClientStub( + httpx.Response( # type: ignore[assignment] 421, content=b"Misdirected Request\n", request=initial_request, - )), - _AsyncRequestClientStub(httpx.Response( - 200, - content=b'{"ok":true}', - headers={"content-type": "application/json"}, - request=httpx.Request("POST", "https://api.githubcopilot.com/chat/completions"), - )), + ) + ) + + retry_clients = [ + _AsyncRequestClientStub( + httpx.Response( + 421, + content=b"Misdirected Request\n", + request=initial_request, + ) + ), + _AsyncRequestClientStub( + httpx.Response( + 200, + content=b'{"ok":true}', + headers={"content-type": "application/json"}, + request=httpx.Request( + "POST", "https://api.githubcopilot.com/chat/completions" + ), + ) + ), ] retry_urls: list[str] = [] @@ -530,20 +595,26 @@ async def test_copilot_stream_421_retries_alternate_base_url(): fallback_base_url = "https://api.githubcopilot.com" stream_calls: list[str] = [] - async def _fake_stream_from_client(client, *, base_url, body, prepared_headers, request_model): + async def _fake_stream_from_client( + client, *, base_url, body, prepared_headers, request_model + ): stream_calls.append(base_url) request = httpx.Request("POST", f"{base_url}/chat/completions") if base_url != fallback_base_url: raise httpx.HTTPStatusError( "copilot API error: 421", request=request, - response=httpx.Response(421, content=b"Misdirected Request\n", request=request), + response=httpx.Response( + 421, content=b"Misdirected Request\n", request=request + ), ) - yield b"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" + yield b'event: message_stop\ndata: {"type":"message_stop"}\n\n' vendor._stream_from_client = _fake_stream_from_client # type: ignore[method-assign] vendor._create_fresh_client = lambda base_url: _AsyncRequestClientStub( # type: ignore[method-assign] - httpx.Response(200, request=httpx.Request("POST", f"{base_url}/chat/completions")) + httpx.Response( + 200, request=httpx.Request("POST", f"{base_url}/chat/completions") + ) ) chunks = [] @@ -570,7 +641,9 @@ async def test_copilot_stream_retries_after_model_not_supported(): refreshed = False - async def _fake_fetch_available_models(*, refresh_reason: str, **kwargs) -> list[str]: + async def _fake_fetch_available_models( + *, refresh_reason: str, **kwargs + ) -> list[str]: nonlocal refreshed refreshed = refreshed or refresh_reason == "model_not_supported_retry" return ["claude-sonnet-4.5"] if not refreshed else ["claude-sonnet-4.6"] @@ -579,7 +652,9 @@ async def _fake_fetch_available_models(*, refresh_reason: str, **kwargs) -> list stream_models: list[str] = [] - async def _fake_stream_from_client(client, *, base_url, body, prepared_headers, request_model): + async def _fake_stream_from_client( + client, *, base_url, body, prepared_headers, request_model + ): stream_models.append(body["model"]) request = httpx.Request("POST", f"{base_url}/chat/completions") if len(stream_models) == 1: @@ -593,8 +668,8 @@ async def _fake_stream_from_client(client, *, base_url, body, prepared_headers, request=request, ), ) - yield b"event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"model\":\"claude-sonnet-4.6\",\"usage\":{\"input_tokens\":10}}}\n\n" - yield b"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" + yield b'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1","model":"claude-sonnet-4.6","usage":{"input_tokens":10}}}\n\n' + yield b'event: message_stop\ndata: {"type":"message_stop"}\n\n' vendor._stream_from_client = _fake_stream_from_client # type: ignore[method-assign] @@ -618,7 +693,9 @@ async def test_probe_models_reports_opus_46(): mock_response = MagicMock() mock_response.status_code = 200 - mock_response.content = b'{"data":[{"id":"claude-opus-4.6"},{"id":"claude-sonnet-4"}]}' + mock_response.content = ( + b'{"data":[{"id":"claude-opus-4.6"},{"id":"claude-sonnet-4"}]}' + ) mock_response.json.return_value = { "data": [{"id": "claude-opus-4.6"}, {"id": "claude-sonnet-4"}] } @@ -632,7 +709,10 @@ async def test_probe_models_reports_opus_46(): assert probe["probe_status"] == "ok" assert probe["has_claude_opus_4_6"] is True assert "claude-opus-4.6" in probe["available_models"] - assert vendor.get_diagnostics()["available_models_cache"] == ["claude-opus-4.6", "claude-sonnet-4"] + assert vendor.get_diagnostics()["available_models_cache"] == [ + "claude-opus-4.6", + "claude-sonnet-4", + ] @pytest.mark.asyncio @@ -649,12 +729,14 @@ async def test_copilot_send_message_translates_openai_response_to_anthropic(): mock_response.json.return_value = { "id": "chatcmpl_1", "model": "claude-opus-4", - "choices": [{ - "message": {"role": "assistant", "content": "hello"}, - "finish_reason": "stop", - "index": 0, - "logprobs": None, - }], + "choices": [ + { + "message": {"role": "assistant", "content": "hello"}, + "finish_reason": "stop", + "index": 0, + "logprobs": None, + } + ], "usage": {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10}, } @@ -664,7 +746,10 @@ async def test_copilot_send_message_translates_openai_response_to_anthropic(): vendor._client = mock_client resp = await vendor.send_message( - {"model": "claude-opus-4-20250514", "messages": [{"role": "user", "content": "hi"}]}, + { + "model": "claude-opus-4-20250514", + "messages": [{"role": "user", "content": "hi"}], + }, {"anthropic-version": "2023-06-01"}, ) body = json.loads(resp.raw_body) @@ -683,23 +768,29 @@ async def test_copilot_send_message_retries_after_model_not_supported(): refreshed = False - async def _fake_fetch_available_models(*, refresh_reason: str, **kwargs) -> list[str]: + async def _fake_fetch_available_models( + *, refresh_reason: str, **kwargs + ) -> list[str]: nonlocal refreshed refreshed = refreshed or refresh_reason == "model_not_supported_retry" return ["claude-sonnet-4.5"] if not refreshed else ["claude-sonnet-4.6"] vendor._model_resolver.fetch_available = _fake_fetch_available_models # type: ignore[method-assign] - first_request = httpx.Request("POST", "https://api.individual.githubcopilot.com/chat/completions") + first_request = httpx.Request( + "POST", "https://api.individual.githubcopilot.com/chat/completions" + ) success_payload = { "id": "chatcmpl_2", "model": "claude-sonnet-4.6", - "choices": [{ - "message": {"role": "assistant", "content": "fixed"}, - "finish_reason": "stop", - "index": 0, - "logprobs": None, - }], + "choices": [ + { + "message": {"role": "assistant", "content": "fixed"}, + "finish_reason": "stop", + "index": 0, + "logprobs": None, + } + ], "usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7}, } responses = [ @@ -723,13 +814,19 @@ async def _fake_fetch_available_models(*, refresh_reason: str, **kwargs) -> list vendor._client = mock_client resp = await vendor.send_message( - {"model": "claude-sonnet-4-20250514", "messages": [{"role": "user", "content": "hi"}]}, + { + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "hi"}], + }, {"anthropic-version": "2023-06-01"}, ) assert resp.status_code == 200 assert vendor.get_diagnostics()["resolved_model"] == "claude-sonnet-4.6" - assert vendor.get_diagnostics()["last_model_refresh_reason"] == "model_not_supported_retry" + assert ( + vendor.get_diagnostics()["last_model_refresh_reason"] + == "model_not_supported_retry" + ) @pytest.mark.asyncio @@ -739,7 +836,9 @@ async def test_copilot_send_message_returns_enriched_model_error_when_family_mis vendor._token_manager.get_token = AsyncMock(return_value="cop_token") vendor._model_resolver.fetch_available = AsyncMock(return_value=["claude-opus-4.6"]) # type: ignore[method-assign] - request = httpx.Request("POST", "https://api.individual.githubcopilot.com/chat/completions") + request = httpx.Request( + "POST", "https://api.individual.githubcopilot.com/chat/completions" + ) model_error = httpx.Response( 400, content=b'{"error":{"message":"The requested model is not supported.","code":"model_not_supported","param":"model","type":"invalid_request_error"}}', @@ -752,7 +851,10 @@ async def test_copilot_send_message_returns_enriched_model_error_when_family_mis vendor._client = mock_client resp = await vendor.send_message( - {"model": "claude-sonnet-4-20250514", "messages": [{"role": "user", "content": "hi"}]}, + { + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "hi"}], + }, {"anthropic-version": "2023-06-01"}, ) @@ -782,7 +884,10 @@ async def test_copilot_send_message_handles_non_json_success_without_crash(): vendor._client = mock_client resp = await vendor.send_message( - {"model": "claude-opus-4-20250514", "messages": [{"role": "user", "content": "hi"}]}, + { + "model": "claude-opus-4-20250514", + "messages": [{"role": "user", "content": "hi"}], + }, {"anthropic-version": "2023-06-01"}, ) assert resp.status_code == 502 @@ -791,22 +896,34 @@ async def test_copilot_send_message_handles_non_json_success_without_crash(): # ===== ModelMapper 集成测试 ===== + def _make_copilot_mapper(rules: list[ModelMappingRule]) -> ModelMapper: return ModelMapper(rules) def _make_copilot_vendor(mapper: ModelMapper | None = None) -> CopilotVendor: - return CopilotVendor(CopilotConfig(github_token="ghp_test"), FailoverConfig(), model_mapper=mapper) + return CopilotVendor( + CopilotConfig(github_token="ghp_test"), FailoverConfig(), model_mapper=mapper + ) @pytest.mark.asyncio async def test_resolve_model_uses_config_mapping_when_rule_matches(): """配置规则命中时直接返回目标模型,不走内部解析(不调用 _get_available_models).""" - mapper = _make_copilot_mapper([ - ModelMappingRule(pattern="claude-sonnet-.*", target="claude-sonnet-4.6", is_regex=True, vendors=["copilot"]), - ]) + mapper = _make_copilot_mapper( + [ + ModelMappingRule( + pattern="claude-sonnet-.*", + target="claude-sonnet-4.6", + is_regex=True, + vendors=["copilot"], + ), + ] + ) vendor = _make_copilot_vendor(mapper) - vendor._model_resolver.get_available = AsyncMock(side_effect=AssertionError("不应调用 get_available")) + vendor._model_resolver.get_available = AsyncMock( + side_effect=AssertionError("不应调用 get_available") + ) resolved = await vendor._resolve_request_model( "claude-sonnet-4-20250514", force_refresh=False, refresh_reason="test" @@ -821,11 +938,20 @@ async def test_resolve_model_uses_config_mapping_when_rule_matches(): @pytest.mark.asyncio async def test_resolve_model_falls_back_to_internal_when_no_copilot_rule(): """配置规则无 copilot 条目时,走内部家族匹配策略.""" - mapper = _make_copilot_mapper([ - ModelMappingRule(pattern="claude-sonnet-.*", target="glm-5.1", is_regex=True, vendors=["fallback"]), - ]) + mapper = _make_copilot_mapper( + [ + ModelMappingRule( + pattern="claude-sonnet-.*", + target="glm-5.1", + is_regex=True, + vendors=["fallback"], + ), + ] + ) vendor = _make_copilot_vendor(mapper) - vendor._model_resolver.get_available = AsyncMock(return_value=["claude-sonnet-4.6", "claude-opus-4.6"]) + vendor._model_resolver.get_available = AsyncMock( + return_value=["claude-sonnet-4.6", "claude-opus-4.6"] + ) resolved = await vendor._resolve_request_model( "claude-sonnet-4-20250514", force_refresh=False, refresh_reason="test" @@ -839,7 +965,9 @@ async def test_resolve_model_falls_back_to_internal_when_no_copilot_rule(): async def test_resolve_model_without_mapper_uses_internal_resolution(): """model_mapper=None 时向后兼容,走内部家族匹配策略.""" vendor = _make_copilot_vendor(mapper=None) - vendor._model_resolver.get_available = AsyncMock(return_value=["claude-haiku-4.5", "claude-sonnet-4.6"]) + vendor._model_resolver.get_available = AsyncMock( + return_value=["claude-haiku-4.5", "claude-sonnet-4.6"] + ) resolved = await vendor._resolve_request_model( "claude-haiku-4-20250514", force_refresh=False, refresh_reason="test" @@ -852,11 +980,28 @@ async def test_resolve_model_without_mapper_uses_internal_resolution(): @pytest.mark.asyncio async def test_resolve_model_config_mapping_all_three_families(): """三个家族(sonnet / opus / haiku)的 copilot 规则均正确命中.""" - mapper = _make_copilot_mapper([ - ModelMappingRule(pattern="claude-sonnet-.*", target="claude-sonnet-4.6", is_regex=True, vendors=["copilot"]), - ModelMappingRule(pattern="claude-opus-.*", target="claude-opus-4.6", is_regex=True, vendors=["copilot"]), - ModelMappingRule(pattern="claude-haiku-.*", target="claude-haiku-4.5", is_regex=True, vendors=["copilot"]), - ]) + mapper = _make_copilot_mapper( + [ + ModelMappingRule( + pattern="claude-sonnet-.*", + target="claude-sonnet-4.6", + is_regex=True, + vendors=["copilot"], + ), + ModelMappingRule( + pattern="claude-opus-.*", + target="claude-opus-4.6", + is_regex=True, + vendors=["copilot"], + ), + ModelMappingRule( + pattern="claude-haiku-.*", + target="claude-haiku-4.5", + is_regex=True, + vendors=["copilot"], + ), + ] + ) cases = [ ("claude-sonnet-4-20250514", "claude-sonnet-4.6"), @@ -865,7 +1010,11 @@ async def test_resolve_model_config_mapping_all_three_families(): ] for requested, expected in cases: vendor = _make_copilot_vendor(mapper) - vendor._model_resolver.get_available = AsyncMock(side_effect=AssertionError("不应调用")) - resolved = await vendor._resolve_request_model(requested, force_refresh=False, refresh_reason="test") + vendor._model_resolver.get_available = AsyncMock( + side_effect=AssertionError("不应调用") + ) + resolved = await vendor._resolve_request_model( + requested, force_refresh=False, refresh_reason="test" + ) assert resolved == expected, f"{requested} 期望 {expected},实际 {resolved}" assert vendor._last_model_resolution_reason == "config_model_mapping" diff --git a/tests/test_copilot_convert_request.py b/tests/test_copilot_convert_request.py index 24d2533..0a4dbce 100644 --- a/tests/test_copilot_convert_request.py +++ b/tests/test_copilot_convert_request.py @@ -2,7 +2,6 @@ from coding.proxy.convert.anthropic_to_openai import convert_request - # === Thinking / Extended Thinking 映射 === @@ -83,7 +82,11 @@ def test_system_with_cache_control_still_extracts_text(): body = { "model": "claude-sonnet-4-20250514", "system": [ - {"type": "text", "text": "You are helpful.", "cache_control": {"type": "ephemeral"}}, + { + "type": "text", + "text": "You are helpful.", + "cache_control": {"type": "ephemeral"}, + }, {"type": "text", "text": "Be concise."}, ], "messages": [{"role": "user", "content": "Hi"}], @@ -123,13 +126,15 @@ def test_system_empty_list_produces_no_system_message(): def test_assistant_thinking_block_separated_from_text(): body = { "model": "claude-sonnet-4-20250514", - "messages": [{ - "role": "assistant", - "content": [ - {"type": "thinking", "thinking": "Let me analyze..."}, - {"type": "text", "text": "The answer is 42."}, - ], - }], + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Let me analyze..."}, + {"type": "text", "text": "The answer is 42."}, + ], + } + ], } result = convert_request(body) assistant_msgs = [m for m in result["messages"] if m["role"] == "assistant"] @@ -143,12 +148,14 @@ def test_assistant_thinking_block_separated_from_text(): def test_assistant_only_thinking_becomes_content(): body = { "model": "claude-sonnet-4-20250514", - "messages": [{ - "role": "assistant", - "content": [ - {"type": "thinking", "thinking": "Just thinking..."}, - ], - }], + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Just thinking..."}, + ], + } + ], } result = convert_request(body) assistant_msgs = [m for m in result["messages"] if m["role"] == "assistant"] @@ -159,20 +166,29 @@ def test_assistant_thinking_with_tool_uses_drops_thinking(): """有 tool_use 时 thinking 内容被丢弃(工具调用场景不需要历史思考过程).""" body = { "model": "claude-sonnet-4-20250514", - "messages": [{ - "role": "assistant", - "content": [ - {"type": "thinking", "thinking": "I should call a tool."}, - {"type": "tool_use", "id": "toolu_123", "name": "get_weather", "input": {"city": "Tokyo"}}, - ], - }], + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "I should call a tool."}, + { + "type": "tool_use", + "id": "toolu_123", + "name": "get_weather", + "input": {"city": "Tokyo"}, + }, + ], + } + ], } result = convert_request(body) assistant_msgs = [m for m in result["messages"] if m["role"] == "assistant"] assert len(assistant_msgs) == 1 assert "tool_calls" in assistant_msgs[0] # thinking 不应出现在 content 中 - assert assistant_msgs[0]["content"] is None or "I should call a tool" not in (assistant_msgs[0]["content"] or "") + assert assistant_msgs[0]["content"] is None or "I should call a tool" not in ( + assistant_msgs[0]["content"] or "" + ) # === Tool Result is_error === @@ -181,15 +197,19 @@ def test_assistant_thinking_with_tool_uses_drops_thinking(): def test_tool_result_is_error_injected_into_content(): body = { "model": "claude-sonnet-4-20250514", - "messages": [{ - "role": "user", - "content": [{ - "type": "tool_result", - "tool_use_id": "toolu_123", - "content": "API rate limited", - "is_error": True, - }], - }], + "messages": [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_123", + "content": "API rate limited", + "is_error": True, + } + ], + } + ], } result = convert_request(body) tool_msgs = [m for m in result["messages"] if m["role"] == "tool"] @@ -202,14 +222,18 @@ def test_tool_result_is_error_injected_into_content(): def test_tool_result_non_error_not_injected(): body = { "model": "claude-sonnet-4-20250514", - "messages": [{ - "role": "user", - "content": [{ - "type": "tool_result", - "tool_use_id": "toolu_123", - "content": "Sunny, 25°C", - }], - }], + "messages": [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_123", + "content": "Sunny, 25°C", + } + ], + } + ], } result = convert_request(body) tool_msgs = [m for m in result["messages"] if m["role"] == "tool"] @@ -313,11 +337,13 @@ def test_non_claude_model_passthrough(): def test_simple_text_message_still_works(): - result = convert_request({ - "model": "claude-sonnet-4-20250514", - "messages": [{"role": "user", "content": "Hello"}], - "max_tokens": 100, - }) + result = convert_request( + { + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 100, + } + ) assert result["model"] == "claude-sonnet-4" assert len(result["messages"]) == 1 assert result["messages"][0]["role"] == "user" @@ -325,76 +351,94 @@ def test_simple_text_message_still_works(): def test_tool_use_conversion_still_works(): - result = convert_request({ - "model": "claude-sonnet-4-20250514", - "messages": [{"role": "user", "content": "Hi"}], - "tools": [ - {"name": "get_weather", "description": "Get weather", "input_schema": {"type": "object"}}, - ], - }) + result = convert_request( + { + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "Hi"}], + "tools": [ + { + "name": "get_weather", + "description": "Get weather", + "input_schema": {"type": "object"}, + }, + ], + } + ) assert "tools" in result assert result["tools"][0]["type"] == "function" assert result["tools"][0]["function"]["name"] == "get_weather" def test_tool_choice_auto_still_works(): - result = convert_request({ - "model": "claude-sonnet-4-20250514", - "messages": [{"role": "user", "content": "Hi"}], - "tool_choice": {"type": "auto"}, - }) + result = convert_request( + { + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "Hi"}], + "tool_choice": {"type": "auto"}, + } + ) assert result["tool_choice"] == "auto" def test_tool_choice_specific_tool_still_works(): - result = convert_request({ - "model": "claude-sonnet-4-20250514", - "messages": [{"role": "user", "content": "Hi"}], - "tool_choice": {"type": "tool", "name": "get_weather"}, - }) + result = convert_request( + { + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "Hi"}], + "tool_choice": {"type": "tool", "name": "get_weather"}, + } + ) assert result["tool_choice"]["type"] == "function" assert result["tool_choice"]["function"]["name"] == "get_weather" def test_stop_sequences_mapped_to_stop(): - result = convert_request({ - "model": "claude-sonnet-4-20250514", - "messages": [{"role": "user", "content": "Hi"}], - "stop_sequences": ["END", "STOP"], - }) + result = convert_request( + { + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "Hi"}], + "stop_sequences": ["END", "STOP"], + } + ) assert result["stop"] == ["END", "STOP"] def test_temperature_and_top_p_still_works(): - result = convert_request({ - "model": "claude-sonnet-4-20250514", - "messages": [{"role": "user", "content": "Hi"}], - "temperature": 0.7, - "top_p": 0.9, - }) + result = convert_request( + { + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "Hi"}], + "temperature": 0.7, + "top_p": 0.9, + } + ) assert result["temperature"] == 0.7 assert result["top_p"] == 0.9 def test_stream_options_included_when_streaming(): - result = convert_request({ - "model": "claude-sonnet-4-20250514", - "messages": [{"role": "user", "content": "Hi"}], - "stream": True, - }) + result = convert_request( + { + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "Hi"}], + "stream": True, + } + ) assert result["stream"] is True assert result["stream_options"] == {"include_usage": True} def test_multi_turn_conversation_still_works(): - result = convert_request({ - "model": "claude-sonnet-4-20250514", - "messages": [ - {"role": "user", "content": "What is 2+2?"}, - {"role": "assistant", "content": "4"}, - {"role": "user", "content": "And 3+3?"}, - ], - }) + result = convert_request( + { + "model": "claude-sonnet-4-20250514", + "messages": [ + {"role": "user", "content": "What is 2+2?"}, + {"role": "assistant", "content": "4"}, + {"role": "user", "content": "And 3+3?"}, + ], + } + ) assert len(result["messages"]) == 3 assert result["messages"][0]["role"] == "user" assert result["messages"][1]["role"] == "assistant" @@ -402,16 +446,27 @@ def test_multi_turn_conversation_still_works(): def test_image_block_converted_to_image_url(): - result = convert_request({ - "model": "claude-sonnet-4-20250514", - "messages": [{ - "role": "user", - "content": [ - {"type": "text", "text": "What is this?"}, - {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "abc123"}}, + result = convert_request( + { + "model": "claude-sonnet-4-20250514", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is this?"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "abc123", + }, + }, + ], + } ], - }], - }) + } + ) user_msg = [m for m in result["messages"] if m["role"] == "user"][0] assert isinstance(user_msg["content"], list) image_part = [p for p in user_msg["content"] if p.get("type") == "image_url"] diff --git a/tests/test_copilot_convert_response.py b/tests/test_copilot_convert_response.py index 2ef9cbb..9aaa684 100644 --- a/tests/test_copilot_convert_response.py +++ b/tests/test_copilot_convert_response.py @@ -7,14 +7,16 @@ def test_response_with_reasoning_content_creates_thinking_block(): openai_resp = { "id": "chatcmpl_1", "model": "claude-sonnet-4", - "choices": [{ - "message": { - "role": "assistant", - "reasoning_content": "Let me think step by step...", - "content": "The answer is 42.", - }, - "finish_reason": "stop", - }], + "choices": [ + { + "message": { + "role": "assistant", + "reasoning_content": "Let me think step by step...", + "content": "The answer is 42.", + }, + "finish_reason": "stop", + } + ], "usage": {"prompt_tokens": 10, "completion_tokens": 20}, } result = convert_response(openai_resp) @@ -29,14 +31,16 @@ def test_response_with_empty_reasoning_content_ignored(): openai_resp = { "id": "chatcmpl_2", "model": "claude-sonnet-4", - "choices": [{ - "message": { - "role": "assistant", - "reasoning_content": " ", - "content": "Hello", - }, - "finish_reason": "stop", - }], + "choices": [ + { + "message": { + "role": "assistant", + "reasoning_content": " ", + "content": "Hello", + }, + "finish_reason": "stop", + } + ], "usage": {}, } result = convert_response(openai_resp) @@ -48,10 +52,12 @@ def test_response_with_empty_reasoning_content_ignored(): def test_unknown_finish_reason_defaults_to_end_turn(): openai_resp = { - "choices": [{ - "message": {"role": "assistant", "content": "ok"}, - "finish_reason": "some_new_reason", - }], + "choices": [ + { + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "some_new_reason", + } + ], "usage": {}, } result = convert_response(openai_resp) @@ -60,10 +66,12 @@ def test_unknown_finish_reason_defaults_to_end_turn(): def test_stop_finish_reason_maps_to_end_turn(): openai_resp = { - "choices": [{ - "message": {"role": "assistant", "content": "done"}, - "finish_reason": "stop", - }], + "choices": [ + { + "message": {"role": "assistant", "content": "done"}, + "finish_reason": "stop", + } + ], "usage": {}, } result = convert_response(openai_resp) @@ -72,10 +80,12 @@ def test_stop_finish_reason_maps_to_end_turn(): def test_length_finish_reason_maps_to_max_tokens(): openai_resp = { - "choices": [{ - "message": {"role": "assistant", "content": "partial..."}, - "finish_reason": "length", - }], + "choices": [ + { + "message": {"role": "assistant", "content": "partial..."}, + "finish_reason": "length", + } + ], "usage": {}, } result = convert_response(openai_resp) @@ -86,18 +96,25 @@ def test_tool_calls_in_response(): openai_resp = { "id": "chatcmpl_2", "model": "claude-sonnet-4", - "choices": [{ - "message": { - "role": "assistant", - "content": None, - "tool_calls": [{ - "id": "call_123", - "type": "function", - "function": {"name": "get_weather", "arguments": '{"city":"Tokyo"}'}, - }], - }, - "finish_reason": "tool_calls", - }], + "choices": [ + { + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_123", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"city":"Tokyo"}', + }, + } + ], + }, + "finish_reason": "tool_calls", + } + ], "usage": {"prompt_tokens": 15, "completion_tokens": 30}, } result = convert_response(openai_resp) @@ -110,18 +127,22 @@ def test_tool_calls_in_response(): def test_tool_calls_with_invalid_json_arguments(): """Tool call with invalid JSON arguments falls back to empty dict.""" openai_resp = { - "choices": [{ - "message": { - "role": "assistant", - "content": None, - "tool_calls": [{ - "id": "call_bad", - "type": "function", - "function": {"name": "bad_tool", "arguments": "not-json"}, - }], - }, - "finish_reason": "tool_calls", - }], + "choices": [ + { + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_bad", + "type": "function", + "function": {"name": "bad_tool", "arguments": "not-json"}, + } + ], + }, + "finish_reason": "tool_calls", + } + ], "usage": {}, } result = convert_response(openai_resp) @@ -130,17 +151,33 @@ def test_tool_calls_with_invalid_json_arguments(): def test_multiple_tool_calls_preserved(): openai_resp = { - "choices": [{ - "message": { - "role": "assistant", - "content": "I will check both.", - "tool_calls": [ - {"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": '{"city":"Tokyo"}'}}, - {"id": "call_2", "type": "function", "function": {"name": "get_time", "arguments": '{"tz":"UTC"}'}}, - ], - }, - "finish_reason": "tool_calls", - }], + "choices": [ + { + "message": { + "role": "assistant", + "content": "I will check both.", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"city":"Tokyo"}', + }, + }, + { + "id": "call_2", + "type": "function", + "function": { + "name": "get_time", + "arguments": '{"tz":"UTC"}', + }, + }, + ], + }, + "finish_reason": "tool_calls", + } + ], "usage": {}, } result = convert_response(openai_resp) @@ -152,7 +189,9 @@ def test_multiple_tool_calls_preserved(): def test_usage_with_cached_tokens(): openai_resp = { - "choices": [{"message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}], + "choices": [ + {"message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"} + ], "usage": { "prompt_tokens": 100, "completion_tokens": 20, @@ -169,7 +208,9 @@ def test_request_id_from_openai_id(): openai_resp = { "id": "chatcmpl_custom", "model": "claude-opus-4", - "choices": [{"message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}], + "choices": [ + {"message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"} + ], "usage": {}, } result = convert_response(openai_resp) @@ -178,7 +219,12 @@ def test_request_id_from_openai_id(): def test_content_filter_finish_reason_maps_to_end_turn(): openai_resp = { - "choices": [{"message": {"role": "assistant", "content": ""}, "finish_reason": "content_filter"}], + "choices": [ + { + "message": {"role": "assistant", "content": ""}, + "finish_reason": "content_filter", + } + ], "usage": {}, } result = convert_response(openai_resp) @@ -198,16 +244,18 @@ def test_null_finish_reason_in_choice_defaults(): def test_list_content_in_message(): """OpenAI response with list content (multimodal) extracts text correctly.""" openai_resp = { - "choices": [{ - "message": { - "role": "assistant", - "content": [ - {"type": "text", "text": "Part 1"}, - {"type": "text", "text": "Part 2"}, - ], - }, - "finish_reason": "stop", - }], + "choices": [ + { + "message": { + "role": "assistant", + "content": [ + {"type": "text", "text": "Part 1"}, + {"type": "text", "text": "Part 2"}, + ], + }, + "finish_reason": "stop", + } + ], "usage": {}, } result = convert_response(openai_resp) diff --git a/tests/test_copilot_models.py b/tests/test_copilot_models.py index d45cf2c..4aa36ea 100644 --- a/tests/test_copilot_models.py +++ b/tests/test_copilot_models.py @@ -2,33 +2,49 @@ import time -import pytest - from coding.proxy.vendors.copilot_models import ( CopilotExchangeDiagnostics, CopilotModelCatalog, - _select_copilot_model as select_copilot_model, + normalize_copilot_requested_model, +) +from coding.proxy.vendors.copilot_models import ( _copilot_model_family as copilot_model_family, +) +from coding.proxy.vendors.copilot_models import ( _copilot_model_major as copilot_model_major, +) +from coding.proxy.vendors.copilot_models import ( _copilot_model_version_rank as copilot_model_version_rank, - normalize_copilot_requested_model, ) - +from coding.proxy.vendors.copilot_models import ( + _select_copilot_model as select_copilot_model, +) # ── normalize_copilot_requested_model ──────────────────── def test_normalize_claude_sonnet(): - assert normalize_copilot_requested_model("claude-sonnet-4-20250514") == "claude-sonnet-4" - assert normalize_copilot_requested_model("claude-sonnet-4.5-20250514") == "claude-sonnet-4" + assert ( + normalize_copilot_requested_model("claude-sonnet-4-20250514") + == "claude-sonnet-4" + ) + assert ( + normalize_copilot_requested_model("claude-sonnet-4.5-20250514") + == "claude-sonnet-4" + ) def test_normalize_claude_opus(): - assert normalize_copilot_requested_model("claude-opus-4-20250514") == "claude-opus-4" + assert ( + normalize_copilot_requested_model("claude-opus-4-20250514") == "claude-opus-4" + ) def test_normalize_claude_haiku(): - assert normalize_copilot_requested_model("claude-haiku-4-5-20251001") == "claude-haiku-4" + assert ( + normalize_copilot_requested_model("claude-haiku-4-5-20251001") + == "claude-haiku-4" + ) def test_normalize_passthrough_non_claude(): diff --git a/tests/test_copilot_urls.py b/tests/test_copilot_urls.py index da87711..87da4a6 100644 --- a/tests/test_copilot_urls.py +++ b/tests/test_copilot_urls.py @@ -8,23 +8,38 @@ def test_resolve_copilot_base_url_individual(): - assert resolve_copilot_base_url("individual", "") == "https://api.individual.githubcopilot.com" + assert ( + resolve_copilot_base_url("individual", "") + == "https://api.individual.githubcopilot.com" + ) def test_resolve_copilot_base_url_business(): - assert resolve_copilot_base_url("business", "") == "https://api.business.githubcopilot.com" + assert ( + resolve_copilot_base_url("business", "") + == "https://api.business.githubcopilot.com" + ) def test_resolve_copilot_base_url_enterprise(): - assert resolve_copilot_base_url("enterprise", "") == "https://api.enterprise.githubcopilot.com" + assert ( + resolve_copilot_base_url("enterprise", "") + == "https://api.enterprise.githubcopilot.com" + ) def test_resolve_copilot_base_url_custom_overrides_default(): - assert resolve_copilot_base_url("individual", "https://custom.example.com") == "https://custom.example.com" + assert ( + resolve_copilot_base_url("individual", "https://custom.example.com") + == "https://custom.example.com" + ) def test_resolve_copilot_base_url_trailing_slash_stripped(): - assert resolve_copilot_base_url("individual", "https://custom.example.com/") == "https://custom.example.com" + assert ( + resolve_copilot_base_url("individual", "https://custom.example.com/") + == "https://custom.example.com" + ) def test_build_candidates_individual(): @@ -44,7 +59,9 @@ def test_build_candidates_business(): def test_build_candidates_custom_url(): - result = build_copilot_candidate_base_urls("individual", "https://custom.example.com/") + result = build_copilot_candidate_base_urls( + "individual", "https://custom.example.com/" + ) assert result == ["https://custom.example.com"] @@ -64,4 +81,6 @@ def test_normalize_base_url_removes_trailing_slash(): def test_resolve_empty_account_type_falls_back_to_individual(): """空 account_type 回退到 individual.""" - assert resolve_copilot_base_url("", "") == "https://api.individual.githubcopilot.com" + assert ( + resolve_copilot_base_url("", "") == "https://api.individual.githubcopilot.com" + ) diff --git a/tests/test_currency.py b/tests/test_currency.py index 174d0a2..86c07f2 100644 --- a/tests/test_currency.py +++ b/tests/test_currency.py @@ -7,7 +7,6 @@ from coding.proxy.config.routing import _detect_currency, _price_to_float from coding.proxy.model.pricing import CostValue, Currency, ModelPricing - # ── Currency 枚举 ──────────────────────────────────────────── @@ -18,7 +17,7 @@ def test_usd_symbol(self): assert Currency.USD.symbol == "$" def test_cny_symbol(self): - assert Currency.CNY.symbol == "\u00a5" # ¥ (U+00A5) + assert Currency.CNY.symbol == "\u00a5" # ¥ (U+00A5) def test_default_is_usd(self): assert Currency.default() == Currency.USD @@ -79,7 +78,7 @@ def test_equality_different_currency(self): def test_frozen_immutable(self): cv = CostValue(1.0, Currency.USD) with pytest.raises(AttributeError): - cv.amount = 2.0 # type: ignore[misc] + cv.amount = 2.0 # type: ignore[misc] def test_symbol_property(self): assert CostValue(1.0, Currency.USD).symbol == "$" diff --git a/tests/test_error_classifier.py b/tests/test_error_classifier.py index 9d5f44e..d6289d1 100644 --- a/tests/test_error_classifier.py +++ b/tests/test_error_classifier.py @@ -1,7 +1,6 @@ """HTTP 错误分类与请求能力画像提取单元测试.""" import httpx -import pytest from coding.proxy.routing.error_classifier import ( build_request_capabilities, @@ -9,7 +8,6 @@ is_semantic_rejection, ) - # --- is_semantic_rejection 测试 --- @@ -17,44 +15,65 @@ class TestIsSemanticRejection: """语义拒绝判定测试 — 400 状态码 + 特征错误类型/消息.""" def test_400_with_invalid_request_error_type(self): - assert is_semantic_rejection(status_code=400, error_type="invalid_request_error") is True + assert ( + is_semantic_rejection(status_code=400, error_type="invalid_request_error") + is True + ) def test_400_with_validation_message(self): - assert is_semantic_rejection( - status_code=400, - error_message="should match pattern", - ) is True + assert ( + is_semantic_rejection( + status_code=400, + error_message="should match pattern", + ) + is True + ) def test_400_with_tool_use_id_message(self): - assert is_semantic_rejection( - status_code=400, - error_message="tool_use_id is invalid", - ) is True + assert ( + is_semantic_rejection( + status_code=400, + error_message="tool_use_id is invalid", + ) + is True + ) def test_non_400_status_rejected(self): assert is_semantic_rejection(status_code=429) is False assert is_semantic_rejection(status_code=500) is False def test_400_generic_message_not_rejected(self): - assert is_semantic_rejection( - status_code=400, - error_message="something went wrong", - ) is False + assert ( + is_semantic_rejection( + status_code=400, + error_message="something went wrong", + ) + is False + ) def test_none_values_safe(self): - assert is_semantic_rejection(status_code=400, error_type=None, error_message=None) is False + assert ( + is_semantic_rejection(status_code=400, error_type=None, error_message=None) + is False + ) def test_case_insensitive_type(self): - assert is_semantic_rejection( - status_code=400, - error_type="Invalid_Request_Error", - ) is True + assert ( + is_semantic_rejection( + status_code=400, + error_type="Invalid_Request_Error", + ) + is True + ) def test_case_insensitive_message(self): - assert is_semantic_rejection( - status_code=400, - error_message="VALIDATION failed for field x", - ) is True + assert ( + is_semantic_rejection( + status_code=400, + error_message="VALIDATION failed for field x", + ) + is True + ) # --- extract_error_payload_from_http_status 测试 --- @@ -66,7 +85,9 @@ class TestExtractErrorPayload: def test_extract_valid_json_payload(): resp = httpx.Response(400, content=b'{"error":{"type":"bad","message":"oops"}}') - exc = httpx.HTTPStatusError("error", request=httpx.Request("POST", "http://x"), response=resp) + exc = httpx.HTTPStatusError( + "error", request=httpx.Request("POST", "http://x"), response=resp + ) result = extract_error_payload_from_http_status(exc) assert result is not None assert result["error"]["type"] == "bad" @@ -74,24 +95,32 @@ def test_extract_valid_json_payload(): def test_extract_empty_response(): resp = httpx.Response(400, content=b"") - exc = httpx.HTTPStatusError("error", request=httpx.Request("POST", "http://x"), response=resp) + exc = httpx.HTTPStatusError( + "error", request=httpx.Request("POST", "http://x"), response=resp + ) assert extract_error_payload_from_http_status(exc) is None def test_extract_invalid_json(): resp = httpx.Response(400, content=b"not json") - exc = httpx.HTTPStatusError("error", request=httpx.Request("POST", "http://x"), response=resp) + exc = httpx.HTTPStatusError( + "error", request=httpx.Request("POST", "http://x"), response=resp + ) assert extract_error_payload_from_http_status(exc) is None def test_extract_none_response(): - exc = httpx.HTTPStatusError("error", request=httpx.Request("POST", "http://x"), response=None) + exc = httpx.HTTPStatusError( + "error", request=httpx.Request("POST", "http://x"), response=None + ) assert extract_error_payload_from_http_status(exc) is None def test_extract_non_dict_payload(): resp = httpx.Response(400, content=b'"just a string"') - exc = httpx.HTTPStatusError("error", request=httpx.Request("POST", "http://x"), response=resp) + exc = httpx.HTTPStatusError( + "error", request=httpx.Request("POST", "http://x"), response=resp + ) assert extract_error_payload_from_http_status(exc) is None @@ -103,7 +132,9 @@ class TestBuildRequestCapabilities: def test_basic_request(): - caps = build_request_capabilities({"model": "claude-sonnet-4-20250514", "messages": []}) + caps = build_request_capabilities( + {"model": "claude-sonnet-4-20250514", "messages": []} + ) assert caps.has_tools is False assert caps.has_thinking is False assert caps.has_images is False @@ -111,63 +142,82 @@ def test_basic_request(): def test_tools_detected(): - caps = build_request_capabilities({ - "model": "claude-sonnet-4-20250514", - "messages": [], - "tools": [{"name": "t1"}], - }) + caps = build_request_capabilities( + { + "model": "claude-sonnet-4-20250514", + "messages": [], + "tools": [{"name": "t1"}], + } + ) assert caps.has_tools is True def test_tool_choice_detected(): - caps = build_request_capabilities({ - "model": "claude-sonnet-4-20250514", - "messages": [], - "tool_choice": "auto", - }) + caps = build_request_capabilities( + { + "model": "claude-sonnet-4-20250514", + "messages": [], + "tool_choice": "auto", + } + ) assert caps.has_tools is True def test_thinking_detected(): - caps = build_request_capabilities({ - "model": "claude-sonnet-4-20250514", - "messages": [], - "thinking": {"type": "enabled"}, - }) + caps = build_request_capabilities( + { + "model": "claude-sonnet-4-20250514", + "messages": [], + "thinking": {"type": "enabled"}, + } + ) assert caps.has_thinking is True def test_extended_thinking_detected(): - caps = build_request_capabilities({ - "model": "claude-sonnet-4-20250514", - "messages": [], - "extended_thinking": {"type": "enabled"}, - }) + caps = build_request_capabilities( + { + "model": "claude-sonnet-4-20250514", + "messages": [], + "extended_thinking": {"type": "enabled"}, + } + ) assert caps.has_thinking is True def test_images_in_content(): - caps = build_request_capabilities({ - "model": "claude-sonnet-4-20250514", - "messages": [{"role": "user", "content": [{"type": "image", "source": {"type": "base64"}}]}], - }) + caps = build_request_capabilities( + { + "model": "claude-sonnet-4-20250514", + "messages": [ + { + "role": "user", + "content": [{"type": "image", "source": {"type": "base64"}}], + } + ], + } + ) assert caps.has_images is True def test_metadata_detected(): - caps = build_request_capabilities({ - "model": "claude-sonnet-4-20250514", - "messages": [], - "metadata": {"key": "val"}, - }) + caps = build_request_capabilities( + { + "model": "claude-sonnet-4-20250514", + "messages": [], + "metadata": {"key": "val"}, + } + ) assert caps.has_metadata is True def test_string_content_not_image(): - caps = build_request_capabilities({ - "model": "claude-sonnet-4-20250514", - "messages": [{"role": "user", "content": "hello"}], - }) + caps = build_request_capabilities( + { + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "hello"}], + } + ) assert caps.has_images is False diff --git a/tests/test_model_compat.py b/tests/test_model_compat.py index 9635873..4d07d15 100644 --- a/tests/test_model_compat.py +++ b/tests/test_model_compat.py @@ -23,7 +23,6 @@ CompatSessionRecord, ) - # ═══════════════════════════════════════════════════════════════ # 1. 枚举值完整性 # ═══════════════════════════════════════════════════════════════ @@ -151,7 +150,10 @@ class TestCanonicalThinkingFullConstruction: def test_full_fields(self) -> None: t = CanonicalThinking( - enabled=True, budget_tokens=1024, effort="high", source_field="thinking", + enabled=True, + budget_tokens=1024, + effort="high", + source_field="thinking", ) assert t.enabled is True assert t.budget_tokens == 1024 @@ -186,7 +188,9 @@ def test_full_request(self) -> None: model="claude-sonnet-4-20250514", messages=[ CanonicalMessagePart( - type=CanonicalPartType.TEXT, role="user", text="hello", + type=CanonicalPartType.TEXT, + role="user", + text="hello", ), CanonicalMessagePart( type=CanonicalPartType.TOOL_USE, @@ -253,8 +257,14 @@ def test_frozen_raises_on_assignment( def test_canonical_request_is_frozen(self) -> None: req = CanonicalRequest( - session_key="a", trace_id="b", request_id="c", model="d", - messages=[], thinking=CanonicalThinking(), metadata={}, tool_names=[], + session_key="a", + trace_id="b", + request_id="c", + model="d", + messages=[], + thinking=CanonicalThinking(), + metadata={}, + tool_names=[], supports_json_output=False, ) with pytest.raises(dataclasses.FrozenInstanceError): @@ -317,8 +327,11 @@ def test_to_dict_values_match(self) -> None: def test_to_dict_returns_independent_copy(self) -> None: """to_dict() 返回的 dict 不应与内部状态共享可变引用.""" trace = CompatibilityTrace( - trace_id="tr_3", vendor="x", session_key="y", - provider_protocol="z", compat_mode="m", + trace_id="tr_3", + vendor="x", + session_key="y", + provider_protocol="z", + compat_mode="m", simulation_actions=["a"], ) d = trace.to_dict() @@ -362,10 +375,14 @@ def test_request_contains_nested_thinking_and_messages(self) -> None: thinking = CanonicalThinking(enabled=True, budget_tokens=2048) messages = [ CanonicalMessagePart( - type=CanonicalPartType.TEXT, role="user", text="explain recursion", + type=CanonicalPartType.TEXT, + role="user", + text="explain recursion", ), CanonicalMessagePart( - type=CanonicalPartType.THINKING, role="assistant", text="let me think...", + type=CanonicalPartType.THINKING, + role="assistant", + text="let me think...", ), ] req = CanonicalRequest( @@ -417,8 +434,14 @@ class TestEdgeCases: def test_canonical_request_empty_messages(self) -> None: req = CanonicalRequest( - session_key="sk_e", trace_id="tr_e", request_id="req_e", model="m", - messages=[], thinking=CanonicalThinking(), metadata={}, tool_names=[], + session_key="sk_e", + trace_id="tr_e", + request_id="req_e", + model="m", + messages=[], + thinking=CanonicalThinking(), + metadata={}, + tool_names=[], supports_json_output=False, ) assert req.messages == [] @@ -431,8 +454,11 @@ def test_canonical_tool_call_empty_arguments(self) -> None: def test_compatibility_trace_default_lists_are_empty(self) -> None: trace = CompatibilityTrace( - trace_id="t", vendor="b", session_key="s", - provider_protocol="p", compat_mode="c", + trace_id="t", + vendor="b", + session_key="s", + provider_protocol="p", + compat_mode="c", ) assert trace.simulation_actions == [] assert trace.unsupported_semantics == [] @@ -458,7 +484,9 @@ def test_compat_session_record_is_mutable(self) -> None: def test_canonical_message_part_raw_block_preserved(self) -> None: raw = {"type": "text", "text": "original", "extra": 42} part = CanonicalMessagePart( - type=CanonicalPartType.UNKNOWN, role="assistant", raw_block=raw, + type=CanonicalPartType.UNKNOWN, + role="assistant", + raw_block=raw, ) assert part.raw_block == raw assert part.raw_block["extra"] == 42 diff --git a/tests/test_model_constants.py b/tests/test_model_constants.py index 44a2d6a..66f946a 100644 --- a/tests/test_model_constants.py +++ b/tests/test_model_constants.py @@ -1,38 +1,37 @@ """constants.py 跨模块共享常量单元测试.""" from coding.proxy.model.constants import ( - PROXY_SKIP_HEADERS, - RESPONSE_SANITIZE_SKIP_HEADERS, _COPILOT_VERSION, _EDITOR_PLUGIN_VERSION, _EDITOR_VERSION, _GITHUB_API_VERSION, _USER_AGENT, + PROXY_SKIP_HEADERS, + RESPONSE_SANITIZE_SKIP_HEADERS, ) - # ── Header 常量 ────────────────────────────────────────────── def test_proxy_skip_headers_is_frozenset_with_expected_members(): """PROXY_SKIP_HEADERS 应为 frozenset[str] 且包含全部 hop-by-hop 请求头.""" assert isinstance(PROXY_SKIP_HEADERS, frozenset) - assert PROXY_SKIP_HEADERS == { + assert { "host", "content-length", "transfer-encoding", "connection", - } + } == PROXY_SKIP_HEADERS def test_response_sanitize_skip_headers_is_frozenset_with_expected_members(): """RESPONSE_SANITIZE_SKIP_HEADERS 应为 frozenset[str] 且包含需移除的响应头.""" assert isinstance(RESPONSE_SANITIZE_SKIP_HEADERS, frozenset) - assert RESPONSE_SANITIZE_SKIP_HEADERS == { + assert { "content-encoding", "content-length", "transfer-encoding", - } + } == RESPONSE_SANITIZE_SKIP_HEADERS def test_header_sets_have_correct_overlap(): @@ -53,8 +52,8 @@ def test_copilot_version_constant_values(): def test_copilot_derived_constants_use_correct_interpolation(): """派生常量应基于 _COPILOT_VERSION 正确拼接字符串.""" - assert _EDITOR_PLUGIN_VERSION == f"copilot-chat/{_COPILOT_VERSION}" + assert f"copilot-chat/{_COPILOT_VERSION}" == _EDITOR_PLUGIN_VERSION assert _EDITOR_PLUGIN_VERSION == "copilot-chat/0.26.7" - assert _USER_AGENT == f"GitHubCopilotChat/{_COPILOT_VERSION}" + assert f"GitHubCopilotChat/{_COPILOT_VERSION}" == _USER_AGENT assert _USER_AGENT == "GitHubCopilotChat/0.26.7" diff --git a/tests/test_model_mapper.py b/tests/test_model_mapper.py index a530bd1..a6ca85d 100644 --- a/tests/test_model_mapper.py +++ b/tests/test_model_mapper.py @@ -10,9 +10,13 @@ def _make_mapper(rules: list[ModelMappingRule] | None = None) -> ModelMapper: if rules is None: rules = [ ModelMappingRule(pattern="claude-sonnet-4-20250514", target="glm-exact"), - ModelMappingRule(pattern="claude-sonnet-.*", target="glm-5.1", is_regex=True), + ModelMappingRule( + pattern="claude-sonnet-.*", target="glm-5.1", is_regex=True + ), ModelMappingRule(pattern="claude-opus-.*", target="glm-5.1", is_regex=True), - ModelMappingRule(pattern="claude-haiku-.*", target="glm-4.5-air", is_regex=True), + ModelMappingRule( + pattern="claude-haiku-.*", target="glm-4.5-air", is_regex=True + ), ] return ModelMapper(rules) @@ -57,32 +61,50 @@ def test_empty_rules_use_default(): def test_vendor_scoped_mapping(): - mapper = _make_mapper([ - ModelMappingRule(pattern="claude-sonnet-*", target="claude-sonnet-4-6-thinking", vendors=["antigravity"]), - ModelMappingRule(pattern="claude-sonnet-*", target="glm-5.1", vendors=["fallback"]), - ]) - assert mapper.map("claude-sonnet-4-20250514", vendor="antigravity") == "claude-sonnet-4-6-thinking" + mapper = _make_mapper( + [ + ModelMappingRule( + pattern="claude-sonnet-*", + target="claude-sonnet-4-6-thinking", + vendors=["antigravity"], + ), + ModelMappingRule( + pattern="claude-sonnet-*", target="glm-5.1", vendors=["fallback"] + ), + ] + ) + assert ( + mapper.map("claude-sonnet-4-20250514", vendor="antigravity") + == "claude-sonnet-4-6-thinking" + ) assert mapper.map("claude-sonnet-4-20250514", vendor="zhipu") == "glm-5.1" def test_legacy_rule_only_applies_to_fallback(): - mapper = _make_mapper([ - ModelMappingRule(pattern="claude-sonnet-*", target="glm-5.1"), - ]) + mapper = _make_mapper( + [ + ModelMappingRule(pattern="claude-sonnet-*", target="glm-5.1"), + ] + ) assert mapper.map("claude-sonnet-4-20250514", vendor="fallback") == "glm-5.1" - assert mapper.map( - "claude-sonnet-4-20250514", - vendor="antigravity", - default="claude-sonnet-4-20250514", - ) == "claude-sonnet-4-20250514" + assert ( + mapper.map( + "claude-sonnet-4-20250514", + vendor="antigravity", + default="claude-sonnet-4-20250514", + ) + == "claude-sonnet-4-20250514" + ) def test_zhipu_vendor_logs_with_original_name(caplog): """zhipu 供应商传入时,日志应显示 vendor=zhipu 而非 vendor=fallback.""" caplog.set_level(logging.DEBUG, logger="coding.proxy.routing.model_mapper") - mapper = _make_mapper([ - ModelMappingRule(pattern="claude-sonnet-*", target="glm-5.1"), - ]) + mapper = _make_mapper( + [ + ModelMappingRule(pattern="claude-sonnet-*", target="glm-5.1"), + ] + ) result = mapper.map("claude-sonnet-4-20250514", vendor="zhipu") assert result == "glm-5.1" diff --git a/tests/test_model_pricing.py b/tests/test_model_pricing.py index 6a63f62..26dcf2e 100644 --- a/tests/test_model_pricing.py +++ b/tests/test_model_pricing.py @@ -54,7 +54,9 @@ def test_price_field_types_are_float(): } for f in fields(ModelPricing): if f.name in price_fields: - assert f.type == "float", f"字段 {f.name} 的类型注解应为 'float',实际为 {f.type!r}" + assert f.type == "float", ( + f"字段 {f.name} 的类型注解应为 'float',实际为 {f.type!r}" + ) def test_equality(): diff --git a/tests/test_model_token.py b/tests/test_model_token.py index a731c1c..7772b22 100644 --- a/tests/test_model_token.py +++ b/tests/test_model_token.py @@ -9,11 +9,11 @@ TokenManagerDiagnostics, ) - # --------------------------------------------------------------------------- # TokenErrorKind # --------------------------------------------------------------------------- + class TestTokenErrorKind: """枚举值完整性验证.""" @@ -32,7 +32,10 @@ def test_enum_values(): """各枚举成员的 value 应与声明一致.""" assert TokenErrorKind.TEMPORARY.value == "temporary" assert TokenErrorKind.INVALID_CREDENTIALS.value == "invalid_credentials" - assert TokenErrorKind.PERMISSION_UPGRADE_REQUIRED.value == "permission_upgrade_required" + assert ( + TokenErrorKind.PERMISSION_UPGRADE_REQUIRED.value + == "permission_upgrade_required" + ) assert TokenErrorKind.INSUFFICIENT_SCOPE.value == "insufficient_scope" @@ -46,6 +49,7 @@ def test_enum_from_string(): # TokenAcquireError # --------------------------------------------------------------------------- + class TestTokenAcquireError: """异常构造与属性验证.""" @@ -99,6 +103,7 @@ def test_is_exception_subclass(): # TokenManagerDiagnostics # --------------------------------------------------------------------------- + class TestTokenManagerDiagnostics: """诊断数据类验证.""" diff --git a/tests/test_model_vendor.py b/tests/test_model_vendor.py index 0eb21e0..14895e2 100644 --- a/tests/test_model_vendor.py +++ b/tests/test_model_vendor.py @@ -11,9 +11,11 @@ import httpx import pytest +from coding.proxy.model.constants import ( + PROXY_SKIP_HEADERS, + RESPONSE_SANITIZE_SKIP_HEADERS, +) from coding.proxy.model.vendor import ( - VendorCapabilities, - VendorResponse, CapabilityLossReason, CopilotExchangeDiagnostics, CopilotMisdirectedRequest, @@ -21,15 +23,12 @@ NoCompatibleVendorError, RequestCapabilities, UsageInfo, + VendorCapabilities, + VendorResponse, decode_json_body, extract_error_message, sanitize_headers_for_synthetic_response, ) -from coding.proxy.model.constants import ( - PROXY_SKIP_HEADERS, - RESPONSE_SANITIZE_SKIP_HEADERS, -) - # ═══════════════════════════════════════════════════════════════ # 1. UsageInfo — 默认值 & 自定义构造 @@ -171,9 +170,14 @@ def test_hashable(self): def test_all_enabled(self): """全量启用场景.""" caps = RequestCapabilities( - has_tools=True, has_thinking=True, has_images=True, has_metadata=True, + has_tools=True, + has_thinking=True, + has_images=True, + has_metadata=True, + ) + assert all( + [caps.has_tools, caps.has_thinking, caps.has_images, caps.has_metadata] ) - assert all([caps.has_tools, caps.has_thinking, caps.has_images, caps.has_metadata]) # ═══════════════════════════════════════════════════════════════ @@ -292,7 +296,9 @@ class TestNoCompatibleVendorError: def test_with_reasons(self): """带 reasons 列表构造: message 与 reasons 均可访问.""" - err = NoCompatibleVendorError("no vendor available", reasons=["tools", "thinking"]) + err = NoCompatibleVendorError( + "no vendor available", reasons=["tools", "thinking"] + ) assert str(err) == "no vendor available" assert err.reasons == ["tools", "thinking"] @@ -342,7 +348,11 @@ def test_construction(self): def test_mutable(self): """非 frozen: 可修改字段.""" diag = CopilotMisdirectedRequest( - base_url="", status_code=0, request=None, headers=None, body=b"", + base_url="", + status_code=0, + request=None, + headers=None, + body=b"", ) diag.base_url = "https://new.example.com" diag.status_code = 502 @@ -385,7 +395,9 @@ def test_to_dict_populated_all_fields(self): assert "ttl_seconds" in result assert result["ttl_seconds"] <= 1800 assert result["capabilities"] == {"models": ["gpt-4"]} - assert result["updated_at"] == now # to_dict() 输出 updated_at 而非 updated_at_unix + assert ( + result["updated_at"] == now + ) # to_dict() 输出 updated_at 而非 updated_at_unix def test_to_dict_ttl_seconds_non_negative(self): """ttl_seconds 始终 >= 0 (max(..., 0) 保护).""" @@ -488,9 +500,17 @@ def test_proxy_skip_headers_is_frozenset(self): def test_proxy_skip_headers_members(self): """包含 hop-by-hop 头部.""" - assert PROXY_SKIP_HEADERS == frozenset({ - "host", "content-length", "transfer-encoding", "connection", - }) + assert ( + frozenset( + { + "host", + "content-length", + "transfer-encoding", + "connection", + } + ) + == PROXY_SKIP_HEADERS + ) def test_response_sanitize_skip_headers_is_frozenset(self): """RESPONSE_SANITIZE_SKIP_HEADERS 类型为 frozenset.""" @@ -498,9 +518,16 @@ def test_response_sanitize_skip_headers_is_frozenset(self): def test_response_sanitize_skip_headers_members(self): """包含需移除的合成响应头部.""" - assert RESPONSE_SANITIZE_SKIP_HEADERS == frozenset({ - "content-encoding", "content-length", "transfer-encoding", - }) + assert ( + frozenset( + { + "content-encoding", + "content-length", + "transfer-encoding", + } + ) + == RESPONSE_SANITIZE_SKIP_HEADERS + ) def test_frozenset_immutability(self): """frozenset 不可变: add 抛 AttributeError.""" @@ -518,13 +545,15 @@ class TestSanitizeHeadersForSyntheticResponse: def test_removes_content_encoding_and_length(self): """移除 content-encoding / content-length / transfer-encoding.""" - raw = httpx.Headers({ - "content-type": "application/json", - "content-encoding": "gzip", - "content-length": "123", - "transfer-encoding": "chunked", - "x-request-id": "abc", - }) + raw = httpx.Headers( + { + "content-type": "application/json", + "content-encoding": "gzip", + "content-length": "123", + "transfer-encoding": "chunked", + "x-request-id": "abc", + } + ) result = sanitize_headers_for_synthetic_response(raw) assert "content-type" in result assert "x-request-id" in result @@ -534,11 +563,13 @@ def test_removes_content_encoding_and_length(self): def test_preserves_other_headers(self): """不匹配跳过集合的头部原样保留.""" - raw = httpx.Headers({ - "retry-after": "60", - "x-ratelimit-remaining": "0", - "content-type": "text/event-stream", - }) + raw = httpx.Headers( + { + "retry-after": "60", + "x-ratelimit-remaining": "0", + "content-type": "text/event-stream", + } + ) result = sanitize_headers_for_synthetic_response(raw) assert result["retry-after"] == "60" assert result["x-ratelimit-remaining"] == "0" @@ -559,11 +590,13 @@ def test_returns_plain_dict_not_httpx_headers(self): def test_case_insensitive_matching(self): """头部名匹配大小写不敏感 (httpx.Headers 内部统一小写).""" - raw = httpx.Headers({ - "Content-Encoding": "gzip", - "Content-Length": "42", - "Transfer-Encoding": "chunked", - }) + raw = httpx.Headers( + { + "Content-Encoding": "gzip", + "Content-Length": "42", + "Transfer-Encoding": "chunked", + } + ) result = sanitize_headers_for_synthetic_response(raw) assert "content-encoding" not in result assert "content-length" not in result @@ -571,10 +604,12 @@ def test_case_insensitive_matching(self): def test_no_decompression_error_on_synthetic_response(self): """清洗后头部可用于构造 httpx.Response 且不触发解压错误.""" - raw_headers = httpx.Headers({ - "content-type": "application/json", - "content-encoding": "gzip", - }) + raw_headers = httpx.Headers( + { + "content-type": "application/json", + "content-encoding": "gzip", + } + ) clean = sanitize_headers_for_synthetic_response(raw_headers) resp = httpx.Response( 429, @@ -596,37 +631,53 @@ class TestDecodeJsonBody: def test_valid_json_with_json_content_type(self): """标准 JSON content-type + 合法 JSON → 解析成功.""" - resp = httpx.Response(200, content=b'{"key":"value"}', headers={"content-type": "application/json"}) + resp = httpx.Response( + 200, + content=b'{"key":"value"}', + headers={"content-type": "application/json"}, + ) assert decode_json_body(resp) == {"key": "value"} def test_valid_json_array(self): """合法 JSON 数组 → 返回 list.""" - resp = httpx.Response(200, content=b'[1,2,3]', headers={"content-type": "application/json"}) + resp = httpx.Response( + 200, content=b"[1,2,3]", headers={"content-type": "application/json"} + ) assert decode_json_body(resp) == [1, 2, 3] def test_empty_content_returns_none(self): """空 body → None.""" - resp = httpx.Response(200, content=b"", headers={"content-type": "application/json"}) + resp = httpx.Response( + 200, content=b"", headers={"content-type": "application/json"} + ) assert decode_json_body(resp) is None def test_invalid_json_returns_none(self): """非法 JSON 内容 → None (安全降级).""" - resp = httpx.Response(200, content=b"{invalid json", headers={"content-type": "application/json"}) + resp = httpx.Response( + 200, content=b"{invalid json", headers={"content-type": "application/json"} + ) assert decode_json_body(resp) is None def test_html_content_type_returns_none(self): """text/html content-type + HTML 内容 → 无法解析为 JSON → None.""" - resp = httpx.Response(200, content=b"not json", headers={"content-type": "text/html"}) + resp = httpx.Response( + 200, content=b"not json", headers={"content-type": "text/html"} + ) assert decode_json_body(resp) is None def test_non_json_content_type_with_valid_json_body(self): """text/plain 但内容是合法 JSON → 尝试解析并返回结果.""" - resp = httpx.Response(200, content=b'{"ok":true}', headers={"content-type": "text/plain"}) + resp = httpx.Response( + 200, content=b'{"ok":true}', headers={"content-type": "text/plain"} + ) assert decode_json_body(resp) == {"ok": True} def test_non_json_content_type_with_invalid_body(self): """text/plain + 非法内容 → None.""" - resp = httpx.Response(200, content=b"just plain text", headers={"content-type": "text/plain"}) + resp = httpx.Response( + 200, content=b"just plain text", headers={"content-type": "text/plain"} + ) assert decode_json_body(resp) is None def test_no_content_type_header_valid_json(self): @@ -645,8 +696,12 @@ class TestExtractErrorMessage: def test_nested_error_dict(self): """{"error": {"message": "..."}} → 提取内层 message.""" - resp = httpx.Response(401, content=b'{"error":{"type":"auth","message":"bad token"}}') - msg = extract_error_message(resp, {"error": {"type": "auth", "message": "bad token"}}) + resp = httpx.Response( + 401, content=b'{"error":{"type":"auth","message":"bad token"}}' + ) + msg = extract_error_message( + resp, {"error": {"type": "auth", "message": "bad token"}} + ) assert msg == "bad token" def test_error_string_value(self): @@ -684,12 +739,16 @@ def test_whitespace_only_content_returns_none(self): def test_error_dict_without_message_key_returns_none(self): """error 是 dict 但无 message 键 → 返回 None (直接返回, 不回退到顶层 message 也不走 raw text).""" # 当 error 为 dict 时, 函数直接 return error.get("message"), 无 message 键则返回 None. - resp = httpx.Response(400, content=b'some raw error text') + resp = httpx.Response(400, content=b"some raw error text") msg = extract_error_message(resp, {"error": {"code": 123}}) assert msg is None def test_error_dict_with_message_takes_priority(self): """优先级: error.message 最先匹配, 即使顶层也有 message 字段.""" - resp = httpx.Response(400, content=b'{"error":{"message":"from_error"},"message":"from_top"}') - msg = extract_error_message(resp, {"error": {"message": "from_error"}, "message": "from_top"}) + resp = httpx.Response( + 400, content=b'{"error":{"message":"from_error"},"message":"from_top"}' + ) + msg = extract_error_message( + resp, {"error": {"message": "from_error"}, "message": "from_top"} + ) assert msg == "from_error" diff --git a/tests/test_parse_usage.py b/tests/test_parse_usage.py index c0971d8..8c277ca 100644 --- a/tests/test_parse_usage.py +++ b/tests/test_parse_usage.py @@ -2,7 +2,7 @@ import logging -from coding.proxy.routing.usage_parser import parse_usage_from_chunk, _set_if_nonzero +from coding.proxy.routing.usage_parser import _set_if_nonzero, parse_usage_from_chunk def _sse(data_str: str) -> bytes: @@ -39,10 +39,13 @@ def test_anthropic_message_start_and_delta(): usage: dict = {} # message_start - parse_usage_from_chunk(_sse( - '{"type":"message_start","message":{"id":"msg_123","model":"claude-sonnet-4-20250514",' - '"usage":{"input_tokens":100,"cache_creation_input_tokens":10,"cache_read_input_tokens":5}}}' - ), usage) + parse_usage_from_chunk( + _sse( + '{"type":"message_start","message":{"id":"msg_123","model":"claude-sonnet-4-20250514",' + '"usage":{"input_tokens":100,"cache_creation_input_tokens":10,"cache_read_input_tokens":5}}}' + ), + usage, + ) assert usage["input_tokens"] == 100 assert usage["cache_creation_tokens"] == 10 assert usage["cache_read_tokens"] == 5 @@ -50,10 +53,13 @@ def test_anthropic_message_start_and_delta(): assert usage["model_served"] == "claude-sonnet-4-20250514" # message_delta - parse_usage_from_chunk(_sse( - '{"type":"message_delta","delta":{"stop_reason":"end_turn"},' - '"usage":{"output_tokens":50}}' - ), usage) + parse_usage_from_chunk( + _sse( + '{"type":"message_delta","delta":{"stop_reason":"end_turn"},' + '"usage":{"output_tokens":50}}' + ), + usage, + ) assert usage["output_tokens"] == 50 # input_tokens 不被覆盖 assert usage["input_tokens"] == 100 @@ -62,27 +68,33 @@ def test_anthropic_message_start_and_delta(): def test_anthropic_empty_usage(): """message_start 中 usage 为空对象,后续有输出.""" usage: dict = {} - parse_usage_from_chunk(_sse( - '{"type":"message_start","message":{"id":"msg_abc","usage":{}}}' - ), usage) + parse_usage_from_chunk( + _sse('{"type":"message_start","message":{"id":"msg_abc","usage":{}}}'), usage + ) assert usage.get("input_tokens", 0) == 0 - parse_usage_from_chunk(_sse( - '{"type":"message_delta","delta":{},"usage":{"output_tokens":30}}' - ), usage) + parse_usage_from_chunk( + _sse('{"type":"message_delta","delta":{},"usage":{"output_tokens":30}}'), usage + ) assert usage["output_tokens"] == 30 def test_anthropic_cache_only_input_signal(): """Anthropic prompt caching 场景下,cache tokens 本身就是有效输入信号.""" usage: dict = {} - parse_usage_from_chunk(_sse( - '{"type":"message_start","message":{"id":"msg_cache_only","usage":' - '{"input_tokens":0,"cache_creation_input_tokens":720,"cache_read_input_tokens":82408}}}' - ), usage) - parse_usage_from_chunk(_sse( - '{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":8220}}' - ), usage) + parse_usage_from_chunk( + _sse( + '{"type":"message_start","message":{"id":"msg_cache_only","usage":' + '{"input_tokens":0,"cache_creation_input_tokens":720,"cache_read_input_tokens":82408}}}' + ), + usage, + ) + parse_usage_from_chunk( + _sse( + '{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":8220}}' + ), + usage, + ) assert usage.get("input_tokens", 0) == 0 assert usage["cache_creation_tokens"] == 720 @@ -96,11 +108,14 @@ def test_anthropic_cache_only_input_signal(): def test_openai_zhipu_final_chunk(): """Zhipu 最后一个 chunk: 顶层 usage 含 prompt_tokens / completion_tokens.""" usage: dict = {} - parse_usage_from_chunk(_sse( - '{"id":"chatcmpl-1","model":"glm-5.1",' - '"choices":[{"index":0,"finish_reason":"stop","delta":{"role":"assistant","content":""}}],' - '"usage":{"prompt_tokens":200,"completion_tokens":80,"total_tokens":280}}' - ), usage) + parse_usage_from_chunk( + _sse( + '{"id":"chatcmpl-1","model":"glm-5.1",' + '"choices":[{"index":0,"finish_reason":"stop","delta":{"role":"assistant","content":""}}],' + '"usage":{"prompt_tokens":200,"completion_tokens":80,"total_tokens":280}}' + ), + usage, + ) assert usage["input_tokens"] == 200 assert usage["output_tokens"] == 80 assert usage["request_id"] == "chatcmpl-1" @@ -109,10 +124,13 @@ def test_openai_zhipu_final_chunk(): def test_openai_final_chunk_with_cache_tokens(): """OpenAI/Copilot 风格最终 chunk 应提取 cache read / creation tokens.""" usage: dict = {} - parse_usage_from_chunk(_sse( - '{"id":"chatcmpl-cache","usage":{"prompt_tokens":120,"completion_tokens":30,' - '"cache_read_input_tokens":40,"cache_creation_input_tokens":10}}' - ), usage) + parse_usage_from_chunk( + _sse( + '{"id":"chatcmpl-cache","usage":{"prompt_tokens":120,"completion_tokens":30,' + '"cache_read_input_tokens":40,"cache_creation_input_tokens":10}}' + ), + usage, + ) assert usage["input_tokens"] == 120 assert usage["output_tokens"] == 30 assert usage["cache_read_tokens"] == 40 @@ -123,18 +141,24 @@ def test_openai_final_chunk_with_cache_tokens(): def test_openai_zhipu_content_chunks_no_usage(): """Zhipu 中间 chunk 不含 usage,不应干扰.""" usage: dict = {} - parse_usage_from_chunk(_sse( - '{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}' - ), usage) + parse_usage_from_chunk( + _sse( + '{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}' + ), + usage, + ) assert usage.get("input_tokens", 0) == 0 assert usage.get("output_tokens", 0) == 0 # 最后一个 chunk 才有 usage - parse_usage_from_chunk(_sse( - '{"id":"chatcmpl-1","choices":[{"index":0,"finish_reason":"stop",' - '"delta":{"role":"assistant","content":""}}],' - '"usage":{"prompt_tokens":50,"completion_tokens":10}}' - ), usage) + parse_usage_from_chunk( + _sse( + '{"id":"chatcmpl-1","choices":[{"index":0,"finish_reason":"stop",' + '"delta":{"role":"assistant","content":""}}],' + '"usage":{"prompt_tokens":50,"completion_tokens":10}}' + ), + usage, + ) assert usage["input_tokens"] == 50 assert usage["output_tokens"] == 10 @@ -145,15 +169,21 @@ def test_openai_zhipu_content_chunks_no_usage(): def test_mixed_anthropic_input_openai_output(): """Anthropic message_start 提供 input_tokens, Zhipu 最后 chunk 提供 completion_tokens.""" usage: dict = {} - parse_usage_from_chunk(_sse( - '{"type":"message_start","message":{"id":"msg_mix","usage":{"input_tokens":150}}}' - ), usage) + parse_usage_from_chunk( + _sse( + '{"type":"message_start","message":{"id":"msg_mix","usage":{"input_tokens":150}}}' + ), + usage, + ) assert usage["input_tokens"] == 150 - parse_usage_from_chunk(_sse( - '{"id":"mix-1","choices":[{"finish_reason":"stop","delta":{}}],' - '"usage":{"completion_tokens":40}}' - ), usage) + parse_usage_from_chunk( + _sse( + '{"id":"mix-1","choices":[{"finish_reason":"stop","delta":{}}],' + '"usage":{"completion_tokens":40}}' + ), + usage, + ) assert usage["output_tokens"] == 40 assert usage["input_tokens"] == 150 # 不被后续 chunk 覆盖 @@ -164,15 +194,18 @@ def test_mixed_anthropic_input_openai_output(): def test_zero_does_not_overwrite_nonzero(): """后续 chunk 的 0 值不应覆盖已提取的非零值.""" usage: dict = {} - parse_usage_from_chunk(_sse( - '{"type":"message_start","message":{"id":"msg_z","usage":{"input_tokens":100}}}' - ), usage) + parse_usage_from_chunk( + _sse( + '{"type":"message_start","message":{"id":"msg_z","usage":{"input_tokens":100}}}' + ), + usage, + ) assert usage["input_tokens"] == 100 # 另一个 message_start 带 input_tokens=0(如 Anthropic 某些事件) - parse_usage_from_chunk(_sse( - '{"type":"message_start","message":{"usage":{"input_tokens":0}}}' - ), usage) + parse_usage_from_chunk( + _sse('{"type":"message_start","message":{"usage":{"input_tokens":0}}}'), usage + ) assert usage["input_tokens"] == 100 @@ -182,24 +215,28 @@ def test_zero_does_not_overwrite_nonzero(): def test_request_id_from_top_level(): """OpenAI 格式: id 在顶层而非 message 内.""" usage: dict = {} - parse_usage_from_chunk(_sse( - '{"id":"top-level-id","usage":{"prompt_tokens":10,"completion_tokens":5}}' - ), usage) + parse_usage_from_chunk( + _sse( + '{"id":"top-level-id","usage":{"prompt_tokens":10,"completion_tokens":5}}' + ), + usage, + ) assert usage["request_id"] == "top-level-id" def test_request_id_message_priority(): """message.id 应优先于顶层 data.id.""" usage: dict = {} - parse_usage_from_chunk(_sse( - '{"id":"top-id","message":{"id":"msg-id","usage":{"input_tokens":10}}}' - ), usage) + parse_usage_from_chunk( + _sse('{"id":"top-id","message":{"id":"msg-id","usage":{"input_tokens":10}}}'), + usage, + ) assert usage["request_id"] == "msg-id" # 后续顶层 id 不应覆盖已设置的 message.id - parse_usage_from_chunk(_sse( - '{"id":"another-top-id","usage":{"output_tokens":5}}' - ), usage) + parse_usage_from_chunk( + _sse('{"id":"another-top-id","usage":{"output_tokens":5}}'), usage + ) assert usage["request_id"] == "msg-id" @@ -240,7 +277,9 @@ def test_vendor_label_anthropic(caplog): caplog.set_level(logging.DEBUG, logger="coding.proxy.routing.usage_parser") usage: dict = {} parse_usage_from_chunk( - _sse('{"type":"message_delta","delta":{},"usage":{"output_tokens":42,"input_tokens":10}}'), + _sse( + '{"type":"message_delta","delta":{},"usage":{"output_tokens":42,"input_tokens":10}}' + ), usage, vendor_label="Anthropic", ) @@ -264,7 +303,9 @@ def test_vendor_label_gemini(caplog): caplog.set_level(logging.DEBUG, logger="coding.proxy.routing.usage_parser") usage: dict = {} parse_usage_from_chunk( - _sse('{"type":"message_delta","delta":{},"usage":{"output_tokens":30,"input_tokens":200}}'), + _sse( + '{"type":"message_delta","delta":{},"usage":{"output_tokens":30,"input_tokens":200}}' + ), usage, vendor_label="Gemini", ) @@ -281,4 +322,8 @@ def test_no_vendor_label_omits_parenthesis(caplog): ) # 不应有任何带括号的 vendor 标签 for record in caplog.records: - assert "(" not in record.message or "Extracted" not in record.message or ") " not in record.message[record.message.index("("):] + assert ( + "(" not in record.message + or "Extracted" not in record.message + or ") " not in record.message[record.message.index("(") :] + ) diff --git a/tests/test_pricing.py b/tests/test_pricing.py index d1edcce..ec5eff6 100644 --- a/tests/test_pricing.py +++ b/tests/test_pricing.py @@ -49,6 +49,7 @@ class TestPricingTable: def _make_entry(self, vendor: str = "copilot", model: str = "test", **kwargs): from coding.proxy.config.routing import ModelPricingEntry + # 字段单位为 USD / 1M tokens,直接传入原始值(如 3 表示 $3/M tokens) return ModelPricingEntry( vendor=vendor, @@ -65,9 +66,11 @@ def test_empty_table(self): assert table.compute_cost("copilot", "any", 100, 200, 0, 0) is None def test_exact_match(self): - table = PricingTable([ - self._make_entry("copilot", "model-a", input=2, output=10), - ]) + table = PricingTable( + [ + self._make_entry("copilot", "model-a", input=2, output=10), + ] + ) pricing = table.get_pricing("copilot", "model-a") assert pricing is not None assert pricing.input_cost_per_token == 2e-6 @@ -75,18 +78,22 @@ def test_exact_match(self): def test_normalized_match(self): """规范化匹配:'glm.4.5-air' → 'glm-4-5-air'.""" - table = PricingTable([ - self._make_entry("antigravity", "glm.4.5.air", input=1, output=5), - ]) + table = PricingTable( + [ + self._make_entry("antigravity", "glm.4.5.air", input=1, output=5), + ] + ) # 精确不命中(含点号) assert table.get_pricing("antigravity", "glm.4.5.air") is not None # 规范化命中 assert table.get_pricing("antigravity", "glm-4-5-air") is not None def test_compute_cost(self): - table = PricingTable([ - self._make_entry("copilot", "m", input=3, output=15, cwrite=1, cread=2), - ]) + table = PricingTable( + [ + self._make_entry("copilot", "m", input=3, output=15, cwrite=1, cread=2), + ] + ) cost_value = table.compute_cost("copilot", "m", 1000, 2000, 50, 100) assert cost_value is not None assert isinstance(cost_value, CostValue) @@ -100,10 +107,12 @@ def test_compute_cost_miss_returns_none(self): def test_multiple_entries_same_vendor(self): """同一 vendor 下多模型独立定价.""" - table = PricingTable([ - self._make_entry("copilot", "cheap", input=1, output=1), - self._make_entry("copilot", "expensive", input=10, output=50), - ]) + table = PricingTable( + [ + self._make_entry("copilot", "cheap", input=1, output=1), + self._make_entry("copilot", "expensive", input=10, output=50), + ] + ) cheap = table.get_pricing("copilot", "cheap") expensive = table.get_pricing("copilot", "expensive") assert cheap.input_cost_per_token < expensive.input_cost_per_token @@ -114,6 +123,7 @@ class TestPricingTableCurrency: def _make_usd_entry(self, **kwargs): from coding.proxy.config.routing import ModelPricingEntry + return ModelPricingEntry( vendor="anthropic", model="claude-test", @@ -125,6 +135,7 @@ def _make_usd_entry(self, **kwargs): def _make_cny_entry(self, **kwargs): from coding.proxy.config.routing import ModelPricingEntry + return ModelPricingEntry( vendor="zhipu", model="glm-test", @@ -160,7 +171,9 @@ def test_compute_cost_amount_correct_usd(self): def test_compute_cost_amount_correct_cny(self): """CNY 定价金额计算正确.""" - table = PricingTable([self._make_cny_entry(input="\u00a51.0", output="\u00a53.2")]) + table = PricingTable( + [self._make_cny_entry(input="\u00a51.0", output="\u00a53.2")] + ) result = table.compute_cost("zhipu", "glm-test", 1000, 2000, 0, 0) assert result is not None expected = 1000 * 1e-6 + 2000 * 3.2e-6 @@ -183,8 +196,10 @@ def test_compute_cost_format_cny(self): def test_backward_compatible_plain_number(self): """不带前缀的纯数字应默认为 USD(向后兼容).""" from coding.proxy.config.routing import ModelPricingEntry + entry = ModelPricingEntry( - vendor="copilot", model="legacy", + vendor="copilot", + model="legacy", input_cost_per_mtok=3.0, output_cost_per_mtok=15.0, ) @@ -204,9 +219,11 @@ def test_get_pricing_carries_currency(self): def test_mixed_currency_rejected(self): """同一 entry 内混用 $ 和 ¥ 应抛出 ValidationError.""" from coding.proxy.config.routing import ModelPricingEntry - with pytest.raises(Exception): # pydantic.ValidationError + + with pytest.raises(Exception): # pydantic.ValidationError ModelPricingEntry( - vendor="test", model="mixed", + vendor="test", + model="mixed", input_cost_per_mtok="$3.0", output_cost_per_mtok="\u00a35.0", ) @@ -214,9 +231,11 @@ def test_mixed_currency_rejected(self): def test_negative_price_rejected(self): """负数价格应抛出 ValidationError.""" from coding.proxy.config.routing import ModelPricingEntry - with pytest.raises(Exception): # pydantic.ValidationError + + with pytest.raises(Exception): # pydantic.ValidationError ModelPricingEntry( - vendor="test", model="neg", + vendor="test", + model="neg", input_cost_per_mtok="$-3.0", output_cost_per_mtok="$15.0", ) @@ -224,6 +243,7 @@ def test_negative_price_rejected(self): def test_private_attr_not_in_dump(self): """_currency 不应出现在 model_dump 序列化输出中.""" from coding.proxy.config.routing import ModelPricingEntry + entry = ModelPricingEntry(vendor="t", model="m", input_cost_per_mtok="$3.0") dump = entry.model_dump() assert "__currency__" not in dump @@ -233,5 +253,6 @@ def test_private_attr_not_in_dump(self): def test_all_zero_no_currency_error(self): """所有价格字段为零时不应触发币种校验错误.""" from coding.proxy.config.routing import ModelPricingEntry + entry = ModelPricingEntry(vendor="t", model="m") - assert entry.currency == "USD" # 默认值 + assert entry.currency == "USD" # 默认值 diff --git a/tests/test_quota_guard.py b/tests/test_quota_guard.py index 4f6309e..9d3ed9a 100644 --- a/tests/test_quota_guard.py +++ b/tests/test_quota_guard.py @@ -3,7 +3,7 @@ import time from unittest.mock import patch -from coding.proxy.routing.quota_guard import QuotaGuard, QuotaState +from coding.proxy.routing.quota_guard import QuotaGuard def _make_guard(**overrides): diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py index 94ee095..f286073 100644 --- a/tests/test_rate_limit.py +++ b/tests/test_rate_limit.py @@ -9,7 +9,6 @@ parse_rate_limit_headers, ) - # --- compute_rate_limit_deadline --- @@ -69,9 +68,9 @@ def test_compute_deadline_takes_max_of_all_signals(): """多信号并存时取最大值.""" now = time.monotonic() info = RateLimitInfo( - retry_after_seconds=10.0, # → now + 11 - requests_reset_at=now + 200, # → now + 220 - tokens_reset_at=now + 100, # → now + 110 + retry_after_seconds=10.0, # → now + 11 + requests_reset_at=now + 200, # → now + 220 + tokens_reset_at=now + 100, # → now + 110 ) deadline = compute_rate_limit_deadline(info) assert deadline is not None diff --git a/tests/test_request_normalizer.py b/tests/test_request_normalizer.py index a7fd78f..7a7f7ed 100644 --- a/tests/test_request_normalizer.py +++ b/tests/test_request_normalizer.py @@ -6,31 +6,33 @@ def test_rewrites_server_tool_use_to_standard_tool_use(): - result = normalize_anthropic_request({ - "messages": [ - { - "role": "assistant", - "content": [ - { - "type": "server_tool_use", - "id": "srvtoolu_bad_1", - "name": "bash", - "input": {"cmd": "pwd"}, - }, - ], - }, - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "srvtoolu_bad_1", - "content": "ok", - }, - ], - }, - ], - }) + result = normalize_anthropic_request( + { + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "server_tool_use", + "id": "srvtoolu_bad_1", + "name": "bash", + "input": {"cmd": "pwd"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "srvtoolu_bad_1", + "content": "ok", + }, + ], + }, + ], + } + ) assistant_block = result.body["messages"][0]["content"][0] user_block = result.body["messages"][1]["content"][0] @@ -43,18 +45,23 @@ def test_rewrites_server_tool_use_to_standard_tool_use(): def test_filters_vendor_delta_blocks(): - result = normalize_anthropic_request({ - "messages": [ - { - "role": "assistant", - "content": [ - {"type": "text", "text": "before"}, - {"type": "server_tool_use_delta", "partial_json": '{"cmd":"pwd"}'}, - {"type": "text", "text": "after"}, - ], - }, - ], - }) + result = normalize_anthropic_request( + { + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "text", "text": "before"}, + { + "type": "server_tool_use_delta", + "partial_json": '{"cmd":"pwd"}', + }, + {"type": "text", "text": "after"}, + ], + }, + ], + } + ) content = result.body["messages"][0]["content"] assert len(content) == 2 @@ -63,20 +70,22 @@ def test_filters_vendor_delta_blocks(): def test_unknown_tool_result_id_marks_fatal_reason(): - result = normalize_anthropic_request({ - "messages": [ - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "bad_unknown_id", - "content": "nope", - }, - ], - }, - ], - }) + result = normalize_anthropic_request( + { + "messages": [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "bad_unknown_id", + "content": "nope", + }, + ], + }, + ], + } + ) assert result.recoverable is False assert result.fatal_reasons diff --git a/tests/test_router_chain.py b/tests/test_router_chain.py index f5694bf..aba8a96 100644 --- a/tests/test_router_chain.py +++ b/tests/test_router_chain.py @@ -2,22 +2,25 @@ from __future__ import annotations -from dataclasses import dataclass, field -from typing import Any, AsyncIterator +from collections.abc import AsyncIterator from unittest.mock import AsyncMock import httpx import pytest -from coding.proxy.vendors.base import BaseVendor, VendorCapabilities, VendorResponse, UsageInfo -from coding.proxy.vendors.copilot import CopilotVendor -from coding.proxy.vendors.token_manager import TokenAcquireError from coding.proxy.config.schema import CopilotConfig, FailoverConfig from coding.proxy.routing.circuit_breaker import CircuitBreaker from coding.proxy.routing.quota_guard import QuotaGuard from coding.proxy.routing.router import RequestRouter from coding.proxy.routing.tier import VendorTier - +from coding.proxy.vendors.base import ( + BaseVendor, + UsageInfo, + VendorCapabilities, + VendorResponse, +) +from coding.proxy.vendors.copilot import CopilotVendor +from coding.proxy.vendors.token_manager import TokenAcquireError # --- 测试用 Mock 供应商 --- @@ -76,12 +79,19 @@ def _headers() -> dict: @pytest.mark.asyncio async def test_route_message_primary_success(): """首层成功 → 直接返回.""" - b0 = FakeVendor("primary", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=10, output_tokens=5))) + b0 = FakeVendor( + "primary", + VendorResponse( + status_code=200, usage=UsageInfo(input_tokens=10, output_tokens=5) + ), + ) b1 = FakeVendor("fallback") - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b1), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b1), + ] + ) resp = await router.route_message(_body(), _headers()) assert resp.status_code == 200 assert b0.call_count == 1 @@ -101,16 +111,21 @@ async def test_route_message_failover_to_tier1(): ) # 为 primary 配置 failover from coding.proxy.config.schema import FailoverConfig + b0._failover_config = FailoverConfig() - b1 = FakeVendor("copilot", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=20))) + b1 = FakeVendor( + "copilot", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=20)) + ) b2 = FakeVendor("zhipu") - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b1, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b2), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b1, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b2), + ] + ) resp = await router.route_message(_body(), _headers()) assert resp.status_code == 200 assert b0.call_count == 1 @@ -122,12 +137,16 @@ async def test_route_message_failover_to_tier1(): async def test_route_message_read_error_failover_to_tier1(): """首层 ReadError → 次层接管.""" b0 = FakeVendor("primary", raise_on_call=httpx.ReadError("boom")) - b1 = FakeVendor("copilot", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=20))) + b1 = FakeVendor( + "copilot", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=20)) + ) - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b1), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b1), + ] + ) resp = await router.route_message(_body(), _headers()) @@ -141,19 +160,33 @@ async def test_route_message_failover_to_terminal(): """前两层都失败 → 终端层接管.""" from coding.proxy.config.schema import FailoverConfig - b0 = FakeVendor("primary", VendorResponse(status_code=429, error_type="rate_limit_error", error_message="limit")) + b0 = FakeVendor( + "primary", + VendorResponse( + status_code=429, error_type="rate_limit_error", error_message="limit" + ), + ) b0._failover_config = FailoverConfig() - b1 = FakeVendor("copilot", VendorResponse(status_code=503, error_type="overloaded_error", error_message="overloaded")) + b1 = FakeVendor( + "copilot", + VendorResponse( + status_code=503, error_type="overloaded_error", error_message="overloaded" + ), + ) b1._failover_config = FailoverConfig() - b2 = FakeVendor("zhipu", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=30))) + b2 = FakeVendor( + "zhipu", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=30)) + ) - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b1, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b2), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b1, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b2), + ] + ) resp = await router.route_message(_body(), _headers()) assert resp.status_code == 200 assert b0.call_count == 1 @@ -167,10 +200,12 @@ async def test_route_message_connection_error_failover(): b0 = FakeVendor("primary", raise_on_call=httpx.ConnectError("connection refused")) b1 = FakeVendor("fallback", VendorResponse(status_code=200)) - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b1), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b1), + ] + ) resp = await router.route_message(_body(), _headers()) assert resp.status_code == 200 assert b0.call_count == 1 @@ -183,10 +218,12 @@ async def test_route_message_last_tier_raises(): b0 = FakeVendor("primary", raise_on_call=httpx.ConnectError("refused")) b1 = FakeVendor("fallback", raise_on_call=httpx.ConnectError("also refused")) - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b1), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b1), + ] + ) with pytest.raises(httpx.ConnectError): await router.route_message(_body(), _headers()) @@ -200,10 +237,12 @@ async def test_circuit_open_skips_tier(): cb0 = CircuitBreaker(failure_threshold=1) cb0.record_failure() # OPEN - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=cb0), - VendorTier(vendor=b1), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=cb0), + VendorTier(vendor=b1), + ] + ) resp = await router.route_message(_body(), _headers()) assert resp.status_code == 200 assert b0.call_count == 0 # 被跳过 @@ -216,13 +255,20 @@ async def test_quota_exceeded_skips_tier(): b0 = FakeVendor("primary", VendorResponse(status_code=200)) b1 = FakeVendor("fallback", VendorResponse(status_code=200)) - qg = QuotaGuard(enabled=True, token_budget=100, window_seconds=3600, probe_interval_seconds=99999) + qg = QuotaGuard( + enabled=True, + token_budget=100, + window_seconds=3600, + probe_interval_seconds=99999, + ) qg.notify_cap_error() - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker(), quota_guard=qg), - VendorTier(vendor=b1), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker(), quota_guard=qg), + VendorTier(vendor=b1), + ] + ) resp = await router.route_message(_body(), _headers()) assert resp.status_code == 200 assert b0.call_count == 0 @@ -242,11 +288,13 @@ async def test_all_non_terminal_skipped_reaches_terminal(): cb1 = CircuitBreaker(failure_threshold=1) cb1.record_failure() - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=cb0), - VendorTier(vendor=b1, circuit_breaker=cb1), - VendorTier(vendor=b2), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=cb0), + VendorTier(vendor=b1, circuit_breaker=cb1), + VendorTier(vendor=b2), + ] + ) resp = await router.route_message(_body(), _headers()) assert resp.status_code == 200 assert b0.call_count == 0 @@ -261,11 +309,13 @@ async def test_last_tier_always_tried_even_if_unavailable(): # 终端层无 CB/QG,始终被执行 b1 = FakeVendor("fallback", VendorResponse(status_code=200)) - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b1), - ]) - resp = await router.route_message(_body(), _headers()) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b1), + ] + ) + await router.route_message(_body(), _headers()) assert b1.call_count == 1 @@ -279,10 +329,12 @@ async def test_route_stream_primary_success(): b0 = FakeVendor("primary", stream_chunks=chunks) b1 = FakeVendor("fallback") - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b1), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b1), + ] + ) collected = [] async for chunk, name in router.route_stream(_body(), _headers()): @@ -299,10 +351,12 @@ async def test_route_stream_failover(): b0 = FakeVendor("primary", raise_on_call=httpx.ConnectError("refused")) b1 = FakeVendor("fallback", stream_chunks=[b"data: ok\n\n"]) - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b1), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b1), + ] + ) collected = [] async for chunk, name in router.route_stream(_body(), _headers()): @@ -318,10 +372,12 @@ async def test_route_stream_read_error_failover(): b0 = FakeVendor("primary", raise_on_call=httpx.ReadError("boom")) b1 = FakeVendor("fallback", stream_chunks=[b"data: ok\n\n"]) - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b1), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b1), + ] + ) collected = [] async for chunk, name in router.route_stream(_body(), _headers()): @@ -337,10 +393,12 @@ async def test_route_stream_all_fail_raises(): b0 = FakeVendor("primary", raise_on_call=httpx.ConnectError("refused")) b1 = FakeVendor("fallback", raise_on_call=httpx.ConnectError("also refused")) - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b1), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b1), + ] + ) with pytest.raises(httpx.ConnectError): async for _ in router.route_stream(_body(), _headers()): @@ -362,10 +420,12 @@ async def test_router_close_calls_all_vendors(): b0.close = AsyncMock() b1.close = AsyncMock() - router = RequestRouter([ - VendorTier(vendor=b0), - VendorTier(vendor=b1), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0), + VendorTier(vendor=b1), + ] + ) await router.close() b0.close.assert_awaited_once() b1.close.assert_awaited_once() @@ -385,23 +445,42 @@ async def test_four_tier_failover_chain(): """4-tier 完整降级:anthropic→copilot→antigravity→zhipu.""" from coding.proxy.config.schema import FailoverConfig - b0 = FakeVendor("anthropic", VendorResponse(status_code=429, error_type="rate_limit_error", error_message="limit")) + b0 = FakeVendor( + "anthropic", + VendorResponse( + status_code=429, error_type="rate_limit_error", error_message="limit" + ), + ) b0._failover_config = FailoverConfig() - b1 = FakeVendor("copilot", VendorResponse(status_code=503, error_type="overloaded_error", error_message="overloaded")) + b1 = FakeVendor( + "copilot", + VendorResponse( + status_code=503, error_type="overloaded_error", error_message="overloaded" + ), + ) b1._failover_config = FailoverConfig() - b2 = FakeVendor("antigravity", VendorResponse(status_code=403, error_type="api_error", error_message="forbidden")) + b2 = FakeVendor( + "antigravity", + VendorResponse( + status_code=403, error_type="api_error", error_message="forbidden" + ), + ) b2._failover_config = FailoverConfig() - b3 = FakeVendor("zhipu", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=50))) + b3 = FakeVendor( + "zhipu", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=50)) + ) - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b1, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b2, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b3), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b1, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b2, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b3), + ] + ) resp = await router.route_message(_body(), _headers()) assert resp.status_code == 200 assert b0.call_count == 1 @@ -415,21 +494,38 @@ async def test_four_tier_antigravity_succeeds(): """4-tier:前两层失败,antigravity 成功.""" from coding.proxy.config.schema import FailoverConfig - b0 = FakeVendor("anthropic", VendorResponse(status_code=429, error_type="rate_limit_error", error_message="limit")) + b0 = FakeVendor( + "anthropic", + VendorResponse( + status_code=429, error_type="rate_limit_error", error_message="limit" + ), + ) b0._failover_config = FailoverConfig() - b1 = FakeVendor("copilot", VendorResponse(status_code=429, error_type="rate_limit_error", error_message="limit")) + b1 = FakeVendor( + "copilot", + VendorResponse( + status_code=429, error_type="rate_limit_error", error_message="limit" + ), + ) b1._failover_config = FailoverConfig() - b2 = FakeVendor("antigravity", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=40, output_tokens=20))) + b2 = FakeVendor( + "antigravity", + VendorResponse( + status_code=200, usage=UsageInfo(input_tokens=40, output_tokens=20) + ), + ) b3 = FakeVendor("zhipu") - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b1, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b2, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b3), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b1, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b2, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b3), + ] + ) resp = await router.route_message(_body(), _headers()) assert resp.status_code == 200 assert b0.call_count == 1 @@ -450,12 +546,14 @@ async def test_four_tier_all_non_terminal_skipped(): for cb in cbs: cb.record_failure() - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=cbs[0]), - VendorTier(vendor=b1, circuit_breaker=cbs[1]), - VendorTier(vendor=b2, circuit_breaker=cbs[2]), - VendorTier(vendor=b3), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=cbs[0]), + VendorTier(vendor=b1, circuit_breaker=cbs[1]), + VendorTier(vendor=b2, circuit_breaker=cbs[2]), + VendorTier(vendor=b3), + ] + ) resp = await router.route_message(_body(), _headers()) assert resp.status_code == 200 assert b0.call_count == 0 @@ -472,12 +570,14 @@ async def test_four_tier_stream_failover(): b2 = FakeVendor("antigravity", raise_on_call=httpx.ConnectError("refused")) b3 = FakeVendor("zhipu", stream_chunks=[b"data: ok\n\n"]) - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b1, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b2, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b3), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b1, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b2, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b3), + ] + ) collected = [] async for chunk, name in router.route_stream(_body(), _headers()): @@ -496,19 +596,30 @@ async def test_four_tier_failover_when_copilot_token_acquire_fails(): """Anthropic 429 后,Copilot token 获取失败仍应降级到 Antigravity.""" from coding.proxy.config.schema import FailoverConfig - b0 = FakeVendor("anthropic", VendorResponse(status_code=429, error_type="rate_limit_error", error_message="limit")) + b0 = FakeVendor( + "anthropic", + VendorResponse( + status_code=429, error_type="rate_limit_error", error_message="limit" + ), + ) b0._failover_config = FailoverConfig() - b1 = FakeVendor("copilot", raise_on_call=TokenAcquireError("Copilot token 交换返回非预期响应")) - b2 = FakeVendor("antigravity", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=40))) + b1 = FakeVendor( + "copilot", raise_on_call=TokenAcquireError("Copilot token 交换返回非预期响应") + ) + b2 = FakeVendor( + "antigravity", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=40)) + ) b3 = FakeVendor("zhipu") - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b1, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b2, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b3), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b1, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b2, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b3), + ] + ) resp = await router.route_message(_body(), _headers()) assert resp.status_code == 200 @@ -522,16 +633,20 @@ async def test_four_tier_failover_when_copilot_token_acquire_fails(): async def test_four_tier_stream_failover_when_copilot_token_acquire_fails(): """流式请求下,Copilot token 获取失败也应继续降级.""" b0 = FakeVendor("anthropic", raise_on_call=httpx.ConnectError("refused")) - b1 = FakeVendor("copilot", raise_on_call=TokenAcquireError("Copilot token 交换返回非预期响应")) + b1 = FakeVendor( + "copilot", raise_on_call=TokenAcquireError("Copilot token 交换返回非预期响应") + ) b2 = FakeVendor("antigravity", stream_chunks=[b"data: ok\n\n"]) b3 = FakeVendor("zhipu") - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b1, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b2, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b3), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b1, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b2, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b3), + ] + ) collected = [] async for chunk, name in router.route_stream(_body(), _headers()): @@ -546,37 +661,45 @@ async def test_stream_failover_to_copilot_even_when_request_has_thinking(): """Anthropic 429 且请求含 thinking 时,Copilot 仍应通过适配层接管.""" from coding.proxy.config.schema import FailoverConfig as RouterFailoverConfig - b0 = FakeVendor("anthropic", raise_on_call=httpx.HTTPStatusError( - "anthropic API error: 429", - request=httpx.Request("POST", "https://api.anthropic.com/v1/messages"), - response=httpx.Response( - 429, - content=b'{"error":{"type":"rate_limit_error","message":"limited"}}', - headers={"content-type": "application/json"}, + b0 = FakeVendor( + "anthropic", + raise_on_call=httpx.HTTPStatusError( + "anthropic API error: 429", request=httpx.Request("POST", "https://api.anthropic.com/v1/messages"), + response=httpx.Response( + 429, + content=b'{"error":{"type":"rate_limit_error","message":"limited"}}', + headers={"content-type": "application/json"}, + request=httpx.Request("POST", "https://api.anthropic.com/v1/messages"), + ), ), - )) + ) b0._failover_config = RouterFailoverConfig() copilot = CopilotVendor(CopilotConfig(github_token="ghp_test"), FailoverConfig()) copilot.check_health = AsyncMock(return_value=True) # type: ignore[method-assign] async def _copilot_stream(_body, _headers): - yield b"event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"model\":\"claude-sonnet-4\",\"usage\":{\"input_tokens\":10}}}\n\n" - yield b"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" + yield b'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1","model":"claude-sonnet-4","usage":{"input_tokens":10}}}\n\n' + yield b'event: message_stop\ndata: {"type":"message_stop"}\n\n' copilot.send_message_stream = _copilot_stream # type: ignore[method-assign] - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=copilot, circuit_breaker=CircuitBreaker()), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=copilot, circuit_breaker=CircuitBreaker()), + ] + ) collected: list[tuple[bytes, str]] = [] - async for chunk, name in router.route_stream({ - **_body(), - "thinking": {"budget_tokens": 512}, - }, _headers()): + async for chunk, name in router.route_stream( + { + **_body(), + "thinking": {"budget_tokens": 512}, + }, + _headers(), + ): collected.append((chunk, name)) assert collected @@ -599,7 +722,9 @@ async def test_stream_semantic_rejection_fails_over_without_opening_circuit(): ) b0 = FakeVendor( "anthropic", - raise_on_call=httpx.HTTPStatusError("anthropic API error: 400", request=request, response=response), + raise_on_call=httpx.HTTPStatusError( + "anthropic API error: 400", request=request, response=response + ), ) b0._failover_config = FailoverConfig() @@ -611,10 +736,12 @@ async def test_stream_semantic_rejection_fails_over_without_opening_circuit(): ], ) cb0 = CircuitBreaker(failure_threshold=1) - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=cb0), - VendorTier(vendor=b1), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=cb0), + VendorTier(vendor=b1), + ] + ) collected: list[tuple[bytes, str]] = [] async for chunk, name in router.route_stream(_body(), _headers()): @@ -639,12 +766,16 @@ async def test_nonstream_semantic_rejection_fails_over_without_opening_circuit() ), ) b0._failover_config = FailoverConfig() - b1 = FakeVendor("zhipu", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=8))) + b1 = FakeVendor( + "zhipu", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=8)) + ) cb0 = CircuitBreaker(failure_threshold=1) - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=cb0), - VendorTier(vendor=b1), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=cb0), + VendorTier(vendor=b1), + ] + ) resp = await router.route_message(_body(), _headers()) @@ -657,28 +788,39 @@ async def test_nonstream_semantic_rejection_fails_over_without_opening_circuit() @pytest.mark.asyncio async def test_incompatible_tool_request_skips_non_compatible_tiers(): """带 tools 的请求不会静默降级到不兼容的 antigravity/zhipu.""" - b0 = FakeVendor("copilot", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=10))) + b0 = FakeVendor( + "copilot", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=10)) + ) b1 = FakeVendor("antigravity", VendorResponse(status_code=200)) b2 = FakeVendor("zhipu", VendorResponse(status_code=200)) b1.get_capabilities = lambda: VendorCapabilities( - supports_tools=False, supports_thinking=False, supports_images=True, + supports_tools=False, + supports_thinking=False, + supports_images=True, ) b2.get_capabilities = lambda: VendorCapabilities( - supports_tools=False, supports_thinking=False, supports_images=True, + supports_tools=False, + supports_thinking=False, + supports_images=True, emits_vendor_tool_events=True, ) - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b1, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b2), - ]) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b1, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b2), + ] + ) - resp = await router.route_message({ - **_body(), - "tools": [{"name": "analyze_image"}], - }, _headers()) + resp = await router.route_message( + { + **_body(), + "tools": [{"name": "analyze_image"}], + }, + _headers(), + ) assert resp.status_code == 200 assert b0.call_count == 1 assert b1.call_count == 0 @@ -691,9 +833,13 @@ async def test_incompatible_tool_request_skips_non_compatible_tiers(): class MappingFakeVendor(FakeVendor): """带模型映射的假供应商.""" - def __init__(self, name: str = "mapping-fake", mapped_model: str = "glm-5.1", - response: VendorResponse | None = None, - stream_chunks: list[bytes] | None = None) -> None: + def __init__( + self, + name: str = "mapping-fake", + mapped_model: str = "glm-5.1", + response: VendorResponse | None = None, + stream_chunks: list[bytes] | None = None, + ) -> None: super().__init__(name=name, response=response, stream_chunks=stream_chunks) self._mapped_model = mapped_model @@ -705,7 +851,9 @@ def map_model(self, model: str) -> str: async def test_route_message_model_served_from_response(): """非流式:model_served 从响应体提取.""" logger_mock = AsyncMock() - resp = VendorResponse(status_code=200, usage=UsageInfo(input_tokens=10), model_served="glm-5.1") + resp = VendorResponse( + status_code=200, usage=UsageInfo(input_tokens=10), model_served="glm-5.1" + ) vendor = FakeVendor("zhipu", response=resp) router = RequestRouter([VendorTier(vendor=vendor)], token_logger=logger_mock) @@ -740,12 +888,15 @@ async def test_route_message_model_served_fallback_to_map_model(): resp = VendorResponse(status_code=200, usage=UsageInfo(input_tokens=10)) # model_served 默认为 None,但后端有模型映射 vendor = MappingFakeVendor( - name="zhipu", mapped_model="glm-4.5-air", response=resp, + name="zhipu", + mapped_model="glm-4.5-air", + response=resp, ) router = RequestRouter([VendorTier(vendor=vendor)], token_logger=logger_mock) await router.route_message( - {"model": "claude-haiku-4-5-20251001", "messages": []}, _headers(), + {"model": "claude-haiku-4-5-20251001", "messages": []}, + _headers(), ) logger_mock.log.assert_awaited_once() @@ -759,9 +910,9 @@ async def test_route_stream_model_served_from_sse(): """流式:model_served 从 SSE message_start 事件提取.""" logger_mock = AsyncMock() sse_chunk = ( - b'event: message_start\n' + b"event: message_start\n" b'data: {"type":"message_start","message":{"id":"msg_1","model":"glm-5.1","usage":{"input_tokens":10,"output_tokens":0}}}\n\n' - b'data: [DONE]\n\n' + b"data: [DONE]\n\n" ) vendor = MappingFakeVendor(mapped_model="glm-5.1", stream_chunks=[sse_chunk]) router = RequestRouter([VendorTier(vendor=vendor)], token_logger=logger_mock) @@ -785,7 +936,8 @@ async def test_route_stream_model_served_fallback_to_map_model(): router = RequestRouter([VendorTier(vendor=vendor)], token_logger=logger_mock) async for _ in router.route_stream( - {"model": "claude-haiku-4-5-20251001", "messages": []}, _headers(), + {"model": "claude-haiku-4-5-20251001", "messages": []}, + _headers(), ): pass @@ -817,11 +969,11 @@ async def test_route_stream_copilot_logs_cache_evidence(): """Copilot 流式请求应额外写入 cache evidence 记录.""" logger_mock = AsyncMock() chunk = ( - b'event: message_start\n' + b"event: message_start\n" b'data: {"type":"message_start","message":{"id":"msg_cache","model":"claude-sonnet-4","usage":{"input_tokens":25}}}\n\n' - b'event: message_delta\n' + b"event: message_delta\n" b'data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":3,"cache_read_input_tokens":12}}\n\n' - b'data: [DONE]\n\n' + b"data: [DONE]\n\n" ) vendor = FakeVendor("copilot", stream_chunks=[chunk]) router = RequestRouter([VendorTier(vendor=vendor)], token_logger=logger_mock) @@ -843,12 +995,12 @@ async def test_route_stream_cache_only_input_does_not_warn(caplog): """cache-only 输入信号不应被误判为缺失 usage.""" logger_mock = AsyncMock() chunk = ( - b'event: message_start\n' + b"event: message_start\n" b'data: {"type":"message_start","message":{"id":"msg_cache_only","model":"claude-haiku-4-5-20251001",' b'"usage":{"input_tokens":0,"cache_creation_input_tokens":1920,"cache_read_input_tokens":80488}}}\n\n' - b'event: message_delta\n' + b"event: message_delta\n" b'data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":368}}\n\n' - b'data: [DONE]\n\n' + b"data: [DONE]\n\n" ) vendor = FakeVendor("anthropic", stream_chunks=[chunk]) router = RequestRouter([VendorTier(vendor=vendor)], token_logger=logger_mock) @@ -867,9 +1019,9 @@ async def test_route_stream_missing_input_signals_still_warns(caplog): """真正缺失所有输入信号时,仍应保留 WARNING.""" logger_mock = AsyncMock() chunk = ( - b'event: message_delta\n' + b"event: message_delta\n" b'data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":9}}\n\n' - b'data: [DONE]\n\n' + b"data: [DONE]\n\n" ) vendor = FakeVendor("anthropic", stream_chunks=[chunk]) router = RequestRouter([VendorTier(vendor=vendor)], token_logger=logger_mock) @@ -887,7 +1039,10 @@ async def test_route_stream_missing_input_signals_still_warns(caplog): async def test_route_message_non_copilot_does_not_log_evidence(): """非 Copilot 后端不应写 usage evidence.""" logger_mock = AsyncMock() - vendor = FakeVendor("anthropic", response=VendorResponse(status_code=200, usage=UsageInfo(input_tokens=10))) + vendor = FakeVendor( + "anthropic", + response=VendorResponse(status_code=200, usage=UsageInfo(input_tokens=10)), + ) router = RequestRouter([VendorTier(vendor=vendor)], token_logger=logger_mock) await router.route_message(_body(), _headers()) @@ -907,15 +1062,22 @@ async def test_failover_records_source(): logger_mock = AsyncMock() b0 = FakeVendor( "anthropic", - VendorResponse(status_code=429, error_type="rate_limit_error", error_message="limit"), + VendorResponse( + status_code=429, error_type="rate_limit_error", error_message="limit" + ), ) b0._failover_config = FailoverConfig() - b1 = FakeVendor("zhipu", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=10))) + b1 = FakeVendor( + "zhipu", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=10)) + ) - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b1), - ], token_logger=logger_mock) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b1), + ], + token_logger=logger_mock, + ) resp = await router.route_message(_body(), _headers()) assert resp.status_code == 200 @@ -933,15 +1095,20 @@ async def test_circuit_open_not_counted_as_failover(): """CB OPEN 跳过后的请求不算故障转移.""" logger_mock = AsyncMock() b0 = FakeVendor("anthropic", VendorResponse(status_code=200)) - b1 = FakeVendor("zhipu", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=10))) + b1 = FakeVendor( + "zhipu", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=10)) + ) cb0 = CircuitBreaker(failure_threshold=1) cb0.record_failure() # anthropic CB OPEN - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=cb0), - VendorTier(vendor=b1), - ], token_logger=logger_mock) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=cb0), + VendorTier(vendor=b1), + ], + token_logger=logger_mock, + ) resp = await router.route_message(_body(), _headers()) assert resp.status_code == 200 @@ -960,17 +1127,32 @@ async def test_multi_tier_failover_tracks_source(): from coding.proxy.config.schema import FailoverConfig logger_mock = AsyncMock() - b0 = FakeVendor("anthropic", VendorResponse(status_code=429, error_type="rate_limit_error", error_message="limit")) + b0 = FakeVendor( + "anthropic", + VendorResponse( + status_code=429, error_type="rate_limit_error", error_message="limit" + ), + ) b0._failover_config = FailoverConfig() - b1 = FakeVendor("copilot", VendorResponse(status_code=503, error_type="overloaded_error", error_message="overloaded")) + b1 = FakeVendor( + "copilot", + VendorResponse( + status_code=503, error_type="overloaded_error", error_message="overloaded" + ), + ) b1._failover_config = FailoverConfig() - b2 = FakeVendor("zhipu", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=30))) + b2 = FakeVendor( + "zhipu", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=30)) + ) - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b1, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b2), - ], token_logger=logger_mock) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b1, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b2), + ], + token_logger=logger_mock, + ) resp = await router.route_message(_body(), _headers()) assert resp.status_code == 200 @@ -988,10 +1170,13 @@ async def test_stream_failover_records_source(): b0 = FakeVendor("anthropic", raise_on_call=httpx.ConnectError("refused")) b1 = FakeVendor("zhipu", stream_chunks=[b"data: ok\n\n"]) - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - VendorTier(vendor=b1), - ], token_logger=logger_mock) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + VendorTier(vendor=b1), + ], + token_logger=logger_mock, + ) async for _ in router.route_stream(_body(), _headers()): pass @@ -1012,10 +1197,13 @@ async def test_stream_circuit_open_not_failover(): cb0 = CircuitBreaker(failure_threshold=1) cb0.record_failure() - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=cb0), - VendorTier(vendor=b1), - ], token_logger=logger_mock) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=cb0), + VendorTier(vendor=b1), + ], + token_logger=logger_mock, + ) async for _ in router.route_stream(_body(), _headers()): pass @@ -1030,11 +1218,16 @@ async def test_stream_circuit_open_not_failover(): async def test_primary_success_no_failover(): """首层成功:无故障转移.""" logger_mock = AsyncMock() - b0 = FakeVendor("anthropic", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=10))) + b0 = FakeVendor( + "anthropic", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=10)) + ) - router = RequestRouter([ - VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), - ], token_logger=logger_mock) + router = RequestRouter( + [ + VendorTier(vendor=b0, circuit_breaker=CircuitBreaker()), + ], + token_logger=logger_mock, + ) await router.route_message(_body(), _headers()) @@ -1075,7 +1268,9 @@ async def test_rate_limit_deadline_allows_probe_after_expiry(): """rate limit deadline 已过期 → 允许探测,恢复正常路由.""" import time - b0 = FakeVendor("anthropic", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=10))) + b0 = FakeVendor( + "anthropic", VendorResponse(status_code=200, usage=UsageInfo(input_tokens=10)) + ) b1 = FakeVendor("zhipu", VendorResponse(status_code=200)) cb0 = CircuitBreaker(failure_threshold=1, recovery_timeout_seconds=0) diff --git a/tests/test_router_executor.py b/tests/test_router_executor.py index b20d9b9..6e8a04b 100644 --- a/tests/test_router_executor.py +++ b/tests/test_router_executor.py @@ -9,34 +9,33 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest -from coding.proxy.vendors.base import ( - BaseVendor, - VendorCapabilities, - VendorResponse, - NoCompatibleVendorError, - RequestCapabilities, - UsageInfo, -) -from coding.proxy.vendors.token_manager import TokenAcquireError from coding.proxy.compat.canonical import ( CompatibilityDecision, CompatibilityStatus, build_canonical_request, ) from coding.proxy.routing.executor import ( - _RouteExecutor, _VENDOR_PROTOCOL_LABEL_MAP, _has_tool_results, _log_vendor_response_error, + _RouteExecutor, ) from coding.proxy.routing.session_manager import RouteSessionManager from coding.proxy.routing.tier import VendorTier from coding.proxy.routing.usage_recorder import UsageRecorder - +from coding.proxy.vendors.base import ( + BaseVendor, + NoCompatibleVendorError, + RequestCapabilities, + UsageInfo, + VendorCapabilities, + VendorResponse, +) +from coding.proxy.vendors.token_manager import TokenAcquireError # ── Mock 供应商工厂 ───────────────────────────────────────── @@ -61,6 +60,7 @@ def _mock_vendor(name: str = "test", **caps_kwargs) -> BaseVendor: # supports_request 基于实际能力动态判断 def _supports_request(request_caps: RequestCapabilities): from coding.proxy.vendors.base import CapabilityLossReason + reasons: list[CapabilityLossReason] = [] if request_caps.has_tools and not caps.supports_tools: reasons.append(CapabilityLossReason.TOOLS) @@ -73,11 +73,13 @@ def _supports_request(request_caps: RequestCapabilities): return len(reasons) == 0, reasons vendor.supports_request.side_effect = _supports_request - vendor.send_message = AsyncMock(return_value=VendorResponse( - status_code=200, - raw_body=b'{}', - usage=UsageInfo(input_tokens=10, output_tokens=5), - )) + vendor.send_message = AsyncMock( + return_value=VendorResponse( + status_code=200, + raw_body=b"{}", + usage=UsageInfo(input_tokens=10, output_tokens=5), + ) + ) vendor.send_message_stream = AsyncMock() vendor.check_health = AsyncMock(return_value=True) vendor.close = AsyncMock() @@ -206,12 +208,17 @@ async def test_eligible_when_all_checks_pass(self): headers = {} caps = RequestCapabilities() req = build_canonical_request(body, headers) - session_record = await exec_inst._session_mgr.get_or_create_record(req.session_key, req.trace_id) + session_record = await exec_inst._session_mgr.get_or_create_record( + req.session_key, req.trace_id + ) reasons: list[str] = [] result = await exec_inst._try_gate_tier( - tier, is_last=True, request_caps=caps, - canonical_request=req, session_record=session_record, + tier, + is_last=True, + request_caps=caps, + canonical_request=req, + session_record=session_record, incompatible_reasons=reasons, ) assert result == "eligible" @@ -225,12 +232,17 @@ async def test_skip_when_capability_unsupported(self): body = {"model": "test"} headers = {} req = build_canonical_request(body, headers) - session_record = await exec_inst._session_mgr.get_or_create_record(req.session_key, req.trace_id) + session_record = await exec_inst._session_mgr.get_or_create_record( + req.session_key, req.trace_id + ) reasons: list[str] = [] result = await exec_inst._try_gate_tier( - tier, is_last=False, request_caps=caps, - canonical_request=req, session_record=session_record, + tier, + is_last=False, + request_caps=caps, + canonical_request=req, + session_record=session_record, incompatible_reasons=reasons, ) assert result == "skip" @@ -249,12 +261,17 @@ async def test_skip_when_unsafe_compatibility(self): body = {"model": "test", "thinking": {"type": "enabled"}} headers = {} req = build_canonical_request(body, headers) - session_record = await exec_inst._session_mgr.get_or_create_record(req.session_key, req.trace_id) + session_record = await exec_inst._session_mgr.get_or_create_record( + req.session_key, req.trace_id + ) reasons: list[str] = [] result = await exec_inst._try_gate_tier( - tier, is_last=False, request_caps=caps, - canonical_request=req, session_record=session_record, + tier, + is_last=False, + request_caps=caps, + canonical_request=req, + session_record=session_record, incompatible_reasons=reasons, ) assert result == "skip" @@ -285,15 +302,18 @@ async def test_failover_on_token_error(self): good_vendor = _mock_vendor("good") good_resp = VendorResponse( - status_code=200, raw_body=b'{}', + status_code=200, + raw_body=b"{}", usage=UsageInfo(input_tokens=5, output_tokens=2), ) good_vendor.send_message = AsyncMock(return_value=good_resp) - exec_inst = _executor([ - _make_tier(bad_vendor), - _make_tier(good_vendor), - ]) + exec_inst = _executor( + [ + _make_tier(bad_vendor), + _make_tier(good_vendor), + ] + ) resp = await exec_inst.execute_message({"model": "test"}, {}) assert resp.status_code == 200 @@ -307,7 +327,8 @@ async def test_raises_no_compatible_vendor(self): with pytest.raises(NoCompatibleVendorError): await exec_inst.execute_message( - {"model": "test", "tools": [{}]}, {}, + {"model": "test", "tools": [{}]}, + {}, ) @pytest.mark.asyncio @@ -342,7 +363,8 @@ async def test_non_last_tier_continues_on_connect_error(self): good = _mock_vendor("good") good_resp = VendorResponse( - status_code=200, raw_body=b'{}', + status_code=200, + raw_body=b"{}", usage=UsageInfo(input_tokens=1, output_tokens=1), ) good.send_message = AsyncMock(return_value=good_resp) @@ -369,7 +391,8 @@ async def test_unexpected_exception_on_non_last_tier_continues(self): good = _mock_vendor("good") good_resp = VendorResponse( - status_code=200, raw_body=b'{}', + status_code=200, + raw_body=b"{}", usage=UsageInfo(input_tokens=1, output_tokens=1), ) good.send_message = AsyncMock(return_value=good_resp) @@ -430,7 +453,9 @@ async def test_stream_http_error_raises_on_last_tier(self): async def _raise_http(*a, **kw): raise httpx.HTTPStatusError( - "error", request=MagicMock(), response=MagicMock(status_code=500), + "error", + request=MagicMock(), + response=MagicMock(status_code=500), ) yield # noqa: PYS101 return # type: ignore[unreachable] @@ -460,10 +485,12 @@ async def _good_stream(*a, **kw): good_vendor.send_message_stream = _good_stream - exec_inst = _executor([ - _make_tier(bad_vendor), - _make_tier(good_vendor), - ]) + exec_inst = _executor( + [ + _make_tier(bad_vendor), + _make_tier(good_vendor), + ] + ) collected = [] async for chunk, name in exec_inst.execute_stream({"model": "test"}, {}): @@ -501,7 +528,10 @@ async def test_records_failure_and_returns_exc(self): exc = TokenAcquireError("expired") failed_name, last_exc = await exec_inst._handle_token_error( - tier, exc, is_last=True, failed_tier_name=None, + tier, + exc, + is_last=True, + failed_tier_name=None, ) assert failed_name == "test" assert last_exc is exc @@ -518,7 +548,10 @@ async def test_triggers_reauth_for_copilot(self): exc = TokenAcquireError("expired", needs_reauth=True) await exec_inst._handle_token_error( - tier, exc, is_last=False, failed_tier_name=None, + tier, + exc, + is_last=False, + failed_tier_name=None, ) reauth_mock.request_reauth.assert_called_once_with("github") @@ -531,13 +564,15 @@ class TestUsageRecorderIntegration: """UsageRecorder 与 Executor 协作测试.""" def test_build_usage_info_from_dict(self): - info = UsageRecorder.build_usage_info({ - "input_tokens": 100, - "output_tokens": 50, - "cache_creation_tokens": 10, - "cache_read_tokens": 5, - "request_id": "req_123", - }) + info = UsageRecorder.build_usage_info( + { + "input_tokens": 100, + "output_tokens": 50, + "cache_creation_tokens": 10, + "cache_read_tokens": 5, + "request_id": "req_123", + } + ) assert info.input_tokens == 100 assert info.output_tokens == 50 assert info.cache_creation_tokens == 10 @@ -565,8 +600,10 @@ def test_build_nonstream_evidence_records_for_copilot(self): vendor="copilot", model_served="gpt-4o", usage=UsageInfo( - input_tokens=25, output_tokens=10, - cache_creation_tokens=3, cache_read_tokens=7, + input_tokens=25, + output_tokens=10, + cache_creation_tokens=3, + cache_read_tokens=7, request_id="msg_abc", ), ) @@ -583,8 +620,12 @@ async def test_record_without_logger_is_noop(self): """无 token_logger 时 record 不报错.""" recorder = UsageRecorder(token_logger=None) await recorder.record( - vendor="test", model_requested="m", model_served="m", - usage=UsageInfo(), duration_ms=100, success=True, + vendor="test", + model_requested="m", + model_served="m", + usage=UsageInfo(), + duration_ms=100, + success=True, failover=False, ) # 不抛异常即通过 @@ -619,7 +660,12 @@ class TestHasToolResults: def test_detects_tool_result_in_messages(self): body = { "messages": [ - {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "tu_1", "content": "ok"}]}, + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "tu_1", "content": "ok"} + ], + }, ], } assert _has_tool_results(body) is True @@ -646,7 +692,12 @@ def test_returns_false_for_tool_use_only(self): """tool_use 块不应被误判为 tool_result.""" body = { "messages": [ - {"role": "assistant", "content": [{"type": "tool_use", "id": "tu_1", "name": "bash", "input": {}}]}, + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "tu_1", "name": "bash", "input": {}} + ], + }, ], } assert _has_tool_results(body) is False @@ -659,7 +710,11 @@ def test_detects_mixed_content_with_tool_result(self): "role": "user", "content": [ {"type": "text", "text": "before"}, - {"type": "tool_result", "tool_use_id": "tu_1", "content": "result"}, + { + "type": "tool_result", + "tool_use_id": "tu_1", + "content": "result", + }, {"type": "text", "text": "after"}, ], }, @@ -716,7 +771,12 @@ def test_logs_has_tool_results_true_when_present(self, caplog): "model": "glm-5v-turbo", "tools": [{"name": "Bash"}], "messages": [ - {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "tu_1", "content": "ok"}]}, + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "tu_1", "content": "ok"} + ], + }, ], } @@ -816,12 +876,14 @@ async def test_last_tier_500_produces_warning_log(self, caplog): import logging as _logging vendor = _mock_vendor() - vendor.send_message = AsyncMock(return_value=VendorResponse( - status_code=500, - raw_body=b'{"error":{"code":"500","message":"internal error"}}', - error_type=None, - error_message="internal error", - )) + vendor.send_message = AsyncMock( + return_value=VendorResponse( + status_code=500, + raw_body=b'{"error":{"code":"500","message":"internal error"}}', + error_type=None, + error_message="internal error", + ) + ) exec_inst = _executor([_make_tier(vendor)]) with caplog.at_level(_logging.WARNING, logger="coding.proxy.routing.executor"): @@ -840,17 +902,28 @@ async def test_last_tier_500_with_tool_results_logs_context(self, caplog): import logging as _logging vendor = _mock_vendor() - vendor.send_message = AsyncMock(return_value=VendorResponse( - status_code=500, - raw_body=b'{"error":{"code":"500","message":"tool result id error"}}', - )) + vendor.send_message = AsyncMock( + return_value=VendorResponse( + status_code=500, + raw_body=b'{"error":{"code":"500","message":"tool result id error"}}', + ) + ) exec_inst = _executor([_make_tier(vendor)]) body = { "model": "claude-opus-4-6", "tools": [{"name": "Bash"}], "messages": [ - {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "tu_1", "content": "output"}]}, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "tu_1", + "content": "output", + } + ], + }, ], } @@ -858,6 +931,8 @@ async def test_last_tier_500_with_tool_results_logs_context(self, caplog): resp = await exec_inst.execute_message(body, {}) assert resp.status_code == 500 - log_text = "\n".join(r.message for r in caplog.records if r.levelno == _logging.WARNING) + log_text = "\n".join( + r.message for r in caplog.records if r.levelno == _logging.WARNING + ) assert "has_tool_results=True" in log_text assert "claude-opus-4-6" in log_text diff --git a/tests/test_runtime_reauth.py b/tests/test_runtime_reauth.py index d8e28b5..c08b72c 100644 --- a/tests/test_runtime_reauth.py +++ b/tests/test_runtime_reauth.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest @@ -60,10 +60,13 @@ async def test_request_reauth_success(): async def test_request_reauth_google(): """Google 重认证使用 refresh_token 更新.""" store = _make_store() - mock_provider = _make_mock_provider("google", ProviderTokens( - access_token="goog_access", - refresh_token="goog_refresh", - )) + mock_provider = _make_mock_provider( + "google", + ProviderTokens( + access_token="goog_access", + refresh_token="goog_refresh", + ), + ) updater = MagicMock() coordinator = RuntimeReauthCoordinator( @@ -104,7 +107,9 @@ async def test_request_reauth_unknown_provider(): """未知 provider 不报错.""" store = _make_store() coordinator = RuntimeReauthCoordinator( - token_store=store, providers={}, token_updaters={}, + token_store=store, + providers={}, + token_updaters={}, ) # 不应抛异常 await coordinator.request_reauth("unknown") diff --git a/tests/test_schema.py b/tests/test_schema.py index 7d2a007..0ea9d7d 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -2,17 +2,14 @@ import logging -import pytest - from coding.proxy.config.schema import ( - VendorConfig, _ANTIGRAVITY_FIELDS, - _VENDOR_EXCLUSIVE_FIELDS, _COPILOT_FIELDS, + _VENDOR_EXCLUSIVE_FIELDS, _ZHIPU_FIELDS, + VendorConfig, ) - # ── 供应商专属字段分组常量 ──────────────────────────────────── @@ -48,14 +45,18 @@ def test_vendorconfig_copilot_fields_have_description(): """Copilot 专属字段应包含 [copilot] 前缀的 description.""" for field_name in _COPILOT_FIELDS: field_info = VendorConfig.model_fields[field_name] - assert "[copilot]" in field_info.description, f"{field_name} 缺少 [copilot] 标注" + assert "[copilot]" in field_info.description, ( + f"{field_name} 缺少 [copilot] 标注" + ) def test_vendorconfig_antigravity_fields_have_description(): """Antigravity 专属字段应包含 [antigravity] 前缀的 description.""" for field_name in _ANTIGRAVITY_FIELDS: field_info = VendorConfig.model_fields[field_name] - assert "[antigravity]" in field_info.description, f"{field_name} 缺少 [antigravity] 标注" + assert "[antigravity]" in field_info.description, ( + f"{field_name} 缺少 [antigravity] 标注" + ) def test_vendorconfig_zhipu_field_has_description(): @@ -81,9 +82,13 @@ def test_warn_irrelevant_fields_copilot_with_antigravity_values(caplog): vendor="copilot", github_token="ghp_test", client_id="should_be_ignored", # Antigravity 专属 - refresh_token="also_ignored", # Antigravity 专属 + refresh_token="also_ignored", # Antigravity 专属 ) - warnings = [r for r in caplog.records if r.levelno >= logging.WARNING and "将被忽略" in r.message] + warnings = [ + r + for r in caplog.records + if r.levelno >= logging.WARNING and "将被忽略" in r.message + ] assert len(warnings) >= 2 # client_id 和 refresh_token 各一条 warning @@ -94,9 +99,13 @@ def test_warn_irrelevant_fields_antigravity_with_copilot_values(caplog): vendor="antigravity", client_id="cid_test", github_token="ghp_misplaced", # Copilot 专属 - account_type="business", # Copilot 专属 + account_type="business", # Copilot 专属 ) - warnings = [r for r in caplog.records if r.levelno >= logging.WARNING and "将被忽略" in r.message] + warnings = [ + r + for r in caplog.records + if r.levelno >= logging.WARNING and "将被忽略" in r.message + ] assert len(warnings) >= 2 @@ -104,7 +113,11 @@ def test_no_warning_for_correct_fields(caplog): """正确配置的字段不应触发 warning.""" with caplog.at_level(logging.WARNING, logger="coding.proxy.config.schema"): VendorConfig(vendor="copilot", github_token="ghp_ok") - warnings = [r for r in caplog.records if r.levelno >= logging.WARNING and "将被忽略" in r.message] + warnings = [ + r + for r in caplog.records + if r.levelno >= logging.WARNING and "将被忽略" in r.message + ] assert len(warnings) == 0 @@ -112,7 +125,11 @@ def test_no_warning_for_default_values(caplog): """使用默认值的非当前 vendor 字段不应触发 warning(空字符串等于默认值).""" with caplog.at_level(logging.WARNING, logger="coding.proxy.config.schema"): VendorConfig(vendor="anthropic", base_url="https://api.anthropic.com") - warnings = [r for r in caplog.records if r.levelno >= logging.WARNING and "将被忽略" in r.message] + warnings = [ + r + for r in caplog.records + if r.levelno >= logging.WARNING and "将被忽略" in r.message + ] assert len(warnings) == 0 @@ -132,9 +149,16 @@ def test_proxyconfig_legacy_fields_have_deprecation_marker(): from coding.proxy.config.schema import ProxyConfig legacy_fields = [ - "primary", "copilot", "antigravity", "fallback", - "circuit_breaker", "copilot_circuit_breaker", "antigravity_circuit_breaker", - "quota_guard", "copilot_quota_guard", "antigravity_quota_guard", + "primary", + "copilot", + "antigravity", + "fallback", + "circuit_breaker", + "copilot_circuit_breaker", + "antigravity_circuit_breaker", + "quota_guard", + "copilot_quota_guard", + "antigravity_quota_guard", ] for field_name in legacy_fields: field_info = ProxyConfig.model_fields[field_name] @@ -145,8 +169,16 @@ def test_proxyconfig_non_legacy_fields_no_deprecation(): """非 legacy 字段不应包含 [legacy] 标记.""" from coding.proxy.config.schema import ProxyConfig - non_legacy = ["server", "failover", "model_mapping", "pricing", "tiers", - "auth", "database", "logging"] + non_legacy = [ + "server", + "failover", + "model_mapping", + "pricing", + "tiers", + "auth", + "database", + "logging", + ] for field_name in non_legacy: field_info = ProxyConfig.model_fields[field_name] assert "[legacy]" not in (field_info.description or ""), ( diff --git a/tests/test_streaming_anthropic_compat.py b/tests/test_streaming_anthropic_compat.py index 4f8ca5a..c6feff9 100644 --- a/tests/test_streaming_anthropic_compat.py +++ b/tests/test_streaming_anthropic_compat.py @@ -15,7 +15,6 @@ import pytest from coding.proxy.streaming.anthropic_compat import ( - _OpenAICompatState, _extract_cache_creation_tokens, _extract_cache_read_tokens, _extract_text_fragments, @@ -23,10 +22,10 @@ _normalize_direct_event, _normalize_openai_chunk, _normalize_stream_event, + _OpenAICompatState, normalize_anthropic_compatible_stream, ) - # ── 辅助函数 ─────────────────────────────────────────────── @@ -70,10 +69,13 @@ def test_basic_event(self): assert text.endswith("\n\n") def test_unicode_content(self): - result = _make_event("content_block_delta", { - "type": "content_block_delta", - "delta": {"type": "text_delta", "text": "你好世界"}, - }) + result = _make_event( + "content_block_delta", + { + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "你好世界"}, + }, + ) data = json.loads(result.decode().split("data: ")[1]) assert data["delta"]["text"] == "你好世界" @@ -345,7 +347,12 @@ def test_tool_use_block_start_preserved(self): data = { "type": "content_block_start", "index": 0, - "content_block": {"type": "tool_use", "id": "tu_1", "name": "bash", "input": {}}, + "content_block": { + "type": "tool_use", + "id": "tu_1", + "name": "bash", + "input": {}, + }, } result = _normalize_direct_event(data, "content_block_start") assert len(result) == 1 @@ -371,7 +378,12 @@ def test_tool_call_normalized_to_tool_use(self): data = { "type": "content_block_start", "index": 0, - "content_block": {"type": "tool_call", "id": "tc_1", "name": "fn", "input": {}}, + "content_block": { + "type": "tool_call", + "id": "tc_1", + "name": "fn", + "input": {}, + }, } result = _normalize_direct_event(data, "content_block_start") assert len(result) == 1 @@ -382,7 +394,12 @@ def test_function_call_normalized_to_tool_use(self): data = { "type": "content_block_start", "index": 0, - "content_block": {"type": "function_call", "id": "fc_1", "name": "fn2", "input": {}}, + "content_block": { + "type": "function_call", + "id": "fc_1", + "name": "fn2", + "input": {}, + }, } result = _normalize_direct_event(data, "content_block_start") block_data = json.loads(result[0].decode().split("data: ")[1]) @@ -434,7 +451,11 @@ class TestNormalizeStreamEvent: def test_valid_nested_event(self): data = { "type": "stream_event", - "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "hi"}}, + "event": { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "hi"}, + }, } result = _normalize_stream_event(data, "content_block_delta") assert len(result) == 1 @@ -471,101 +492,151 @@ def test_missing_choices_returns_empty(self): def test_text_content_produces_text_delta(self): state = self._make_state() - chunks = _normalize_openai_chunk({ - "choices": [{"delta": {"content": "Hello"}, "finish_reason": None}], - }, state) + chunks = _normalize_openai_chunk( + { + "choices": [{"delta": {"content": "Hello"}, "finish_reason": None}], + }, + state, + ) assert any(b"text_delta" in c and b"Hello" in c for c in chunks) def test_reasoning_content_opens_thinking_block(self): state = self._make_state() - chunks = _normalize_openai_chunk({ - "choices": [{"delta": {"reasoning_content": "Thinking..."}, "finish_reason": None}], - }, state) + chunks = _normalize_openai_chunk( + { + "choices": [ + { + "delta": {"reasoning_content": "Thinking..."}, + "finish_reason": None, + } + ], + }, + state, + ) assert any(b"thinking" in c and b"Thinking..." in c for c in chunks) assert state.thinking_block_open is True def test_finish_reason_stop_maps_to_end_turn(self): state = self._make_state() - chunks = _normalize_openai_chunk({ - "choices": [{"delta": {}, "finish_reason": "stop"}], - "usage": {"prompt_tokens": 5, "completion_tokens": 3}, - }, state) + chunks = _normalize_openai_chunk( + { + "choices": [{"delta": {}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 5, "completion_tokens": 3}, + }, + state, + ) assert any(b"end_turn" in c for c in chunks) def test_finish_reason_length_maps_to_max_tokens(self): state = self._make_state() - chunks = _normalize_openai_chunk({ - "choices": [{"delta": {}, "finish_reason": "length"}], - }, state) + chunks = _normalize_openai_chunk( + { + "choices": [{"delta": {}, "finish_reason": "length"}], + }, + state, + ) assert any(b"max_tokens" in c for c in chunks) def test_finish_reason_tool_calls_maps_to_tool_use(self): state = self._make_state() - chunks = _normalize_openai_chunk({ - "choices": [{"delta": {}, "finish_reason": "tool_calls"}], - }, state) + chunks = _normalize_openai_chunk( + { + "choices": [{"delta": {}, "finish_reason": "tool_calls"}], + }, + state, + ) assert any(b"tool_use" in c for c in chunks) def test_tool_call_registration(self): state = self._make_state() - chunks = _normalize_openai_chunk({ - "choices": [{ - "delta": { - "tool_calls": [{ - "index": 0, - "id": "call_1", - "function": {"name": "get_weather"}, - }], - }, - "finish_reason": None, - }], - }, state) + chunks = _normalize_openai_chunk( + { + "choices": [ + { + "delta": { + "tool_calls": [ + { + "index": 0, + "id": "call_1", + "function": {"name": "get_weather"}, + } + ], + }, + "finish_reason": None, + } + ], + }, + state, + ) assert any(b"tool_use" in c and b"get_weather" in c for c in chunks) assert 0 in state.tool_calls def test_tool_call_argument_feeding(self): state = self._make_state() # 先注册工具 - _normalize_openai_chunk({ - "choices": [{ - "delta": { - "tool_calls": [{ - "index": 0, - "id": "call_1", - "function": {"name": "bash"}, - }], - }, - "finish_reason": None, - }], - }, state) + _normalize_openai_chunk( + { + "choices": [ + { + "delta": { + "tool_calls": [ + { + "index": 0, + "id": "call_1", + "function": {"name": "bash"}, + } + ], + }, + "finish_reason": None, + } + ], + }, + state, + ) # 再追加参数 - chunks = _normalize_openai_chunk({ - "choices": [{ - "delta": { - "tool_calls": [{ - "index": 0, - "function": {"arguments": '{"cmd":"ls"}'}, - }], - }, - "finish_reason": None, - }], - }, state) + chunks = _normalize_openai_chunk( + { + "choices": [ + { + "delta": { + "tool_calls": [ + { + "index": 0, + "function": {"arguments": '{"cmd":"ls"}'}, + } + ], + }, + "finish_reason": None, + } + ], + }, + state, + ) assert any(b"input_json_delta" in c for c in chunks) def test_null_choice_skipped(self): """choices 中含 None 元素时,生产代码会抛出 AttributeError(已知边界行为).""" state = self._make_state() with pytest.raises(AttributeError): - _normalize_openai_chunk({ - "choices": [None, {"delta": {"content": "ok"}, "finish_reason": "stop"}], - }, state) + _normalize_openai_chunk( + { + "choices": [ + None, + {"delta": {"content": "ok"}, "finish_reason": "stop"}, + ], + }, + state, + ) def test_usage_updated_from_chunk(self): state = self._make_state() - _normalize_openai_chunk({ - "usage": {"prompt_tokens": 100, "completion_tokens": 20}, - "choices": [{"delta": {"content": "hi"}, "finish_reason": "stop"}], - }, state) + _normalize_openai_chunk( + { + "usage": {"prompt_tokens": 100, "completion_tokens": 20}, + "choices": [{"delta": {"content": "hi"}, "finish_reason": "stop"}], + }, + state, + ) assert state.input_tokens == 100 assert state.output_tokens == 20 @@ -581,7 +652,8 @@ async def test_empty_stream_closes_cleanly(self): """空输入流仍应输出 close 事件.""" collected = [] async for chunk in normalize_anthropic_compatible_stream( - _raw_chunks([]), model="test", + _raw_chunks([]), + model="test", ): collected.append(chunk) events = _parse_events(collected) @@ -596,7 +668,8 @@ async def test_done_sentinel_triggers_close(self): ] collected = [] async for chunk in normalize_anthropic_compatible_stream( - _raw_chunks(chunks), model="test", + _raw_chunks(chunks), + model="test", ): collected.append(chunk) events = _parse_events(collected) @@ -606,13 +679,14 @@ async def test_done_sentinel_triggers_close(self): async def test_malformed_json_skipped(self): """格式错误的 JSON 行被跳过,不中断处理.""" chunks = [ - 'data: {invalid json}\n\n', + "data: {invalid json}\n\n", 'data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}\n\n', "data: [DONE]\n\n", ] collected = [] async for chunk in normalize_anthropic_compatible_stream( - _raw_chunks(chunks), model="test", + _raw_chunks(chunks), + model="test", ): collected.append(chunk) events = _parse_events(collected) @@ -632,14 +706,19 @@ async def test_anthropic_native_passthrough(self): ] collected = [] async for chunk in normalize_anthropic_compatible_stream( - _raw_chunks(chunks), model="claude-sonnet-4", + _raw_chunks(chunks), + model="claude-sonnet-4", ): collected.append(chunk) events = _parse_events(collected) event_types = [e["event"] for e in events] assert event_types == [ - "message_start", "content_block_start", "content_block_delta", - "content_block_stop", "message_delta", "message_stop", + "message_start", + "content_block_start", + "content_block_delta", + "content_block_stop", + "message_delta", + "message_stop", ] @pytest.mark.asyncio @@ -648,7 +727,8 @@ async def test_ping_event_passes_through(self): chunks = ['event: ping\ndata: {"type":"ping"}\n\n'] collected = [] async for chunk in normalize_anthropic_compatible_stream( - _raw_chunks(chunks), model="test", + _raw_chunks(chunks), + model="test", ): collected.append(chunk) events = _parse_events(collected) @@ -664,7 +744,8 @@ async def test_error_event_passes_through(self): ] collected = [] async for chunk in normalize_anthropic_compatible_stream( - _raw_chunks(chunks), model="test", + _raw_chunks(chunks), + model="test", ): collected.append(chunk) events = _parse_events(collected) diff --git a/tests/test_tier.py b/tests/test_tier.py index 9516549..fce1e18 100644 --- a/tests/test_tier.py +++ b/tests/test_tier.py @@ -6,7 +6,7 @@ import pytest from coding.proxy.routing.circuit_breaker import CircuitBreaker, CircuitState -from coding.proxy.routing.quota_guard import QuotaGuard, QuotaState +from coding.proxy.routing.quota_guard import QuotaGuard from coding.proxy.routing.tier import VendorTier @@ -44,7 +44,12 @@ def test_can_execute_cb_open(): def test_can_execute_qg_exceeded(): """QG QUOTA_EXCEEDED 时不可执行.""" - qg = QuotaGuard(enabled=True, token_budget=100, window_seconds=3600, probe_interval_seconds=99999) + qg = QuotaGuard( + enabled=True, + token_budget=100, + window_seconds=3600, + probe_interval_seconds=99999, + ) qg.notify_cap_error() tier = VendorTier(vendor=_make_vendor(), quota_guard=qg) assert not tier.can_execute() @@ -53,7 +58,12 @@ def test_can_execute_qg_exceeded(): def test_can_execute_cb_ok_qg_exceeded(): """CB 正常但 QG 超限 → 不可执行.""" cb = CircuitBreaker() - qg = QuotaGuard(enabled=True, token_budget=100, window_seconds=3600, probe_interval_seconds=99999) + qg = QuotaGuard( + enabled=True, + token_budget=100, + window_seconds=3600, + probe_interval_seconds=99999, + ) qg.notify_cap_error() tier = VendorTier(vendor=_make_vendor(), circuit_breaker=cb, quota_guard=qg) assert not tier.can_execute() @@ -131,7 +141,12 @@ def test_record_failure_updates_cb(): def test_record_failure_cap_error_notifies_qg(): """is_cap_error=True 时通知 QG.""" - qg = QuotaGuard(enabled=True, token_budget=1000, window_seconds=3600, probe_interval_seconds=99999) + qg = QuotaGuard( + enabled=True, + token_budget=1000, + window_seconds=3600, + probe_interval_seconds=99999, + ) tier = VendorTier(vendor=_make_vendor(), quota_guard=qg) tier.record_failure(is_cap_error=True) assert not qg.can_use_primary() @@ -171,7 +186,7 @@ def test_record_failure_keeps_later_deadline(): now = time.monotonic() tier.record_failure(rate_limit_deadline=now + 60) tier.record_failure(rate_limit_deadline=now + 30) # 更早的 deadline - assert tier.rate_limit_remaining_seconds > 59 # 保留更远的 + assert tier.rate_limit_remaining_seconds > 59 # 保留更远的 def test_record_failure_without_deadline_no_change(): @@ -267,7 +282,12 @@ async def test_health_check_failure_blocks_probe(): def test_can_execute_weekly_qg_exceeded(): """weekly guard 超限时 can_execute() 返回 False.""" - wqg = QuotaGuard(enabled=True, token_budget=100, window_seconds=604800, probe_interval_seconds=99999) + wqg = QuotaGuard( + enabled=True, + token_budget=100, + window_seconds=604800, + probe_interval_seconds=99999, + ) wqg.notify_cap_error() tier = VendorTier(vendor=_make_vendor(), weekly_quota_guard=wqg) assert not tier.can_execute() @@ -276,7 +296,12 @@ def test_can_execute_weekly_qg_exceeded(): def test_can_execute_qg_ok_weekly_qg_exceeded(): """5h guard 正常但 weekly guard 超限 → 不可执行.""" qg = QuotaGuard(enabled=True, token_budget=100000, window_seconds=18000) - wqg = QuotaGuard(enabled=True, token_budget=100, window_seconds=604800, probe_interval_seconds=99999) + wqg = QuotaGuard( + enabled=True, + token_budget=100, + window_seconds=604800, + probe_interval_seconds=99999, + ) wqg.notify_cap_error() tier = VendorTier(vendor=_make_vendor(), quota_guard=qg, weekly_quota_guard=wqg) assert not tier.can_execute() @@ -284,9 +309,19 @@ def test_can_execute_qg_ok_weekly_qg_exceeded(): def test_both_guards_must_pass(): """5h guard EXCEEDED + weekly guard EXCEEDED → 不可执行.""" - qg = QuotaGuard(enabled=True, token_budget=100, window_seconds=18000, probe_interval_seconds=99999) + qg = QuotaGuard( + enabled=True, + token_budget=100, + window_seconds=18000, + probe_interval_seconds=99999, + ) qg.notify_cap_error() - wqg = QuotaGuard(enabled=True, token_budget=100, window_seconds=604800, probe_interval_seconds=99999) + wqg = QuotaGuard( + enabled=True, + token_budget=100, + window_seconds=604800, + probe_interval_seconds=99999, + ) wqg.notify_cap_error() tier = VendorTier(vendor=_make_vendor(), quota_guard=qg, weekly_quota_guard=wqg) assert not tier.can_execute() @@ -303,7 +338,12 @@ def test_record_success_updates_weekly_qg(): def test_record_failure_cap_error_notifies_weekly_qg(): """is_cap_error=True 时同时通知 weekly guard.""" - wqg = QuotaGuard(enabled=True, token_budget=1000, window_seconds=604800, probe_interval_seconds=99999) + wqg = QuotaGuard( + enabled=True, + token_budget=1000, + window_seconds=604800, + probe_interval_seconds=99999, + ) tier = VendorTier(vendor=_make_vendor(), weekly_quota_guard=wqg) tier.record_failure(is_cap_error=True) assert not wqg.can_use_primary() diff --git a/tests/test_tiers_config.py b/tests/test_tiers_config.py index 8cf92ae..72e9812 100644 --- a/tests/test_tiers_config.py +++ b/tests/test_tiers_config.py @@ -2,16 +2,17 @@ from pathlib import Path -import yaml import pytest -from coding.proxy.config.schema import ProxyConfig, VendorType, VendorConfig +from coding.proxy.config.schema import ProxyConfig def _load_yaml_config(text: str) -> ProxyConfig: """从 YAML 文本加载 ProxyConfig(通过 loader 模块).""" - from coding.proxy.config.loader import load_config import tempfile + + from coding.proxy.config.loader import load_config + with tempfile.TemporaryDirectory() as tmpdir: cfg_file = Path(tmpdir) / "config.yaml" cfg_file.write_text(text) @@ -38,21 +39,14 @@ def test_tiers_basic_load(tmp_path: Path): def test_tiers_default_none(tmp_path: Path): """无 tiers 字段时为 None.""" - cfg = _load_yaml_config( - "vendors:\n" - " - vendor: anthropic\n" - " enabled: true\n" - ) + cfg = _load_yaml_config("vendors:\n - vendor: anthropic\n enabled: true\n") assert cfg.tiers is None def test_tiers_empty_list(tmp_path: Path): """空列表合法(表示不启用任何降级链路).""" cfg = _load_yaml_config( - "vendors:\n" - " - vendor: anthropic\n" - " enabled: true\n" - "tiers: []\n" + "vendors:\n - vendor: anthropic\n enabled: true\ntiers: []\n" ) assert cfg.tiers == [] @@ -60,11 +54,7 @@ def test_tiers_empty_list(tmp_path: Path): def test_tiers_single_entry(tmp_path: Path): """仅含一项.""" cfg = _load_yaml_config( - "vendors:\n" - " - vendor: zhipu\n" - " api_key: sk-test\n" - "tiers:\n" - " - zhipu\n" + "vendors:\n - vendor: zhipu\n api_key: sk-test\ntiers:\n - zhipu\n" ) assert cfg.tiers == ["zhipu"] @@ -96,24 +86,17 @@ def test_tiers_all_four_vendors(tmp_path: Path): def test_tiers_rejects_invalid_vendor(tmp_path: Path): """非法 vendor 值触发 ValidationError.""" import pydantic + with pytest.raises(pydantic.ValidationError): - _load_yaml_config( - "vendors:\n" - " - vendor: anthropic\n" - "tiers:\n" - " - openai\n" - ) + _load_yaml_config("vendors:\n - vendor: anthropic\ntiers:\n - openai\n") def test_tiers_rejects_wrong_type(tmp_path: Path): """非列表类型触发 ValidationError.""" import pydantic + with pytest.raises(pydantic.ValidationError): - _load_yaml_config( - "vendors:\n" - " - vendor: anthropic\n" - "tiers: anthropic\n" - ) + _load_yaml_config("vendors:\n - vendor: anthropic\ntiers: anthropic\n") # ── C 组:语义校验 ────────────────────────────────────── @@ -122,6 +105,7 @@ def test_tiers_rejects_wrong_type(tmp_path: Path): def test_tiers_rejects_unknown_vendor(tmp_path: Path): """引用不存在的 vendor 触发 ValidationError(Pydantic Literal 校验).""" import pydantic + with pytest.raises(pydantic.ValidationError): _load_yaml_config( "vendors:\n" @@ -151,6 +135,7 @@ def test_tiers_rejects_duplicates(tmp_path: Path): def test_tiers_warns_on_disabled_vendor(tmp_path: Path, caplog): """引用 disabled 的 vendor 发出 warning.""" import logging + with caplog.at_level(logging.WARNING, logger="coding.proxy.config.schema"): _load_yaml_config( "vendors:\n" @@ -162,7 +147,11 @@ def test_tiers_warns_on_disabled_vendor(tmp_path: Path, caplog): " - anthropic\n" " - copilot\n" ) - warnings = [r for r in caplog.records if r.levelno >= logging.WARNING and "disabled" in r.message] + warnings = [ + r + for r in caplog.records + if r.levelno >= logging.WARNING and "disabled" in r.message + ] assert len(warnings) >= 1 @@ -202,10 +191,7 @@ def test_no_tiers_preserves_vendor_order(tmp_path: Path): def test_legacy_format_no_tiers(tmp_path: Path): """旧 flat 格式迁移后 tiers 为 None.""" cfg = _load_yaml_config( - "primary:\n" - " enabled: true\n" - "fallback:\n" - " api_key: sk-legacy\n" + "primary:\n enabled: true\nfallback:\n api_key: sk-legacy\n" ) assert cfg.tiers is None # vendors 应由迁移器自动生成 diff --git a/tests/test_token_logger.py b/tests/test_token_logger.py index 2a55851..e8ce69b 100644 --- a/tests/test_token_logger.py +++ b/tests/test_token_logger.py @@ -1,5 +1,6 @@ """TokenLogger 查询扩展单元测试.""" +import aiosqlite import pytest import pytest_asyncio @@ -46,15 +47,19 @@ async def test_log_evidence_and_query_by_request_id(logger): @pytest.mark.asyncio async def test_query_window_total_sums_correctly(logger): await logger.log( - vendor="anthropic", model_requested="claude-sonnet-4", + vendor="anthropic", + model_requested="claude-sonnet-4", model_served="claude-sonnet-4", - input_tokens=100, output_tokens=50, + input_tokens=100, + output_tokens=50, success=True, ) await logger.log( - vendor="anthropic", model_requested="claude-sonnet-4", + vendor="anthropic", + model_requested="claude-sonnet-4", model_served="claude-sonnet-4", - input_tokens=200, output_tokens=80, + input_tokens=200, + output_tokens=80, success=True, ) total = await logger.query_window_total(5.0) @@ -64,15 +69,19 @@ async def test_query_window_total_sums_correctly(logger): @pytest.mark.asyncio async def test_query_window_total_filters_vendor(logger): await logger.log( - vendor="anthropic", model_requested="claude-sonnet-4", + vendor="anthropic", + model_requested="claude-sonnet-4", model_served="claude-sonnet-4", - input_tokens=100, output_tokens=50, + input_tokens=100, + output_tokens=50, success=True, ) await logger.log( - vendor="zhipu", model_requested="claude-sonnet-4", + vendor="zhipu", + model_requested="claude-sonnet-4", model_served="glm-5.1", - input_tokens=200, output_tokens=80, + input_tokens=200, + output_tokens=80, success=True, ) total = await logger.query_window_total(5.0, vendor="anthropic") @@ -82,15 +91,19 @@ async def test_query_window_total_filters_vendor(logger): @pytest.mark.asyncio async def test_query_window_total_excludes_failures(logger): await logger.log( - vendor="anthropic", model_requested="claude-sonnet-4", + vendor="anthropic", + model_requested="claude-sonnet-4", model_served="claude-sonnet-4", - input_tokens=100, output_tokens=50, + input_tokens=100, + output_tokens=50, success=True, ) await logger.log( - vendor="anthropic", model_requested="claude-sonnet-4", + vendor="anthropic", + model_requested="claude-sonnet-4", model_served="claude-sonnet-4", - input_tokens=500, output_tokens=0, + input_tokens=500, + output_tokens=0, success=False, ) total = await logger.query_window_total(5.0) @@ -101,19 +114,25 @@ async def test_query_window_total_excludes_failures(logger): async def test_query_daily_groups_by_model(logger): """query_daily 应按 model_requested 和 model_served 分组.""" await logger.log( - vendor="anthropic", model_requested="claude-sonnet-4", + vendor="anthropic", + model_requested="claude-sonnet-4", model_served="claude-sonnet-4", - input_tokens=100, output_tokens=50, + input_tokens=100, + output_tokens=50, ) await logger.log( - vendor="anthropic", model_requested="claude-opus-4", + vendor="anthropic", + model_requested="claude-opus-4", model_served="claude-opus-4", - input_tokens=200, output_tokens=80, + input_tokens=200, + output_tokens=80, ) await logger.log( - vendor="anthropic", model_requested="claude-sonnet-4", + vendor="anthropic", + model_requested="claude-sonnet-4", model_served="claude-sonnet-4", - input_tokens=150, output_tokens=60, + input_tokens=150, + output_tokens=60, ) rows = await logger.query_daily(days=7) assert len(rows) == 2 @@ -130,14 +149,18 @@ async def test_query_daily_groups_by_model(logger): async def test_query_daily_model_filter(logger): """query_daily 的 model 参数应正确过滤.""" await logger.log( - vendor="anthropic", model_requested="claude-sonnet-4", + vendor="anthropic", + model_requested="claude-sonnet-4", model_served="claude-sonnet-4", - input_tokens=100, output_tokens=50, + input_tokens=100, + output_tokens=50, ) await logger.log( - vendor="anthropic", model_requested="claude-opus-4", + vendor="anthropic", + model_requested="claude-opus-4", model_served="claude-opus-4", - input_tokens=200, output_tokens=80, + input_tokens=200, + output_tokens=80, ) rows = await logger.query_daily(days=7, model="claude-opus-4") assert len(rows) == 1 @@ -149,15 +172,20 @@ async def test_query_daily_model_filter(logger): async def test_query_daily_shows_model_mapping(logger): """故障转移场景:model_requested 与 model_served 不同时应分别展示.""" await logger.log( - vendor="zhipu", model_requested="claude-sonnet-4", + vendor="zhipu", + model_requested="claude-sonnet-4", model_served="glm-5.1", - input_tokens=300, output_tokens=100, failover=True, + input_tokens=300, + output_tokens=100, + failover=True, failover_from="anthropic", ) await logger.log( - vendor="anthropic", model_requested="claude-sonnet-4", + vendor="anthropic", + model_requested="claude-sonnet-4", model_served="claude-sonnet-4", - input_tokens=100, output_tokens=50, + input_tokens=100, + output_tokens=50, ) rows = await logger.query_daily(days=7) assert len(rows) == 2 @@ -171,10 +199,13 @@ async def test_query_daily_shows_model_mapping(logger): async def test_log_with_failover_from(logger): """log() 接受 failover_from 参数并正确写入数据库.""" await logger.log( - vendor="zhipu", model_requested="claude-sonnet-4", + vendor="zhipu", + model_requested="claude-sonnet-4", model_served="glm-5.1", - input_tokens=100, output_tokens=50, - failover=True, failover_from="anthropic", + input_tokens=100, + output_tokens=50, + failover=True, + failover_from="anthropic", ) rows = await logger.query_daily(days=7) assert len(rows) == 1 @@ -189,9 +220,11 @@ async def test_log_with_failover_from(logger): async def test_log_without_failover_from(logger): """不传 failover_from 时默认为 None.""" await logger.log( - vendor="anthropic", model_requested="claude-sonnet-4", + vendor="anthropic", + model_requested="claude-sonnet-4", model_served="claude-sonnet-4", - input_tokens=100, output_tokens=50, + input_tokens=100, + output_tokens=50, ) rows = await logger.query_daily(days=7) assert len(rows) == 1 @@ -203,12 +236,16 @@ async def test_log_without_failover_from(logger): async def test_query_daily_merges_failover_rows(logger): """query_daily no longer groups by failover_from, rows merge.""" await logger.log( - vendor="zhipu", model_requested="claude-sonnet-4", + vendor="zhipu", + model_requested="claude-sonnet-4", model_served="claude-sonnet-4", - input_tokens=100, failover=True, failover_from="anthropic", + input_tokens=100, + failover=True, + failover_from="anthropic", ) await logger.log( - vendor="zhipu", model_requested="claude-sonnet-4", + vendor="zhipu", + model_requested="claude-sonnet-4", model_served="claude-sonnet-4", input_tokens=200, ) @@ -222,7 +259,6 @@ async def test_query_daily_merges_failover_rows(logger): @pytest.mark.asyncio async def test_migration_adds_failover_from_column(tmp_path): """旧数据库(无 failover_from 列)迁移后新列存在.""" - import aiosqlite db_path = tmp_path / "old.db" # 创建旧表(不含 failover_from,使用 vendor 列名) @@ -262,22 +298,29 @@ async def test_migration_adds_failover_from_column(tmp_path): async def test_query_failover_stats(logger): """query_failover_stats 按来源→目标聚合故障转移次数.""" await logger.log( - vendor="zhipu", model_requested="claude-sonnet-4", + vendor="zhipu", + model_requested="claude-sonnet-4", model_served="glm-5.1", - failover=True, failover_from="anthropic", + failover=True, + failover_from="anthropic", ) await logger.log( - vendor="zhipu", model_requested="claude-opus-4", + vendor="zhipu", + model_requested="claude-opus-4", model_served="glm-5.1", - failover=True, failover_from="anthropic", + failover=True, + failover_from="anthropic", ) await logger.log( - vendor="zhipu", model_requested="claude-sonnet-4", + vendor="zhipu", + model_requested="claude-sonnet-4", model_served="claude-sonnet-4", - failover=True, failover_from="copilot", + failover=True, + failover_from="copilot", ) await logger.log( - vendor="anthropic", model_requested="claude-sonnet-4", + vendor="anthropic", + model_requested="claude-sonnet-4", model_served="claude-sonnet-4", # 非 failover,不应出现在统计中 ) @@ -294,8 +337,7 @@ async def test_query_failover_stats(logger): # 天数边界与时区修正测试 # --------------------------------------------------------------------------- -import aiosqlite -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from unittest.mock import patch from zoneinfo import ZoneInfo @@ -307,9 +349,11 @@ async def test_query_daily_days_one_shows_one_day(logger): """-d 1 应仅返回今天(本地日期)的数据,不含昨天.""" now_local = datetime.now(_SHANGHAI) today_start_utc = datetime( - now_local.year, now_local.month, now_local.day, + now_local.year, + now_local.month, + now_local.day, tzinfo=_SHANGHAI, - ).astimezone(timezone.utc) + ).astimezone(UTC) yesterday_start_utc = today_start_utc - timedelta(days=1) # 插入一条"今天"的记录 @@ -340,11 +384,13 @@ async def test_query_daily_days_one_shows_one_day(logger): async def test_query_daily_days_boundary_exact(logger): """-d N 的范围应精确包含 N 个自然日.""" now_local = datetime.now(_SHANGHAI) - today_start = datetime(now_local.year, now_local.month, now_local.day, tzinfo=_SHANGHAI) + today_start = datetime( + now_local.year, now_local.month, now_local.day, tzinfo=_SHANGHAI + ) # 插入今天、昨天、前天共 3 条数据 for day_offset in range(3): - dt = (today_start - timedelta(days=day_offset)).astimezone(timezone.utc) + dt = (today_start - timedelta(days=day_offset)).astimezone(UTC) await logger._db.execute( """INSERT INTO usage_log (ts, vendor, model_requested, model_served, input_tokens, output_tokens) @@ -386,7 +432,7 @@ async def test_query_daily_groups_by_local_date(logger): async def test_query_window_total_uses_utc_baseline(logger): """滚动窗口应基于 UTC 时间计算,避免本地时区偏移.""" # 插入一条 2 小时前的记录 - cutoff = datetime.now(timezone.utc) - timedelta(hours=2) + cutoff = datetime.now(UTC) - timedelta(hours=2) await logger._db.execute( """INSERT INTO usage_log (ts, vendor, model_requested, model_served, input_tokens, output_tokens, success) @@ -407,8 +453,10 @@ async def test_query_window_total_uses_utc_baseline(logger): async def test_query_failover_stats_day_boundary(logger): """故障转移统计应遵循与 query_daily 相同的天数边界.""" now_local = datetime.now(_SHANGHAI) - today_start = datetime(now_local.year, now_local.month, now_local.day, tzinfo=_SHANGHAI) - yesterday_start_utc = (today_start - timedelta(days=1)).astimezone(timezone.utc) + today_start = datetime( + now_local.year, now_local.month, now_local.day, tzinfo=_SHANGHAI + ) + yesterday_start_utc = (today_start - timedelta(days=1)).astimezone(UTC) # 昨天的 failover 记录 await logger._db.execute( @@ -434,8 +482,11 @@ async def test_query_failover_stats_day_boundary(logger): async def test_query_daily_clamps_zero_days(logger): """days=0 应被提升为 1(等价于查今天).""" await logger.log( - vendor="anthropic", model_requested="claude-sonnet-4", - model_served="claude-sonnet-4", input_tokens=100, output_tokens=50, + vendor="anthropic", + model_requested="claude-sonnet-4", + model_served="claude-sonnet-4", + input_tokens=100, + output_tokens=50, ) # days=0 不应报错,行为等同 days=1 rows = await logger.query_daily(days=0) diff --git a/tests/test_token_manager.py b/tests/test_token_manager.py index 423034b..d8f2747 100644 --- a/tests/test_token_manager.py +++ b/tests/test_token_manager.py @@ -4,7 +4,6 @@ import asyncio import time -from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest @@ -139,7 +138,9 @@ async def _acquire(self) -> tuple[str, float]: tm = _SlowManager() results = await asyncio.gather( - tm.get_token(), tm.get_token(), tm.get_token(), + tm.get_token(), + tm.get_token(), + tm.get_token(), ) assert all(r == "concurrent_tok" for r in results) assert call_count == 1 @@ -171,6 +172,7 @@ async def test_close(): @pytest.mark.asyncio async def test_refresh_margin(): """验证 _REFRESH_MARGIN 提前刷新.""" + class _MarginManager(BaseTokenManager): _REFRESH_MARGIN = 500 diff --git a/tests/test_types.py b/tests/test_types.py index fc621ba..eebecb7 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -6,18 +6,23 @@ from coding.proxy.vendors.base import ( PROXY_SKIP_HEADERS, RESPONSE_SANITIZE_SKIP_HEADERS, - VendorCapabilities, - VendorResponse, CapabilityLossReason, NoCompatibleVendorError, RequestCapabilities, UsageInfo, + VendorCapabilities, + VendorResponse, +) +from coding.proxy.vendors.base import ( decode_json_body as _decode_json_body, +) +from coding.proxy.vendors.base import ( extract_error_message as _extract_error_message, +) +from coding.proxy.vendors.base import ( sanitize_headers_for_synthetic_response as _sanitize_headers_for_synthetic_response, ) - # ── UsageInfo ──────────────────────────────────────────── @@ -32,7 +37,11 @@ def test_usage_info_defaults(): def test_usage_info_with_values(): usage = UsageInfo( - input_tokens=100, output_tokens=50, cache_creation_tokens=10, cache_read_tokens=20, request_id="req_1", + input_tokens=100, + output_tokens=50, + cache_creation_tokens=10, + cache_read_tokens=20, + request_id="req_1", ) assert usage.input_tokens == 100 assert usage.output_tokens == 50 @@ -120,13 +129,15 @@ def test_response_sanitize_skip_headers_contains_expected(): def test_sanitize_headers_removes_encoding(): - raw = httpx.Headers({ - "content-type": "application/json", - "content-encoding": "gzip", - "content-length": "123", - "transfer-encoding": "chunked", - "x-request-id": "abc", - }) + raw = httpx.Headers( + { + "content-type": "application/json", + "content-encoding": "gzip", + "content-length": "123", + "transfer-encoding": "chunked", + "x-request-id": "abc", + } + ) result = _sanitize_headers_for_synthetic_response(raw) assert "content-type" in result assert "x-request-id" in result @@ -136,10 +147,12 @@ def test_sanitize_headers_removes_encoding(): def test_sanitize_headers_preserves_other(): - raw = httpx.Headers({ - "retry-after": "60", - "x-ratelimit-remaining": "0", - }) + raw = httpx.Headers( + { + "retry-after": "60", + "x-ratelimit-remaining": "0", + } + ) result = _sanitize_headers_for_synthetic_response(raw) assert result["retry-after"] == "60" assert result["x-ratelimit-remaining"] == "0" @@ -147,10 +160,12 @@ def test_sanitize_headers_preserves_other(): def test_synthetic_response_no_decompression_error(): """验证清洗后的头部构造 httpx.Response 不触发 zlib 解压错误.""" - raw_headers = httpx.Headers({ - "content-type": "application/json", - "content-encoding": "gzip", - }) + raw_headers = httpx.Headers( + { + "content-type": "application/json", + "content-encoding": "gzip", + } + ) clean_headers = _sanitize_headers_for_synthetic_response(raw_headers) resp = httpx.Response( 429, @@ -166,31 +181,41 @@ def test_synthetic_response_no_decompression_error(): def test_decode_json_body_valid_json(): - resp = httpx.Response(200, content=b'{"key":"value"}', headers={"content-type": "application/json"}) + resp = httpx.Response( + 200, content=b'{"key":"value"}', headers={"content-type": "application/json"} + ) result = _decode_json_body(resp) assert result == {"key": "value"} def test_decode_json_body_returns_none_for_html(): - resp = httpx.Response(200, content=b"not json", headers={"content-type": "text/html"}) + resp = httpx.Response( + 200, content=b"not json", headers={"content-type": "text/html"} + ) # HTML 但内容是有效 JSON → 应返回解析结果 result = _decode_json_body(resp) assert result is None def test_decode_json_body_empty_content(): - resp = httpx.Response(200, content=b"", headers={"content-type": "application/json"}) + resp = httpx.Response( + 200, content=b"", headers={"content-type": "application/json"} + ) assert _decode_json_body(resp) is None def test_decode_json_body_invalid_json(): - resp = httpx.Response(200, content=b"{invalid", headers={"content-type": "application/json"}) + resp = httpx.Response( + 200, content=b"{invalid", headers={"content-type": "application/json"} + ) assert _decode_json_body(resp) is None def test_decode_json_body_non_json_content_type_with_valid_json(): """非 JSON content-type 但内容为合法 JSON → 尝试解析.""" - resp = httpx.Response(200, content=b'{"ok":true}', headers={"content-type": "text/plain"}) + resp = httpx.Response( + 200, content=b'{"ok":true}', headers={"content-type": "text/plain"} + ) result = _decode_json_body(resp) assert result == {"ok": True} @@ -199,8 +224,12 @@ def test_decode_json_body_non_json_content_type_with_valid_json(): def test_extract_error_nested_dict(): - resp = httpx.Response(401, content=b'{"error":{"type":"auth","message":"bad token"}}') - msg = _extract_error_message(resp, {"error": {"type": "auth", "message": "bad token"}}) + resp = httpx.Response( + 401, content=b'{"error":{"type":"auth","message":"bad token"}}' + ) + msg = _extract_error_message( + resp, {"error": {"type": "auth", "message": "bad token"}} + ) assert msg == "bad token" diff --git a/tests/test_vendor_streaming.py b/tests/test_vendor_streaming.py index 2961c62..7c2f0fa 100644 --- a/tests/test_vendor_streaming.py +++ b/tests/test_vendor_streaming.py @@ -6,7 +6,9 @@ import pytest -from coding.proxy.streaming.anthropic_compat import normalize_anthropic_compatible_stream +from coding.proxy.streaming.anthropic_compat import ( + normalize_anthropic_compatible_stream, +) async def _raw_chunks(lines: list[str]): @@ -44,12 +46,16 @@ async def test_filters_vendor_tool_events(): collected = [] async for chunk in normalize_anthropic_compatible_stream( - _raw_chunks(chunks), model="glm-5.1", + _raw_chunks(chunks), + model="glm-5.1", ): collected.append(chunk) events = _parse_events(collected) - assert [event["event"] for event in events] == ["content_block_delta", "message_stop"] + assert [event["event"] for event in events] == [ + "content_block_delta", + "message_stop", + ] @pytest.mark.asyncio @@ -63,7 +69,8 @@ async def test_openai_style_stream_is_converted(): collected = [] async for chunk in normalize_anthropic_compatible_stream( - _raw_chunks(chunks), model="glm-5.1", + _raw_chunks(chunks), + model="glm-5.1", ): collected.append(chunk) @@ -88,7 +95,8 @@ async def test_openai_style_stream_preserves_cache_read_tokens(): collected = [] async for chunk in normalize_anthropic_compatible_stream( - _raw_chunks(chunks), model="claude-sonnet-4", + _raw_chunks(chunks), + model="claude-sonnet-4", ): collected.append(chunk) @@ -113,7 +121,8 @@ async def test_anthropic_format_tool_use_block_passes_through(): collected = [] async for chunk in normalize_anthropic_compatible_stream( - _raw_chunks(chunks), model="glm-5.1", + _raw_chunks(chunks), + model="glm-5.1", ): collected.append(chunk) @@ -153,7 +162,8 @@ async def test_anthropic_format_vendor_block_still_filtered(): collected = [] async for chunk in normalize_anthropic_compatible_stream( - _raw_chunks(chunks), model="glm-5.1", + _raw_chunks(chunks), + model="glm-5.1", ): collected.append(chunk) @@ -185,7 +195,8 @@ async def test_anthropic_format_thinking_block_passes_through(): collected = [] async for chunk in normalize_anthropic_compatible_stream( - _raw_chunks(chunks), model="glm-5.1", + _raw_chunks(chunks), + model="glm-5.1", ): collected.append(chunk) @@ -217,7 +228,8 @@ async def test_openai_format_reasoning_content_converted_to_thinking(): collected = [] async for chunk in normalize_anthropic_compatible_stream( - _raw_chunks(chunks), model="glm-5.1", + _raw_chunks(chunks), + model="glm-5.1", ): collected.append(chunk) @@ -229,12 +241,22 @@ async def test_openai_format_reasoning_content_converted_to_thinking(): assert block_starts[0]["data"]["content_block"]["type"] == "thinking" assert block_starts[1]["data"]["content_block"]["type"] == "text" # thinking_delta 片段 - thinking_deltas = [d for d in block_deltas if d["data"]["delta"]["type"] == "thinking_delta"] + thinking_deltas = [ + d for d in block_deltas if d["data"]["delta"]["type"] == "thinking_delta" + ] assert len(thinking_deltas) == 2 - assert thinking_deltas[0]["data"]["delta"]["thinking"] == "Let me think step by step..." - assert thinking_deltas[1]["data"]["delta"]["thinking"] == " First, I need to consider..." + assert ( + thinking_deltas[0]["data"]["delta"]["thinking"] + == "Let me think step by step..." + ) + assert ( + thinking_deltas[1]["data"]["delta"]["thinking"] + == " First, I need to consider..." + ) # text_delta 片段 - text_deltas = [d for d in block_deltas if d["data"]["delta"]["type"] == "text_delta"] + text_deltas = [ + d for d in block_deltas if d["data"]["delta"]["type"] == "text_delta" + ] assert len(text_deltas) == 1 assert text_deltas[0]["data"]["delta"]["text"] == "The answer is 42." @@ -250,13 +272,18 @@ async def test_openai_tool_call_stream_is_converted(): collected = [] async for chunk in normalize_anthropic_compatible_stream( - _raw_chunks(chunks), model="claude-opus-4", + _raw_chunks(chunks), + model="claude-opus-4", ): collected.append(chunk) events = _parse_events(collected) - tool_start = next(event for event in events if event["event"] == "content_block_start") - tool_delta = next(event for event in events if event["event"] == "content_block_delta") + tool_start = next( + event for event in events if event["event"] == "content_block_start" + ) + tool_delta = next( + event for event in events if event["event"] == "content_block_delta" + ) message_delta = next(event for event in events if event["event"] == "message_delta") assert tool_start["data"]["content_block"]["type"] == "tool_use" assert tool_start["data"]["content_block"]["name"] == "get_weather" @@ -285,7 +312,8 @@ async def test_anthropic_format_tool_use_with_inline_arguments(): collected = [] async for chunk in normalize_anthropic_compatible_stream( - _raw_chunks(chunks), model="glm-5.1", + _raw_chunks(chunks), + model="glm-5.1", ): collected.append(chunk) @@ -303,7 +331,9 @@ async def test_anthropic_format_tool_use_with_inline_arguments(): (event for event in events if event["event"] == "content_block_delta"), None, ) - assert tool_delta is not None, "Should emit synthetic input_json_delta for inline args" + assert tool_delta is not None, ( + "Should emit synthetic input_json_delta for inline args" + ) assert tool_delta["data"]["delta"]["type"] == "input_json_delta" args = json.loads(tool_delta["data"]["delta"]["partial_json"]) assert args["description"] == "test task" @@ -321,12 +351,15 @@ async def test_openai_tool_call_with_null_arguments(): collected = [] async for chunk in normalize_anthropic_compatible_stream( - _raw_chunks(chunks), model="glm-5.1", + _raw_chunks(chunks), + model="glm-5.1", ): collected.append(chunk) events = _parse_events(collected) - tool_start = next(event for event in events if event["event"] == "content_block_start") + tool_start = next( + event for event in events if event["event"] == "content_block_start" + ) assert tool_start["data"]["content_block"]["type"] == "tool_use" assert tool_start["data"]["content_block"]["name"] == "Task" # 应正常完成,不崩溃 @@ -347,7 +380,8 @@ async def test_nonstandard_tool_call_block_type(): collected = [] async for chunk in normalize_anthropic_compatible_stream( - _raw_chunks(chunks), model="glm-5.1", + _raw_chunks(chunks), + model="glm-5.1", ): collected.append(chunk) @@ -380,7 +414,8 @@ async def test_nonstandard_arguments_delta_type(): collected = [] async for chunk in normalize_anthropic_compatible_stream( - _raw_chunks(chunks), model="glm-5.1", + _raw_chunks(chunks), + model="glm-5.1", ): collected.append(chunk) @@ -389,6 +424,8 @@ async def test_nonstandard_arguments_delta_type(): (event for event in events if event["event"] == "content_block_delta"), None, ) - assert tool_delta is not None, "arguments_delta should be mapped to input_json_delta" + assert tool_delta is not None, ( + "arguments_delta should be mapped to input_json_delta" + ) assert tool_delta["data"]["delta"]["type"] == "input_json_delta" assert tool_delta["data"]["delta"]["partial_json"] == '{"cmd":"ls"}' diff --git a/tests/test_vendors.py b/tests/test_vendors.py index 65cab92..1461fd5 100644 --- a/tests/test_vendors.py +++ b/tests/test_vendors.py @@ -3,16 +3,6 @@ import httpx import pytest -from coding.proxy.vendors.antigravity import AntigravityVendor -from coding.proxy.vendors.anthropic import AnthropicVendor -from coding.proxy.vendors.base import ( - BaseVendor, - VendorResponse, - UsageInfo, - decode_json_body as _decode_json_body, - sanitize_headers_for_synthetic_response as _sanitize_headers_for_synthetic_response, -) -from coding.proxy.vendors.zhipu import ZhipuVendor from coding.proxy.config.schema import ( AnthropicConfig, AntigravityConfig, @@ -21,6 +11,19 @@ ZhipuConfig, ) from coding.proxy.routing.model_mapper import ModelMapper +from coding.proxy.vendors.anthropic import AnthropicVendor +from coding.proxy.vendors.antigravity import AntigravityVendor +from coding.proxy.vendors.base import ( + UsageInfo, + VendorResponse, +) +from coding.proxy.vendors.base import ( + decode_json_body as _decode_json_body, +) +from coding.proxy.vendors.base import ( + sanitize_headers_for_synthetic_response as _sanitize_headers_for_synthetic_response, +) +from coding.proxy.vendors.zhipu import ZhipuVendor @pytest.mark.asyncio @@ -43,9 +46,13 @@ async def test_anthropic_prepare_request_filters_headers(): @pytest.mark.asyncio async def test_zhipu_prepare_request_maps_model(): - mapper = ModelMapper([ - ModelMappingRule(pattern="claude-sonnet-.*", target="glm-5.1", is_regex=True), - ]) + mapper = ModelMapper( + [ + ModelMappingRule( + pattern="claude-sonnet-.*", target="glm-5.1", is_regex=True + ), + ] + ) config = ZhipuConfig(api_key="test-key") zhipu_vendor = ZhipuVendor(config, mapper) @@ -84,9 +91,9 @@ def test_anthropic_should_trigger_failover(): anthropic_vendor = AnthropicVendor(AnthropicConfig(), failover) # 429 + rate_limit_error → True - assert anthropic_vendor.should_trigger_failover(429, { - "error": {"type": "rate_limit_error", "message": "Rate limited"} - }) + assert anthropic_vendor.should_trigger_failover( + 429, {"error": {"type": "rate_limit_error", "message": "Rate limited"}} + ) # 429 without body → True (429/503 always trigger) assert anthropic_vendor.should_trigger_failover(429, None) @@ -98,22 +105,24 @@ def test_anthropic_should_trigger_failover(): assert not anthropic_vendor.should_trigger_failover(500, None) # error message pattern match - assert anthropic_vendor.should_trigger_failover(429, { - "error": {"type": "unknown", "message": "Quota exceeded"} - }) + assert anthropic_vendor.should_trigger_failover( + 429, {"error": {"type": "unknown", "message": "Quota exceeded"}} + ) def test_zhipu_never_triggers_failover(): mapper = ModelMapper([]) zhipu_vendor = ZhipuVendor(ZhipuConfig(), mapper) assert not zhipu_vendor.should_trigger_failover(429, None) - assert not zhipu_vendor.should_trigger_failover(500, {"error": {"type": "rate_limit_error"}}) + assert not zhipu_vendor.should_trigger_failover( + 500, {"error": {"type": "rate_limit_error"}} + ) def test_zhipu_supports_tools_and_thinking(): """ZhipuVendor 应声明全部能力为 NATIVE(原生 Anthropic 兼容端点).""" - from coding.proxy.vendors.base import RequestCapabilities from coding.proxy.compat.canonical import CompatibilityStatus + from coding.proxy.vendors.base import RequestCapabilities mapper = ModelMapper([]) zhipu_vendor = ZhipuVendor(ZhipuConfig(), mapper) @@ -122,11 +131,15 @@ def test_zhipu_supports_tools_and_thinking(): assert caps.supports_thinking is True assert caps.emits_vendor_tool_events is False # 含工具的请求应被接受 - supported, reasons = zhipu_vendor.supports_request(RequestCapabilities(has_tools=True)) + supported, reasons = zhipu_vendor.supports_request( + RequestCapabilities(has_tools=True) + ) assert supported is True assert reasons == [] # 含 thinking 的请求应被接受 - supported, reasons = zhipu_vendor.supports_request(RequestCapabilities(has_thinking=True)) + supported, reasons = zhipu_vendor.supports_request( + RequestCapabilities(has_thinking=True) + ) assert supported is True assert reasons == [] # 兼容性画像应全部为 NATIVE @@ -177,11 +190,14 @@ async def test_zhipu_prepare_request_preserves_thinking(): async def test_zhipu_prepare_request_preserves_anthropic_beta_header(): zhipu_vendor = ZhipuVendor(ZhipuConfig(api_key="sk-test"), ModelMapper([])) body = {"model": "claude-opus-4-6", "messages": []} - _, prepared_headers = await zhipu_vendor._prepare_request(body, { - "anthropic-version": "2023-06-01", - "anthropic-beta": "computer-use-2025-01-24", - "x-request-id": "req-1", - }) + _, prepared_headers = await zhipu_vendor._prepare_request( + body, + { + "anthropic-version": "2023-06-01", + "anthropic-beta": "computer-use-2025-01-24", + "x-request-id": "req-1", + }, + ) assert prepared_headers["anthropic-beta"] == "computer-use-2025-01-24" assert prepared_headers["x-request-id"] == "req-1" @@ -189,30 +205,57 @@ async def test_zhipu_prepare_request_preserves_anthropic_beta_header(): @pytest.mark.parametrize( ("tool_name", "input_payload"), [ - ("Task", {"description": "sub task", "prompt": "do it", "subagent_type": "general"}), + ( + "Task", + {"description": "sub task", "prompt": "do it", "subagent_type": "general"}, + ), ("Bash", {"command": "pwd", "description": "check cwd"}), ("Grep", {"pattern": "TODO", "path": "."}), ("Glob", {"pattern": "**/*.py", "path": "."}), ("Edit", {"file_path": "a.py", "old_string": "x", "new_string": "y"}), ("Write", {"file_path": "a.py", "content": "print('x')\n"}), ("Read", {"file_path": "a.py", "offset": 1, "limit": 10}), - ("TodoWrite", {"todos": [{"content": "task-1", "status": "pending", "priority": "high"}]}), + ( + "TodoWrite", + {"todos": [{"content": "task-1", "status": "pending", "priority": "high"}]}, + ), ], ) @pytest.mark.asyncio -async def test_zhipu_prepare_request_preserves_claude_code_tool_shapes(tool_name, input_payload): +async def test_zhipu_prepare_request_preserves_claude_code_tool_shapes( + tool_name, input_payload +): zhipu_vendor = ZhipuVendor(ZhipuConfig(api_key="sk-test"), ModelMapper([])) body = { "model": "claude-opus-4-6", - "messages": [{ - "role": "assistant", - "content": [{"type": "tool_use", "id": "toolu_1", "name": tool_name, "input": input_payload}], - }], - "tools": [{"name": tool_name, "input_schema": {"type": "object", "properties": {"payload": {"type": "string"}}}}], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_1", + "name": tool_name, + "input": input_payload, + } + ], + } + ], + "tools": [ + { + "name": tool_name, + "input_schema": { + "type": "object", + "properties": {"payload": {"type": "string"}}, + }, + } + ], "tool_choice": {"type": "any"}, } - prepared_body, prepared_headers = await zhipu_vendor._prepare_request(body, {"anthropic-beta": "code-tools-1"}) + prepared_body, prepared_headers = await zhipu_vendor._prepare_request( + body, {"anthropic-beta": "code-tools-1"} + ) assert prepared_body["tools"][0]["name"] == tool_name assert prepared_body["messages"][0]["content"][0]["name"] == tool_name @@ -237,7 +280,9 @@ async def test_zhipu_send_message_normalizes_401_auth_error(): ) # 直接测试 _normalize_error_response 钩子(无需 mock 基类 send_message) - result = zhipu_vendor._normalize_error_response(401, httpx.Response(401, content=raw_body), input_resp) + result = zhipu_vendor._normalize_error_response( + 401, httpx.Response(401, content=raw_body), input_resp + ) assert result.status_code == 401 assert result.error_type == "authentication_error" @@ -256,7 +301,9 @@ def test_zhipu_normalize_error_response_passthrough_non_401(): error_message="Too many requests", ) - result = zhipu_vendor._normalize_error_response(429, httpx.Response(429, content=input_resp.raw_body), input_resp) + result = zhipu_vendor._normalize_error_response( + 429, httpx.Response(429, content=input_resp.raw_body), input_resp + ) assert result.error_type == "rate_limit_error" assert result is input_resp # 透传:返回同一对象 @@ -266,7 +313,9 @@ def test_zhipu_normalize_error_response_passthrough_non_401(): async def test_zhipu_send_message_without_api_key_fails_fast(): zhipu_vendor = ZhipuVendor(ZhipuConfig(api_key=""), ModelMapper([])) - resp = await zhipu_vendor.send_message({"model": "claude-opus-4-6", "messages": {}}, {}) + resp = await zhipu_vendor.send_message( + {"model": "claude-opus-4-6", "messages": {}}, {} + ) assert resp.status_code == 401 assert resp.error_type == "authentication_error" @@ -309,19 +358,22 @@ def test_usage_info_defaults(): def test_base_failover_with_config(): """基类 should_trigger_failover 在有 FailoverConfig 时正确判断.""" - anthropic_vendor = AnthropicVendor(AnthropicConfig(), FailoverConfig( - status_codes=[429], - error_types=["rate_limit_error"], - error_message_patterns=["quota"], - )) + anthropic_vendor = AnthropicVendor( + AnthropicConfig(), + FailoverConfig( + status_codes=[429], + error_types=["rate_limit_error"], + error_message_patterns=["quota"], + ), + ) # 匹配 status_code + error_type - assert anthropic_vendor.should_trigger_failover(429, { - "error": {"type": "rate_limit_error", "message": "test"} - }) + assert anthropic_vendor.should_trigger_failover( + 429, {"error": {"type": "rate_limit_error", "message": "test"}} + ) # 匹配 status_code + error_message - assert anthropic_vendor.should_trigger_failover(429, { - "error": {"type": "unknown", "message": "Quota exceeded"} - }) + assert anthropic_vendor.should_trigger_failover( + 429, {"error": {"type": "unknown", "message": "Quota exceeded"}} + ) # 429 无 body → 仍触发 assert anthropic_vendor.should_trigger_failover(429, None) # status_code 不匹配 @@ -333,9 +385,9 @@ def test_base_failover_without_config_returns_false(): mapper = ModelMapper([]) zhipu_vendor = ZhipuVendor(ZhipuConfig(), mapper) assert not zhipu_vendor.should_trigger_failover(429, None) - assert not zhipu_vendor.should_trigger_failover(429, { - "error": {"type": "rate_limit_error", "message": "limited"} - }) + assert not zhipu_vendor.should_trigger_failover( + 429, {"error": {"type": "rate_limit_error", "message": "limited"}} + ) assert not zhipu_vendor.should_trigger_failover(503, None) @@ -344,13 +396,15 @@ def test_base_failover_without_config_returns_false(): def test_sanitize_headers_removes_encoding(): """移除 content-encoding/content-length/transfer-encoding.""" - raw = httpx.Headers({ - "content-type": "application/json", - "content-encoding": "gzip", - "content-length": "123", - "transfer-encoding": "chunked", - "x-request-id": "abc", - }) + raw = httpx.Headers( + { + "content-type": "application/json", + "content-encoding": "gzip", + "content-length": "123", + "transfer-encoding": "chunked", + "x-request-id": "abc", + } + ) result = _sanitize_headers_for_synthetic_response(raw) assert "content-type" in result assert "x-request-id" in result @@ -361,10 +415,12 @@ def test_sanitize_headers_removes_encoding(): def test_sanitize_headers_preserves_other(): """非跳过头部全部保留.""" - raw = httpx.Headers({ - "retry-after": "60", - "x-ratelimit-remaining": "0", - }) + raw = httpx.Headers( + { + "retry-after": "60", + "x-ratelimit-remaining": "0", + } + ) result = _sanitize_headers_for_synthetic_response(raw) assert result["retry-after"] == "60" assert result["x-ratelimit-remaining"] == "0" @@ -373,10 +429,12 @@ def test_sanitize_headers_preserves_other(): def test_synthetic_response_no_decompression_error(): """验证清洗后的头部构造 httpx.Response 不触发 zlib 解压错误.""" # 这是原始 bug 的精确复现: 已解压的 content + gzip header → zlib error - raw_headers = httpx.Headers({ - "content-type": "application/json", - "content-encoding": "gzip", - }) + raw_headers = httpx.Headers( + { + "content-type": "application/json", + "content-encoding": "gzip", + } + ) clean_headers = _sanitize_headers_for_synthetic_response(raw_headers) # 使用已解压的 JSON 文本构造 Response — 不应抛异常 resp = httpx.Response( @@ -415,7 +473,9 @@ async def test_antigravity_check_health_token_success(): from unittest.mock import AsyncMock config = AntigravityConfig( - client_id="cid", client_secret="csecret", refresh_token="rtoken", + client_id="cid", + client_secret="csecret", + refresh_token="rtoken", ) antigravity_vendor = AntigravityVendor(config, FailoverConfig(), ModelMapper([])) # Mock token manager 返回有效 token @@ -432,11 +492,15 @@ async def test_antigravity_check_health_token_failure(): from unittest.mock import AsyncMock config = AntigravityConfig( - client_id="cid", client_secret="csecret", refresh_token="rtoken", + client_id="cid", + client_secret="csecret", + refresh_token="rtoken", ) antigravity_vendor = AntigravityVendor(config, FailoverConfig(), ModelMapper([])) # Mock token manager 抛出异常 - antigravity_vendor._token_manager.get_token = AsyncMock(side_effect=Exception("refresh failed")) + antigravity_vendor._token_manager.get_token = AsyncMock( + side_effect=Exception("refresh failed") + ) result = await antigravity_vendor.check_health() assert result is False diff --git a/tests/test_zhipu.py b/tests/test_zhipu.py index 580d612..9604518 100644 --- a/tests/test_zhipu.py +++ b/tests/test_zhipu.py @@ -11,20 +11,37 @@ import pytest -from coding.proxy.vendors.zhipu import ZhipuVendor from coding.proxy.compat.canonical import CompatibilityStatus from coding.proxy.config.schema import ModelMappingRule, ZhipuConfig from coding.proxy.routing.model_mapper import ModelMapper +from coding.proxy.vendors.zhipu import ZhipuVendor @pytest.fixture def zhipu_vendor(): """创建使用默认配置的 ZhipuVendor 实例.""" - mapper = ModelMapper([ - ModelMappingRule(pattern="claude-sonnet-.*", target="glm-5.1", is_regex=True, vendors=["zhipu"]), - ModelMappingRule(pattern="claude-opus-.*", target="glm-5.1", is_regex=True, vendors=["zhipu"]), - ModelMappingRule(pattern="claude-haiku-.*", target="glm-4.5-air", is_regex=True, vendors=["zhipu"]), - ]) + mapper = ModelMapper( + [ + ModelMappingRule( + pattern="claude-sonnet-.*", + target="glm-5.1", + is_regex=True, + vendors=["zhipu"], + ), + ModelMappingRule( + pattern="claude-opus-.*", + target="glm-5.1", + is_regex=True, + vendors=["zhipu"], + ), + ModelMappingRule( + pattern="claude-haiku-.*", + target="glm-4.5-air", + is_regex=True, + vendors=["zhipu"], + ), + ] + ) return ZhipuVendor(ZhipuConfig(api_key="test-zhipu-key"), mapper) @@ -150,9 +167,18 @@ async def test_tools_with_mcp_and_browser_preserved(self, zhipu_vendor): "messages": [], "tools": [ {"name": "Task", "input_schema": {"type": "object"}}, - {"name": "mcp__playwright__browser_click", "input_schema": {"type": "object"}}, - {"name": "mcp__vibe_kanban__create_issue", "input_schema": {"type": "object"}}, - {"name": "mcp__chrome_devtools__take_screenshot", "input_schema": {"type": "object"}}, + { + "name": "mcp__playwright__browser_click", + "input_schema": {"type": "object"}, + }, + { + "name": "mcp__vibe_kanban__create_issue", + "input_schema": {"type": "object"}, + }, + { + "name": "mcp__chrome_devtools__take_screenshot", + "input_schema": {"type": "object"}, + }, ], } prepared_body, _ = await zhipu_vendor._prepare_request(body, {}) @@ -161,7 +187,10 @@ async def test_tools_with_mcp_and_browser_preserved(self, zhipu_vendor): @pytest.mark.asyncio async def test_large_tool_set_not_capped(self, zhipu_vendor): """大量工具列表不被截断.""" - tools = [{"name": f"tool_{i}", "input_schema": {"type": "object"}} for i in range(100)] + tools = [ + {"name": f"tool_{i}", "input_schema": {"type": "object"}} + for i in range(100) + ] body = {"model": "claude-opus-4-6", "messages": [], "tools": tools} prepared_body, _ = await zhipu_vendor._prepare_request(body, {}) assert len(prepared_body["tools"]) == 100 @@ -197,7 +226,6 @@ def test_compatibility_profile_all_native(self, zhipu_vendor): class TestAuthErrorHandling: - @pytest.fixture def vendor(self): return ZhipuVendor(ZhipuConfig(api_key="sk-test"), ModelMapper([])) @@ -208,7 +236,8 @@ async def test_missing_api_key_fast_fail_stream(self): chunks = [] try: async for chunk in vendor.send_message_stream( - {"model": "claude-opus-4-6", "messages": []}, {}, + {"model": "claude-opus-4-6", "messages": []}, + {}, ): chunks.append(chunk) except Exception as exc: @@ -220,14 +249,17 @@ async def test_missing_api_key_fast_fail_stream(self): async def test_missing_api_key_fast_fail_nonstream(self): vendor = ZhipuVendor(ZhipuConfig(api_key=""), ModelMapper([])) resp = await vendor.send_message( - {"model": "claude-opus-4-6", "messages": []}, {}, + {"model": "claude-opus-4-6", "messages": []}, + {}, ) assert resp.status_code == 401 assert resp.error_type == "authentication_error" def test_normalize_401_error_payload(self, vendor): payload = {"error": {"type": "401", "message": "令牌已过期"}} - raw, normalized = vendor._normalize_backend_error(401, json.dumps(payload).encode()) + raw, normalized = vendor._normalize_backend_error( + 401, json.dumps(payload).encode() + ) assert normalized["error"]["type"] == "authentication_error" assert b'"authentication_error"' in raw @@ -251,7 +283,9 @@ class TestTerminalVendor: def test_never_triggers_failover(self, zhipu_vendor): assert not zhipu_vendor.should_trigger_failover(429, None) - assert not zhipu_vendor.should_trigger_failover(500, {"error": {"type": "rate_limit_error"}}) + assert not zhipu_vendor.should_trigger_failover( + 500, {"error": {"type": "rate_limit_error"}} + ) assert not zhipu_vendor.should_trigger_failover(503, None) @pytest.mark.asyncio diff --git a/uv.lock b/uv.lock index faf267f..5a0896e 100644 --- a/uv.lock +++ b/uv.lock @@ -82,6 +82,8 @@ dependencies = [ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, ] [package.metadata] @@ -100,6 +102,8 @@ requires-dist = [ dev = [ { name = "pytest", specifier = ">=9.0" }, { name = "pytest-asyncio", specifier = ">=1.3" }, + { name = "pytest-cov", specifier = ">=6.0" }, + { name = "ruff", specifier = ">=0.9.0" }, ] [[package]] @@ -111,6 +115,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { 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 = "fastapi" version = "0.135.2" @@ -345,6 +433,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +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 = "pyyaml" version = "6.0.3" @@ -404,6 +506,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] +[[package]] +name = "ruff" +version = "0.15.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, +] + [[package]] name = "shellingham" version = "1.5.4"