diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index f9fe87a..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,58 +0,0 @@ -# AGENTS.md - -## Collaboration Protocol (协作协议) - -本文件旨在规范 AI Agent(Claude Code、Antigravity 等)在本项目中的代码与文档协作行为。项目定位详见 [README.md](./README.md)。 - -- **Core Language**: Output MUST be in **Chinese (Simplified)** unless serving code/technical constraints. -- **Tone**: Professional, precise, and evidence-based. - -## Engineering Code of Conduct (工程行为准则) - -**Core Philosophy**: **Entropy Reduction (熵减)**. 通过上下文锚定、复用驱动与标准化流水线,对抗软件系统的无序熵增。 - -### 道 (Mindset - 认知心法) - -- **Context-Driven (上下文驱动)**: 上下文是第一性要素 (Context Quality First)。任何变更需建立在深度理解之上(CDD),拒绝基于关键字匹配的机械式修改。 -- **Minimal Intervention (最小干预)**: 遵循奥卡姆剃刀与 YAGNI 原则,仅实施必要的变更,推崇演进式设计 (Evolutionary Design) 而非过度设计。 -- **Evidence-Based (循证工程)**: 杜绝主观臆断,核心决策需以**最新**且**权威**的文献(IEEE 格式)为佐证,构建“设计-实现-验证”的完整反馈闭环,确保每一项工程行动都能产生可观测的反馈信号(测试、日志、监控),以验证假设并指导迭代。 -- **Systemic Integrity (系统完整性)**: 具备全局视角与二阶思维 (Second-Order Thinking),评估变更对上下游依赖及整个生态(Engine, Adapter, Agent, UI)的“涟漪效应”,不只关注变更的直接结果,更要预测“结果的结果”(如引入缓存导致的陈旧数据、重试机制引发的雪崩),优先保障整体稳定性与逻辑自洽。 -- **Knowledge Crystallization (知识结晶)**: 将系统视为有机体,通过将工程错误与 AI 失败案例转化为经验约束 (Negative Prompts) 和持久化知识,驱动系统的自我进化与持续熵减。 -- **Proactive Navigation (主动导航)**: 智能体不应止步于被动响应,需即时转化为“领航者”。在交付任务结果的同时,**必须**基于上下文预判并提出**下一步最佳行动建议 (Next Best Action)**,不仅交付“答案”,更要交付“路径”,消除用户决策的认知摩擦。 - -### 法 (Strategy - 架构原则) - -- **Plan-First Default (规划先行)**: 面对任何非琐碎任务(预估步骤 > 3 或涉及架构级决策),**必须**率先进入 Plan 模式。规划产物需明确界定:功能边界、边缘 Case 应对策略、与现有逻辑的交互锚点以及预计改动的爆炸半径。 -- **Subagent Strategy (子代理并发策略)**: 面对高复杂度命题,严禁主 Agent 单点统揽。应贯彻“算力换空间”思路,果断编排 Subagent 进行任务拆解与并行攻坚,主 Agent 的职责需严格收敛于上下文协同与最终成果的组装整合。 -- **Verification Before Done (交付前验证定式)**: 严禁在缺乏确凿运行证据的情况下标记任务为“已完成”。交付阶段**强制要求**提供客观自证材料:Diff 变更分析、测试用例覆盖、实施日志截图及核心链路边缘 Case 验证结果,并时刻以“方案是否能通过 Staff Engineer 严格审查”的视角自检。 -- **Reuse-Driven (复用驱动)**: Compose over Reinvent。系统变更**必须**主动参考业界经典设计模式与最佳实践。在进入实质性编码前,需率先对相关领域的成熟范式进行深度调研,并结合当前项目上下文输出充分的关联分析与方案梳理。坚决贯彻“拿来主义”,优先通过组合与集成来构建系统,防范闭门造车与重复造轮子。 -- **Boundary Management (边界管理)**: 严控模块/Agent 间的职责边界与契约,确保高内聚低耦合,防范隐式依赖穿透。 -- **Orthogonal Decomposition (正交分解)**: 坚持“正交地提取概念主体”。识别系统中独立变化的维度并进行解耦(如机制与策略分离),确保单一概念主体的变更具备局部性,避免逻辑纠缠。 -- **Single Source of Truth (单一事实源)**:严格维护唯一的权威定义源。引用时**必须**使用轻量级指针 (Link/ID) 而非数据副本 (Copy-Paste),从根源消除断裂 (Split-Brain) 风险。 - -### 术 (Tactics - 执行规范) - -- **Structured AI-Pair Pipeline (规范化 AI 结对流水线)**: 遵循 **Specification-Driven (规约驱动)** + **Context-Anchored (上下文锚定)** + **AI-Pair (AI 结对)** 模式,将开发固化为可审计的流水线,避免代码腐化为无法维护的“大泥球 (Big Ball of Mud)”。 -- **Operational Excellence (卓越运营)**: - 1. **Git Discipline**: 默认严禁调用 git commit;当用户显式要求提交时,一律使用 Claude Code 的自定义 Slash Command: `/commit-no-push` 进行操作(若非 Claude Code 运行环境,则读取 /commit-no-push 命令中的规则执行)。严禁执行 Rebase; - 2. **Temp Management**: 临时产物(执行计划等)一律收敛至 `.temp/` 并及时清理; - 3. **Link Validity**: 确保所有引用的 URL 可访问且具备明确的上下文价值; - 4. **Testing**: 统一在 tests/ 下维护测试用例,区分单元测试(unit)和集成测试(integration),所有测试的本地运行总时间控制在 3 min 以内; - 5. **Pre-commit Hooks**: 首次克隆仓库使用 `uv run pre-commit install` 激活本地 Git hooks,使 Ruff lint(含 auto-fix)、Ruff format 及通用代码卫生检查在每次 commit 前自动运行。若 hooks 自动修复了问题,提交会被中断,执行 `git add -p` 审阅修复内容后重新提交即可; - 6. **Issue**: 在 [issue.md](docs/.agents/issue.md) 中维护你处理过的 Issue 摘要(问题描述、表因根因、处理方式、后续防范、同类问题影响与处理注意事项等),便于同类问题的跨上下文处理;注意识别相同 Issue,不要同 Issue 多处维护; -- **Package Management Standardization (包管理规范)**: - 1. **Python**: 严禁使用 pip/poetry,**必须**统一使用 `uv` 进行包管理与脚本执行(如 `uv run`); - 2. **JavaScript/TypeScript**: 严禁使用 npm/yarn,**必须**统一使用 `pnpm` 进行包管理与脚本执行; -- **Database Management**: 谨慎操作,数据迁移、测试等操作严禁将现有数据删除,谨慎操作数据迁移的回滚,防止数据被清理。 -- **In-depth and close to the facts**:系统且全面地进行问题的分析,深入贴近事实,如有疑问,需先发问,不要乱做决定。 -- **Browser Validation Protocol (浏览器验证准则)**:Agent 不得自行完成、绕过或模拟任何 OAuth / SSO 认证流程,所有登录态均来源于用户已认证的 Chrome 主 profile(真实用户登录态)。完整协议(连通性自检、凭证管理、E2E 集成、实机回归等)详见 [浏览器验证协议](./docs/.agents/browser-validation.md); - 1. **安全红线**:禁止在 Sandbox 浏览器中跳转 Google 同意屏;禁止以模拟用户或第三方账号替代真实登录态;禁止要求用户在 chat 中粘贴密码、Cookie 或验证码; -- **Knowledge Map (知识索引)**:项目所有文档索引统一维护在 [知识索引](./docs/.agents/knowledge-map.md),并在文档目录变更时即时同步跟新; -- **Documentation Standards (文档规范)**: - 1. **Visual Documentation (图文并茂)**: 对于复杂逻辑,优先 **Mermaid Visualization Norms (Mermaid 可视化规范)**,构建”图文并茂”的直观文档; - - **色彩语义与兼容性**:为图表节点配置具备语义辨识度的色彩,并确保在深色模式(Dark Mode)下具有极高的对比度与清晰度; - - **逻辑模块化解构**:针对业务跨度较大的架构流程,强制采用 `subgraph` 容器进行层级解构与边界划分,以增强图表的自解说(Self-explaining)能力; - 2. **语言叙事**:用语精准,叙事完备,行文专业,聚焦核心,篇幅精炼,形象具体,体现真实作用与用户吸引性,字数恰当; - 3. **Direct Hyperlinking (直接跳转)**: 在文档中提及 Repo 内其他资源(文档/代码)时,**必须**构建可跳转的相对路径链接(如 `[Doc Name](./path.md)`),严禁使用”死文本”引用,以降低信息检索熵; - 4. **实操截图**:文档需要引入必要的浏览器实操截图时,需自行通过默认浏览器打开相关页面,通过实操现场截图并保留到文档路径进行文档引用; -- **Reference Specifications (IEEE)**:为保障工程决策的可追溯性与学术严谨性,核心引用需遵循 [reference-specifications.md](docs/.agents/reference-specifications.md)IEEE 标准引用格式; diff --git a/CHANGELOG.md b/CHANGELOG.md index 8947065..d13c1d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,19 @@ 本文件基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/) 规范维护,版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 -## [Unreleased] +## [v0.5.2](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.5.2) - 2026-09-10 + +- fix(quota-guard): `coding-proxy reset` / `POST /api/reset` 不再清空配额守卫的滑动窗口用量——根因是 `QuotaGuard.reset()` 把「状态机复位」与「用量计数清零」耦合在一个方法里(`_entries.clear()` + `_total = 0`),而窗口基线 `load_baseline()` 的唯一调用点在进程启动的 lifespan 钩子中、运行期不再回填,导致 Dashboard 的 `1d配额 45%` 徽章复位后永久停在 0%;现 `reset()` 只保留 `_transition_to(WITHIN_QUOTA)`(该方法本身已清 `_cap_error_active` 并还原探测间隔),CLI / API / Dashboard 三条路径经单一事实源同时修复。语义取舍为**如实**:用量确已超过 `token_budget × threshold_percent` 时守卫保持 `QUOTA_EXCEEDED`(不伪造用量数字),但仍清除上游 cap 错误卡死标志并把被 `Retry-After` 拉长的探测间隔还原为默认值——刻意不走 WITHIN_QUOTA → QUOTA_EXCEEDED 回环,因为 `_transition_to(EXCEEDED)` 会把 `_last_probe` 刷成当前时刻,令探测恢复凭空推迟一个 probe_interval,反复复位即可饿死探测(PR 评审发现的回归,补 2 条用例锁定);文档口径(`cli-reference.md` / `api-reference.md`)同步补明「仅复位状态、不清用量」; +- feat(dashboard): Overview 页「供应商状态」卡片标题栏右侧新增 **⟲ 状态复位** 按钮,一键把处于熔断 / 限流等异常态的供应商复位为可正常访问——调用无 body 的 `POST /api/reset`,因此**不触发重排序、不改动供应商优先级**,配合上述配额修复亦**不清空额度用量**;新增 `.btn-card-action` 卡片标题栏控件通用类(复用 `.card-title` 既有的 `justify-content: space-between`,零布局 CSS 改动)并纳入 `:focus-visible` 焦点环列表;交互沿用 `persistTierOrder` 的 in-flight 防并发范式与 `copyFromParent` 的瞬时反馈范式(复位中… → ✓ 已复位 / ✗ 失败,1.5s 还原),成功后定向重渲染供应商列表而不必等 10 分钟轮询;定向刷新 `/api/status` 失败时单独吞掉、不误报「✗ 失败」诱导重复点击(复位已生效,刷新失败仅影响展示)(实机验证); +- fix(dashboard): 修复「限速中」徽章从未渲染的问题——前端读 `rlInfo.limited`,而后端 `VendorTier.get_rate_limit_info()` 产出的键是 `is_rate_limited`,键名不匹配使 Rate Limit 异常态在 UI 上完全不可观测;补前端守卫测试锁定键名; + +> [!NOTE] +> 本版为 v0.5.2 周期(v0.5.2a1–a8)的正式收口版,完整变更见上方各 alpha 段落;以下条目为 v0.5.2a8 之后的增量(#279)。 + +## [v0.5.2a8](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.5.2a8) - 2026-07-06 + +- fix(vendor-logging): 修复流式 4xx/5xx 错误日志中文乱码——根因是 `logger.warning("...body=%s...", error_body[:500])` 对 `bytes` 走 `repr()`,非 ASCII 的 UTF-8 字节被转义为 `\xe6\x82\xa8` 之类不可读序列(上游限流/鉴权等中文错误信息无法辨认);新增 `decode_error_body(raw, limit=500)` 工具(`errors="replace"` 容错解码、先整体解码再按字符截断以避免在多字节 UTF-8 边界切断产生二次乱码,`limit` 语义为字符数),`base.py` / `copilot.py` / `antigravity.py` 三处流式错误日志统一收敛,并经 `model/__init__` 与 `vendors/base` 导出复用 (#278); +- fix(vendor-antigravity): 流式 scope 检测改用完整解码 body,与日志展示的 500 字符截断解耦——此前 `decode_error_body` 截断后的文本被同时喂给 `_mark_scope_error_if_needed` 子串检测,当 `ACCESS_TOKEN_SCOPE_INSUFFICIENT` 标记位于第 500 字符之后时会被截断丢弃,token 不再标记 `needs_reauth` 而持续以 scope 不足的凭证重试;现检测改用 `error_body.decode("utf-8", errors="replace")` 完整文本、日志展示仍用 500 截断,恢复与非流式路径(`send_message` 传完整 `response.text`)的行为一致性;补回归测试(构造标记位于 500 字符之后的 403 错误体,断言检测仍触发,旧截断实现下该用例失败)(#278); ## [v0.5.2a7](https://github.com/ThreeFish-AI/coding-proxy/releases/tag/v0.5.2a7) - 2026-07-04 diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 47dc3e3..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index 0bfb995..537ee8c 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,6 @@ To ensure this project outlives us all (long-term maintainability), we offer exh - 📖 **[User Guide](./docs/user-guide.md)** — From installation and bare-minimum configs to the semantic breakdown of every `config.yaml` field and common troubleshooting manuals. (Currently in Chinese) - 🏗️ **[Architecture Framework](./docs/framework.md)** — A meticulous decoding of underlying design patterns (Template Method, Circuit Breaker, State Machine, etc.), targeted at devs who want to peek into the matrix or contribute new vendors. (Currently in Chinese) -- 🤝 **[Engineering Guidelines (AGENTS.md)](./AGENTS.md)** — The systemic context mindset and AI Agent collaboration protocol. It preaches **refactoring, reuse, and orthogonal abstractions** and serves as the ultimate guiding light for all development in this repository. --- diff --git a/docs/.agents/browser-validation.md b/docs/.agents/browser-validation.md deleted file mode 100644 index ee4b705..0000000 --- a/docs/.agents/browser-validation.md +++ /dev/null @@ -1,172 +0,0 @@ -# Browser Validation Protocol(浏览器验证协议) - -> 由 [AGENTS.md §Browser Validation Protocol](../../AGENTS.md) 锚定的浏览器自动化与认证态使用协议。本协议是工程行为准则的子集,**任何 AI Agent 在执行浏览器自动化任务前必须完整遵循**。 -> -> **协议版本**:v1.0 | **生效范围**:所有面向本仓库的 AI Agent 协作场景 -> -> **关联工具**:`chrome-devtools` MCP、`claude-in-chrome` MCP、`playwright` MCP - -[TOC] - ---- - -## 1. 协议目的 - -为 AI Agent 在浏览器自动化场景下提供**统一、可审计、不可绕过**的认证态使用规范,解决以下问题: - -- AI Agent 不应也不可代用户决策"我是谁"——所有登录态归属问题必须由用户本人主导 -- 浏览器自动化能力一旦失控,可能在用户毫不知情时产生不可撤销的副作用(消息发送、订单提交、权限变更等) -- OAuth / SSO 同意屏在自动化上下文中存在被绕过的潜在风险,违反平台 ToS 与基本伦理 - -本协议通过"原则—红线—操作流程—验证"四层结构,将上述问题约束在工程可控范围内。 - ---- - -## 2. 核心原则 - -| 原则 | 具体含义 | -| -------------------------- | ----------------------------------------------------------------------------------------------------- | -| **登录态归属于用户** | Agent 不得自行完成、绕过或模拟任何 OAuth / SSO 认证流程;所有登录态来源于用户已认证的 Chrome 主 profile | -| **真实主 profile 优先** | 浏览器自动化默认接入用户日常使用的 Chrome 主 profile,复用其 Cookie / Session / SSO 状态 | -| **可审计、可回放** | 浏览器路径关键操作(点击、表单填写、跳转)应留下可被 GIF 回放或日志追溯的痕迹 | -| **最小副作用** | 优先以只读方式(查看、提取、断言)完成任务;写操作(提交、发送)需在协议第 5 节框架下显式确认 | - ---- - -## 3. 安全红线 - -> 以下条款**不可协商**,违反任一条款即视为协议违反。 - -1. **禁止跳转 Google 同意屏**:在 Sandbox / 自动化浏览器环境内**严禁**触发 Google OAuth 同意屏跳转。同意屏只能在用户主 profile 的真实浏览会话中由用户本人完成。 -2. **禁止模拟身份**:禁止以模拟用户身份、虚构 Cookie、第三方账号或测试账号替代真实登录态完成任务。 -3. **禁止凭证泄露**:禁止要求用户在 chat 中粘贴密码、Cookie、Session Token、二维码扫描结果或任何形式的验证码(含 6 位数字、短信、TOTP)。 -4. **禁止跨账号操作**:在多用户环境下,Agent 不得在未经显式确认的情况下切换 profile 或账号身份。 -5. **禁止规避 ToS**:不得通过 Headless 模式、UA 伪装、Captcha 自动求解等方式规避目标站点的服务条款。 -6. **禁止下载执行**:浏览器路径触发的任何文件下载需在主对话中显式确认;下载文件不得自动执行或注入到项目目录。 - ---- - -## 4. 连通性自检(Connectivity Probe) - -执行浏览器自动化任务前,Agent **必须**完成以下自检序列: - -| 步骤 | 操作 | 通过判据 | -| --------------------- | ----------------------------------------------------------------- | --------------------------------------------------------- | -| 4.1 工具可用性 | 列出当前会话可用的 MCP 工具 | 至少存在 `chrome-devtools` / `claude-in-chrome` 之一 | -| 4.2 主 profile 加载 | 通过工具调用获取当前 Tab 列表或 Page 列表 | 返回非空,且 Tab 标题来自用户真实浏览历史而非空白会话 | -| 4.3 目标域名可达 | 通过 `navigate_page` 或 `browser_navigate` 访问目标域名首页 | HTTP 200 / 已登录态正常加载 | -| 4.4 登录态识别 | 在目标域名首页定位"已登录"标识(头像、用户名、退出按钮) | 能在 Snapshot / AOM 中找到一致标识 | -| 4.5 异常路径分类 | 若 4.4 失败,按"未登录 vs 会话过期 vs 拒绝服务"分类,**不**自动重登 | 输出明确分类,转入第 5 节的用户接力流程 | - -> **失败处置**:自检任一步骤失败,Agent **必须**停止任务、向用户输出诊断结论,**不得**尝试 OAuth / 凭证补救。 - ---- - -## 5. 凭证管理(Credential Lifecycle) - -### 5.1 发现路径 - -凭证通过以下路径**被动**发现,Agent **不**主动读取、导出或日志化: - -- 浏览器 Cookie / LocalStorage(仅由浏览器引擎内部使用) -- 浏览器扩展(如 Claude in Chrome)持有的 Session -- 用户在 chat 中以"我刚登录了 X"形式给出的事实陈述(非凭证本身) - -### 5.2 过期检测信号 - -| 信号 | 处置 | -| ---------------------------------------- | ----------------------------------------------- | -| HTTP 401 / 403 | 转 5.3 接力流程 | -| 重定向到登录页(含 `/login`、`/signin`) | 转 5.3 接力流程 | -| 同意屏触发(OAuth scope 变更) | **立即停止**,由用户在主 profile 完成同意 | -| Captcha 出现 | **立即停止**,输出"需用户介入" | - -### 5.3 用户接力流程(Re-authentication Handoff) - -``` -1. Agent 检测到登录态失效 -2. Agent 向用户输出:(a)失效域名 (b)建议在用户主 profile 完成登录的指引 -3. Agent 暂停浏览器任务,**不**触发任何登录流程 -4. 用户在真实浏览器完成登录后,回到 chat 通知 Agent -5. Agent 重新执行第 4 节连通性自检 -6. 自检通过后恢复任务 -``` - -### 5.4 凭证刷新约束 - -- Agent **不**调用任何 refresh_token / device_code 接口 -- Agent **不**触发邮箱链接、短信验证码、TOTP 输入 -- 凭证刷新由用户在原始登录路径自主完成 - ---- - -## 6. E2E 集成(End-to-End Integration) - -### 6.1 与项目 OAuth 模块的边界 - -本项目内置 GitHub Device Flow 与 Google OAuth 模块(`src/coding/proxy/auth/`)。浏览器协议与之的边界如下: - -- **项目 OAuth 模块**:服务端运行时凭证管理,由 `coding-proxy auth login/reauth` CLI 触发,目标是给 **proxy 自身**获取上游 API 凭证 -- **本协议**:客户端浏览器自动化场景,目标是让 **Agent 协助用户**完成日常任务(如查文档、填表单) - -二者**互不调用**:Agent 不调用 `coding-proxy auth` 替用户完成项目 OAuth;项目 OAuth 流程也不依赖本协议第 4 节自检。 - -### 6.2 与 CLI 命令的协同 - -| 场景 | 由谁触发 | -| ------------------------------- | ------------------------- | -| 给 proxy 注入 GitHub PAT | 用户运行 `auth login` | -| 给 proxy 注入 Google OAuth | 用户运行 `auth login` | -| 凭证过期重认证 | 用户运行 `auth reauth` | -| 浏览器查看 GitHub Token 状态 | Agent 通过本协议浏览器访问 | - -### 6.3 测试用例的浏览器隔离 - -- 单元测试(`tests/unit/`)**不**触发任何浏览器路径 -- 集成测试(`tests/integration/`)**不**触发任何浏览器路径 -- 浏览器路径仅在交互式 Agent 会话中触发,不进入 CI 自动化测试链路 - ---- - -## 7. 实机回归(Real-Device Regression) - -### 7.1 提交前的浏览器路径自检清单 - -涉及浏览器路径的改动在提交前需手工核验: - -- [ ] 第 4 节连通性自检在用户主 profile 通过 -- [ ] 第 3 节安全红线未被触碰(特别是同意屏、密码粘贴) -- [ ] 浏览器路径的关键操作有 GIF / Snapshot 留痕 -- [ ] 失败路径输出明确的用户接力指引 - -### 7.2 与 CI 的边界 - -CI 流水线(详见 [ops/ci-cd.md](../ops/ci-cd.md))**不**触发浏览器自动化路径。所有浏览器侧验证均在本地实机完成。 - -### 7.3 回归失败上报 - -若实机回归失败: - -1. 在 [docs/issue.md](../issue.md) 记录现象、根因、防范 -2. 若涉及协议本身缺陷,提交 PR 修订本文件并同步 [AGENTS.md](../../AGENTS.md) 锚点 -3. 不通过的 Agent 行为应在 [knowledge-map.md](./knowledge-map.md) 标注为已知问题 - ---- - -## 8. 引用规范 - -- 本协议章节可被 [AGENTS.md](../../AGENTS.md) / [CLAUDE.md](../../CLAUDE.md) 通过标题锚点形式引用 -- 修订本协议**必须**在 [docs/issue.md](../issue.md) 留存背景与决策记录 -- 协议条款发生变更时,需同步检查 [AGENTS.md §Browser Validation Protocol](../../AGENTS.md) 的兜底原则与本协议是否一致 - ---- - -## 附录 A:术语对照 - -| 术语 | 说明 | -| ------------------- | ----------------------------------------------------------------- | -| 主 profile | 用户日常使用的 Chrome / Edge 浏览器档案,含真实登录态 | -| Sandbox 浏览器 | 自动化工具启动的临时/隔离浏览器,无真实用户态 | -| 同意屏(Consent) | OAuth 流程中用户授予权限范围的页面 | -| 接力流程 | Agent 停止 → 用户介入完成 → Agent 恢复 的三段式协作 | -| 实机回归 | 在用户真实终端(非 CI)完成的端到端验证 | diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md index 82c76f1..ebdddc0 100644 --- a/docs/.agents/issue.md +++ b/docs/.agents/issue.md @@ -4,6 +4,54 @@ --- +## `coding reset` 误清配额窗口用量(Dashboard 配额百分比归零且不自愈) + +**问题描述** + +执行 `coding-proxy reset`(含 `-v anthropic,zhipu` 形态)后,Dashboard「供应商状态」卡片里 zhipu 的「1d配额 45%」徽章掉到 **0%**,且不会随时间恢复。用户预期该指令只复位熔断等异常状态,不应触碰额度记录。 + +**表因** + +`/api/status` 的 `quota_guard.usage_percent` 由 `QuotaGuard.get_info()` 从内存字段 `_total` 计算,reset 后 `_total` 为 0。 + +**根因** + +`QuotaGuard.reset()`(`routing/quota_guard.py`)把**状态机复位**与**用量计数清零**两件正交的事耦合在了一个方法里: + +```python +self._transition_to(QuotaState.WITHIN_QUOTA) +self._entries.clear() # ← 连带清空滑动窗口 +self._total = 0 # ← 用量归零 +``` + +之所以「不自愈」,是因为窗口基线 `load_baseline()` 的**唯一**调用点在 `server/app.py` 的 lifespan 启动钩子里 —— 进程运行期间不会再回填,只能靠新请求从 0 重新累加。文档口径(`cli-reference.md` / `api-reference.md`)自始至终只承诺「配额守卫**状态** → WITHIN_QUOTA」,从未声明会清空用量,**实现与契约不一致**。 + +CLI `reset` 只是 `POST /api/reset` 的瘦 HTTP 客户端,`/api/reset` 又对全部 tier 调 `quota_guard.reset()` + `weekly_quota_guard.reset()`,故 CLI 与 API 两条路径同时中招。 + +**处理方式** + +在 `QuotaGuard.reset()` 中删除 `_entries.clear()` / `_total = 0` 两行,只保留 `_transition_to(QuotaState.WITHIN_QUOTA)`(该方法本身已负责清 `_cap_error_active` 并还原 `_effective_probe_interval`)。**单一事实源修复**:CLI、`/api/reset`、Dashboard 新增的「状态复位」按钮三条路径自动同时受益,无需各自加 `--keep-quota` 之类开关。 + +语义取舍:用量确已超过 `budget × threshold` 时,复位后守卫**保持** `QUOTA_EXCEEDED`(即「点了没反应」)。这是**如实**行为 —— 宁可不放行,也不伪造用量数字;但仍会清除 cap 错误卡死标志(`_cap_error_active`,正是 5h 限额型 vendor 的主要卡死来源)并把被 `Retry-After` 拉长的探测间隔还原为默认值。 + +**评审回归(PR #279)**:初版修复让 `reset()` 无条件 `_transition_to(WITHIN_QUOTA)`,用量仍超阈值时下一次判定再回落 `QUOTA_EXCEEDED` —— 而该回环会把 `_last_probe` 刷成当前时刻,令探测恢复凭空推迟一个 `probe_interval`(默认 300s),反复点击「状态复位」可**无限饿死探测**(旧实现清零用量,不存在此回环)。教训:**改「复位」语义时必须检查状态机每条转移的附带副作用**(`_transition_to` 不是纯赋值,EXCEEDED 分支带 `_last_probe = now`),修复后的 `reset()` 在用量仍超阈值时只清 `_cap_error_active` 与 `_effective_probe_interval`、不触发任何状态转移;补 `test_reset_does_not_delay_probe_when_over_budget`(mock 时钟断言探测点不被推后)与 `test_reset_still_clears_cap_stall_when_over_budget` 两条回归用例。 + +**后续防范** + +- **写操作测试须补「不误伤其他运行时状态」守卫断言**:`tests/test_app_routes.py::test_tier_order_preserves_quota_guard` 早已是这一范式的样板,但 `/api/reset` 侧长期缺失同类断言,缺陷才得以潜伏。新增写端点时,除「改了什么」外必须同时断言「没改什么」。 +- **警惕「状态机复位」与「计数清零」的耦合**:`reset()` 这类命名天然模糊。凡是同时持有状态字段与累计计数的组件(`CircuitBreaker` 的 `_failure_count` 属于状态、`QuotaGuard` 的 `_total` 属于观测数据),复位语义须显式区分二者。 +- **旧测试固化了错误行为**:`test_reset_clears_all_state` 曾断言 `window_usage_tokens == 0`,等于把缺陷写成契约。修复时应先让新测试在旧实现下 FAIL,确认其真正咬住缺陷。 + +**同类问题影响与处理注意事项** + +- **不可用线上进程验证**:`/api/reset` 会永久抹掉运行中进程的配额基线(直到重启)。验证须另起隔离实例(独立端口 + `/tmp` 数据库),严禁对用户正在使用的代理进程执行 reset。 +- **`weekly_quota_guard` 复用同一个类**,修复自动覆盖周级守卫,无需单独处理。 +- **`-v` 的重排序语义未变**:`-v` 仍是「提升/替换 N-tier 链路顺序」,不是「只复位这几个 vendor」的过滤器;Dashboard 按钮走无 body 路径,因此不触碰优先级。 + +**关联缺陷(同批修复)** + +Dashboard 的「限速中」徽章读 `rlInfo.limited`,而后端 `VendorTier.get_rate_limit_info()` 产出的键是 `is_rate_limited` —— 键名不匹配导致该徽章**从未渲染过**,Rate Limit 异常态在 UI 上完全不可观测。已同步修正为 `rlInfo.is_rate_limited`。 + ## Session 标题豁免前缀对 `[Session]` 兜底标题不生效(豁免仅 Level 1 生效) **问题描述** diff --git a/docs/.agents/knowledge-map.md b/docs/.agents/knowledge-map.md index 08bd983..9f2dd47 100644 --- a/docs/.agents/knowledge-map.md +++ b/docs/.agents/knowledge-map.md @@ -1,6 +1,6 @@ # Knowledge Map(知识索引) -> 项目所有文档的统一入口与权威索引。由 [AGENTS.md §Knowledge Map](../../AGENTS.md) 锚定,文档目录变更时**必须**即时同步更新本文件。 +> 项目所有文档的统一入口与权威索引。文档目录变更时**必须**即时同步更新本文件。 > > **使用方式**:按"受众 × 目的"二维定位所需文档;不确定起点时,从「入口导航」开始。 @@ -59,31 +59,21 @@ --- -## 5. Agent 协作([docs/agents/](./)) +## 5. Agent 协作与问题档案([docs/.agents/](./)) -> AGENTS.md 工程行为准则的卫星文件,定义 AI Agent 协作过程中的规范与协议。 +> 面向 AI Agent 协作与跨上下文问题沉淀的支撑文档。 -| 文档 | 主旨 | -| --------------------------------------------------------------- | --------------------------------------------- | -| [agents/knowledge-map.md](./knowledge-map.md) | 本文件——项目文档统一索引 | -| [agents/reference-specifications.md](./reference-specifications.md) | IEEE 文献引用格式模板与实践指南 | -| [agents/browser-validation.md](./browser-validation.md) | 浏览器验证协议(连通性自检、凭证管理、E2E) | +| 文档 | 主旨 | +| --------------------------------------------------- | --------------------------------------------- | +| [agents/knowledge-map.md](./knowledge-map.md) | 本文件——项目文档统一索引 | +| [agents/issue.md](./issue.md) | 已处理 Issue 摘要档案(表因、根因、防范) | --- -## 6. 问题档案 +## 6. 工程规范(顶层) | 文档 | 主旨 | | --------------------------------- | ----------------------------------------------------- | -| [docs/issue.md](../issue.md) | 已处理 Issue 摘要档案(表因、根因、防范) | - ---- - -## 7. 工程规范(顶层) - -| 文档 | 主旨 | -| --------------------------------- | ----------------------------------------------------- | -| [AGENTS.md](../../AGENTS.md) | 工程行为准则与 AI Agent 协作协议(与 CLAUDE.md 同源) | | [CHANGELOG.md](../../CHANGELOG.md)| 版本历史与变更日志 | --- @@ -91,5 +81,5 @@ ## 维护约束 1. **同步原则**:新增/删除/重命名 `docs/` 下任意 .md 文件时,**必须**同步本索引。 -2. **路径基准**:本文件位于 `docs/agents/`,所有相对路径以此为基准(向上一级 `../` 访问 `docs/`,向上两级 `../../` 访问仓库根)。 +2. **路径基准**:本文件位于 `docs/.agents/`,所有相对路径以此为基准(向上一级 `../` 访问 `docs/`,向上两级 `../../` 访问仓库根)。 3. **链接验证**:维护者修改本文件后应通过 grep 自检:所有 `[...](path)` 中的 `path` 文件存在。 diff --git a/docs/.agents/reference-specifications.md b/docs/.agents/reference-specifications.md deleted file mode 100644 index 896b866..0000000 --- a/docs/.agents/reference-specifications.md +++ /dev/null @@ -1,16 +0,0 @@ -# Reference Specifications (IEEE) - -> **模版准则**:[编号] 作者缩写. 姓, "文章标题," _刊名/会议名缩写 (斜体)_, 卷号, 期数, 页码, 年份. - -```latex -[1] A. Author, B. Author, and C. Author, "Title of paper," *Abbrev. Title of Journal*, vol. X, no. Y, pp. XX–XX, Year. -``` - -**引用实践** - -- **文内锚定**:采用标准上标链接形式:`描述内容[[1]](#ref1)`。 -- **文献索引**:底层采用 HTML 锚点 `id` 实现跳转稳定性。 - -```latex -[1] A. Vaswani et al., "Attention is all you need," Adv. Neural Inf. Process. Syst., vol. 30, pp. 5998–6008, 2017. -``` diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index 86ca332..4dea153 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -277,6 +277,8 @@ curl -X POST http://127.0.0.1:3392/api/reset \ **重置范围**:circuit_breaker(→ CLOSED)、quota_guard(→ WITHIN_QUOTA)、weekly_quota_guard(→ WITHIN_QUOTA)、rate_limit deadline(→ 清除)。 +> **仅复位状态,不清用量**:配额守卫的滑动窗口用量计数(`window_usage_tokens` / `usage_percent`)**原样保留**。该基线仅在进程启动时从数据库回填,一旦清零,Dashboard 的配额百分比会永久停在 0% 直到重启。若某 vendor 窗口用量确已超过 `token_budget × threshold_percent`,守卫**保持** `QUOTA_EXCEEDED`(不伪造用量),但仍会清除上游 cap 错误卡死标志并把被 `Retry-After` 拉长的探测间隔还原为默认值;真正被解开的是熔断、Rate Limit 与 cap 错误卡死标志。 + ## 7. GET /api/copilot/diagnostics 返回 Copilot 认证与交换链路的脱敏诊断信息。 diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 7148fca..c424b56 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -160,6 +160,8 @@ coding-proxy reset [OPTIONS] **重置范围**:所有层级的熔断器状态(→ CLOSED)、配额守卫状态(→ WITHIN_QUOTA)、周级配额守卫状态(→ WITHIN_QUOTA)、Rate Limit 截止时间(→ 清除)。 +> **仅复位状态,不清用量**:配额守卫的滑动窗口用量计数(`window_usage_tokens` / `usage_percent`)**原样保留**。该基线仅在进程启动时从数据库回填,一旦清零,Dashboard 的配额百分比会永久停在 0% 直到重启。若某 vendor 窗口用量确已超过 `token_budget × threshold_percent`,守卫**保持** `QUOTA_EXCEEDED`(不伪造用量),但仍会清除上游 cap 错误卡死标志并把被 `Retry-After` 拉长的探测间隔还原为默认值;真正被解开的是熔断、Rate Limit 与 cap 错误卡死标志。 + ## 5. coding-proxy auth login 执行 OAuth 浏览器登录,获取供应商访问凭证。 diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md index 0332f10..239ba41 100644 --- a/docs/guide/dashboard.md +++ b/docs/guide/dashboard.md @@ -35,6 +35,20 @@ http://127.0.0.1:3392/dashboard | Token 时间线(按供应商) | 各供应商按天的 Token 消耗堆叠图 | | Token 时间线(按供应商+模型) | 细粒度的模型级 Token 消耗堆叠图 | +### 供应商状态复位 + +「供应商状态」卡片标题栏右侧提供 **⟲ 状态复位** 按钮,用于把处于熔断 / 限流等异常态的供应商一键复位为可正常访问。等价于 [`POST /api/reset`](./api-reference.md#6-post-apireset) 的**无 body** 形态: + +| 复位 | 保留 | +| ---------------------------------------------------------- | ------------------------------------------------------------ | +| 熔断器状态(→ CLOSED,「熔断 ×N」徽章转「正常」) | 配额窗口用量(「1d配额 45%」徽章数值不变) | +| 配额守卫状态(用量未超阈值 → WITHIN_QUOTA;确已超阈值 → 保持 EXCEEDED,但仍清除 cap 卡死标志并还原探测间隔) | 供应商优先级顺序(无 body 即不触发重排序) | +| Rate Limit 冷却截止时间(→ 清除,「限速中」徽章消失) | 数据库用量记录(`coding-proxy usage` 统计不受影响) | + +执行期间按钮进入禁用态并显示「复位中…」,成功后短暂显示「✓ 已复位」并立即重渲染供应商列表(不必等 10 分钟轮询),失败显示「✗ 失败」,详情见浏览器控制台。 + +> 若某供应商的窗口用量**确实**已超过配额阈值,复位不会放行它 —— 用量数字不会被伪造,守卫保持 `QUOTA_EXCEEDED`;但 cap 错误卡死标志会被清除、探测间隔还原为默认值,探测恢复不会被推迟。 + ### 时间范围选择器 页面右上角提供时间范围选择: diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index c8fda53..36c94c9 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -175,7 +175,6 @@ graph RL - 📖 **[用户操作指引 (User Guide)](../user-guide.md)** — 从安装、最小配置要求,到每一项配置文件(`config.yaml`)的具体语义和常见排障指南。 - 🏗️ **[架构设计与工程方案 (Architecture Framework)](../framework.md)** — 详细解码底层设计模式(Template Method、Circuit Breaker、State Machine 等),适用于希望深入了解源码或贡献新供应商的开发者。 -- 🤝 **[工程准则 (AGENTS.md)](../../AGENTS.md)** — 系统的上下文心法和 AI Agent 协作协议,强调**重构、复用与正交抽象**,是本仓库一切开发的指导方针。 --- diff --git a/pyproject.toml b/pyproject.toml index 2d15fd1..8317099 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "coding-proxy" -version = "0.5.2a7" +version = "0.5.2" description = "A High-Availability, Transparent, and Smart Multi-Vendor Proxy for Claude Code. Support Claude Plans, GitHub Copilot, Google Antigravity, ZAI/GLM, MiniMax, Qwen, Xiaomi, Kimi, Doubao..." readme = "README.md" requires-python = ">=3.12" diff --git a/src/coding/proxy/config/config.default.yaml b/src/coding/proxy/config/config.default.yaml index e1bd596..6e584fa 100644 --- a/src/coding/proxy/config/config.default.yaml +++ b/src/coding/proxy/config/config.default.yaml @@ -712,5 +712,5 @@ session_policies: # ⚠️ 自定义 yaml 中该字段为整体替换(loader 的 list 合并约定,非追加合并); # 写空列表即关闭豁免。加载时会自动 strip + 去空 + 去重。 title_exempt_prefixes: - - "Write the title in the language" + - "Write the title in the" - "[Session]" diff --git a/src/coding/proxy/model/__init__.py b/src/coding/proxy/model/__init__.py index 4921e60..e2b70bf 100644 --- a/src/coding/proxy/model/__init__.py +++ b/src/coding/proxy/model/__init__.py @@ -25,6 +25,7 @@ UsageInfo, VendorCapabilities, VendorResponse, + decode_error_body, decode_json_body, extract_error_message, sanitize_headers_for_synthetic_response, @@ -83,6 +84,7 @@ "CopilotModelCatalog", "RequestCapabilities", "UsageInfo", + "decode_error_body", "decode_json_body", "extract_error_message", "sanitize_headers_for_synthetic_response", diff --git a/src/coding/proxy/model/vendor.py b/src/coding/proxy/model/vendor.py index dd5ef4f..867069a 100644 --- a/src/coding/proxy/model/vendor.py +++ b/src/coding/proxy/model/vendor.py @@ -75,6 +75,20 @@ def extract_error_message( return text[:500] if text else None +def decode_error_body(raw: bytes, limit: int = 500) -> str: + """将上游错误响应体(bytes)安全解码为可读文本,供日志展示. + + 直接以 ``%s`` 格式化 ``bytes`` 会走 ``repr()``,导致非 ASCII 的 UTF-8 + 字节被转义为 ``\\xe6\\x82\\xa8`` 之类的不可读序列(中文乱码)。本函数: + + - 使用 ``errors="replace"`` 容忍非法字节,非法字节降级为 ``�`` 而非抛异常, + 确保日志路径绝对健壮; + - 先整体解码再按字符截断,避免在多字节 UTF-8 边界切断产生乱码 + (因此 ``limit`` 语义为「字符数」而非「字节数」)。 + """ + return raw.decode("utf-8", errors="replace")[:limit] + + # ═══════════════════════════════════════════════════════════════ # 供应商核心数据类型 # ═══════════════════════════════════════════════════════════════ @@ -229,6 +243,7 @@ def age_seconds(self) -> int | None: "CopilotMisdirectedRequest", "CopilotModelCatalog", # 工具函数 + "decode_error_body", "decode_json_body", "extract_error_message", "sanitize_headers_for_synthetic_response", diff --git a/src/coding/proxy/routing/quota_guard.py b/src/coding/proxy/routing/quota_guard.py index 7259b51..7978199 100644 --- a/src/coding/proxy/routing/quota_guard.py +++ b/src/coding/proxy/routing/quota_guard.py @@ -175,15 +175,39 @@ def load_baseline(self, total_tokens: int, vendor: str | None = None) -> None: ) def reset(self) -> None: - """手动重置为 WITHIN_QUOTA 状态.""" + """手动复位配额守卫(保留滑动窗口用量计数). + + - 用量未超阈值 / 守卫非 EXCEEDED → 状态机回到 WITHIN_QUOTA; + - 用量确已超阈值 → 保持 QUOTA_EXCEEDED:不伪造用量,也不走 + WITHIN_QUOTA → QUOTA_EXCEEDED 回环(回环会把 ``_last_probe`` 刷成 + 当前时刻,令探测恢复凭空推迟一个 probe_interval,反复复位即饿死探测)。 + + 两种分支均清除 cap 错误卡死标志并还原探测间隔。``_entries`` / ``_total`` + 记录的是真实用量,清零会使 ``usage_percent`` 永久归零(基线仅在进程 + 启动时回填),任何情况下不予触碰。 + """ with self._lock: - self._transition_to(QuotaState.WITHIN_QUOTA) - self._entries.clear() - self._total = 0 - logger.info( - "Quota guard [%s]: manually reset to WITHIN_QUOTA", - self._window_label, + self._expire() + over = self._budget > 0 and self._total >= int( + self._budget * self._threshold ) + if over and self._state == QuotaState.QUOTA_EXCEEDED: + self._cap_error_active = False + self._effective_probe_interval = self._probe_interval + logger.info( + "Quota guard [%s]: manually reset (usage %d tokens over " + "threshold, EXCEEDED state kept, probe interval restored)", + self._window_label, + self._total, + ) + else: + self._transition_to(QuotaState.WITHIN_QUOTA) + logger.info( + "Quota guard [%s]: manually reset to WITHIN_QUOTA " + "(usage %d tokens preserved)", + self._window_label, + self._total, + ) def get_info(self) -> dict: """获取配额守卫状态信息.""" diff --git a/src/coding/proxy/server/dashboard.py b/src/coding/proxy/server/dashboard.py index 2694440..293900c 100644 --- a/src/coding/proxy/server/dashboard.py +++ b/src/coding/proxy/server/dashboard.py @@ -350,6 +350,19 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: margin-bottom: 16px; display: flex; align-items: center; justify-content: space-between; } + /* 卡片标题栏右侧操作控件(.card-title 的 space-between 自动推至右端) */ + .btn-card-action { + padding: 4px 10px; border-radius: 6px; + background: rgba(48,54,61,.4); border: 1px solid rgba(255,255,255,.08); + color: var(--text-secondary); font-size: 12px; font-weight: 500; + letter-spacing: 0; cursor: pointer; white-space: nowrap; + transition: all .15s ease; + } + .btn-card-action:hover:not(:disabled) { + background: var(--bg-card-hover); color: var(--text-primary); + border-color: rgba(88,166,255,.3); + } + .btn-card-action:disabled { opacity: .35; cursor: default; } .chart-wrap { position: relative; height: 260px; min-width: 0; } .chart-wrap-lg { position: relative; height: 260px; min-width: 0; } .chart-wrap-xl { position: relative; height: 280px; min-width: 0; } @@ -685,6 +698,7 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: .range-btn:focus-visible, .btn-refresh:focus-visible, .page-btn:focus-visible, + .btn-card-action:focus-visible, .copy-btn:focus-visible { outline: 2px solid var(--accent-blue); outline-offset: 2px; } .tab-pane { display: none; } .tab-pane.active { display: block; } @@ -904,7 +918,7 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None:
-
供应商状态
+
供应商状态
加载中…
@@ -1396,7 +1410,7 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: if (tier.weekly_quota_guard) quotaHTML += renderQuotaBar(tier.weekly_quota_guard); const rlInfo = tier.rate_limit || {}; - const rlHtml = rlInfo.limited ? `限速中` : ''; + const rlHtml = rlInfo.is_rate_limited ? `限速中` : ''; // Pointer Events 重排:仅需 data-vendor 定位;手柄作为拖拽发起点(无原生 draggable) const dragAttrs = ` data-vendor="${tier.name}"`; @@ -1556,6 +1570,34 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: }).catch(function() {}); } +// ── 供应商状态:一键复位(熔断 / 限流 / 配额守卫状态;不动额度用量与优先级)──── +var _vendorResetInFlight = false; +function resetVendorStatus(btn) { + if (_vendorResetInFlight) return; // 防并发,同 persistTierOrder 范式 + _vendorResetInFlight = true; + var label = btn.textContent; + btn.disabled = true; + btn.textContent = '复位中…'; + // 无 body → 服务端跳过重排序,仅对全部 tier 复位弹性设施状态 + fetch('/api/reset', { method: 'POST' }).then(function(res) { + if (!res.ok) throw new Error(res.status); + btn.textContent = '✓ 已复位'; + // 复位已生效,状态刷新失败不应误报失败、诱导重复点击 → 单独吞掉 + return fetchJSON('/api/status').then(function(status) { + updateVendorStatus(status); // 定向重渲染,不等 10 分钟轮询 + }).catch(function() {}); + }).catch(function(e) { + console.error('vendor reset failed:', e); + btn.textContent = '✗ 失败'; + }).finally(function() { + setTimeout(function() { + btn.textContent = label; + btn.disabled = false; + _vendorResetInFlight = false; + }, 1500); + }); +} + // ── Model Calling 实时状态 ──────────────────────────────── function updateModelCalling(status) { var wrap = document.getElementById('model-calling-wrap'); diff --git a/src/coding/proxy/vendors/antigravity.py b/src/coding/proxy/vendors/antigravity.py index b4d7199..c2b261b 100644 --- a/src/coding/proxy/vendors/antigravity.py +++ b/src/coding/proxy/vendors/antigravity.py @@ -24,6 +24,7 @@ VendorCapabilities, VendorResponse, _sanitize_headers_for_synthetic_response, + decode_error_body, ) # GoogleOAuthTokenManager 已从 antigravity_token_manager.py 合并至本文件末尾 @@ -532,14 +533,16 @@ async def send_message_stream( if response.status_code >= 400: self._on_error_status(response.status_code) error_body = await response.aread() + # 检测用完整解码文本(判定性逻辑不应受日志展示上限影响); + # 日志展示则用 decode_error_body 的 500 字符截断版本。 self._mark_scope_error_if_needed( - error_body.decode("utf-8", errors="ignore"), + error_body.decode("utf-8", errors="replace"), ) logger.warning( "%s stream error: status=%d body=%s", self.get_name(), response.status_code, - error_body[:500], + decode_error_body(error_body), ) raise httpx.HTTPStatusError( f"{self.get_name()} API error: {response.status_code}", diff --git a/src/coding/proxy/vendors/base.py b/src/coding/proxy/vendors/base.py index d1434bc..1c4c9c3 100644 --- a/src/coding/proxy/vendors/base.py +++ b/src/coding/proxy/vendors/base.py @@ -25,6 +25,7 @@ UsageInfo, VendorCapabilities, VendorResponse, + decode_error_body, decode_json_body, extract_error_message, sanitize_headers_for_synthetic_response, @@ -328,7 +329,7 @@ async def send_message_stream( "%s stream error: status=%d body=%s", self.get_name(), response.status_code, - error_body[:500], + decode_error_body(error_body), ) raise httpx.HTTPStatusError( f"{self.get_name()} API error: {response.status_code}", diff --git a/src/coding/proxy/vendors/copilot.py b/src/coding/proxy/vendors/copilot.py index b159976..a8e7f0f 100644 --- a/src/coding/proxy/vendors/copilot.py +++ b/src/coding/proxy/vendors/copilot.py @@ -25,6 +25,7 @@ VendorResponse, _decode_json_body, _extract_error_message, + decode_error_body, ) from .copilot_models import ( # noqa: F401 CopilotMisdirectedRequest, @@ -423,7 +424,7 @@ async def _stream_from_client( "%s stream error: status=%d body=%s", self.get_name(), response.status_code, - error_body[:500], + decode_error_body(error_body), ) raise httpx.HTTPStatusError( f"{self.get_name()} API error: {response.status_code}", diff --git a/tests/test_antigravity.py b/tests/test_antigravity.py index cc93127..675d995 100644 --- a/tests/test_antigravity.py +++ b/tests/test_antigravity.py @@ -275,6 +275,51 @@ def test_mark_scope_error_if_needed(): assert diagnostics["token_manager"]["error_kind"] == "insufficient_scope" +@pytest.mark.asyncio +async def test_stream_scope_detection_scans_full_body_not_log_truncation(): + """流式 scope 检测必须扫描完整 body,而非日志展示用的 500 字符截断. + + 回归守护:``decode_error_body`` 的 ``[:500]`` 截断仅服务日志展示;若把它 + 同时喂给 ``_mark_scope_error_if_needed`` 的子串检测,则标记出现在 500 字符 + 之后的错误体会漏检,token 不会被标记 ``needs_reauth``。本用例构造标记位于 + 500 字符之后的 403 错误体,断言检测仍然触发(旧的截断实现下会失败)。 + """ + marker = "ACCESS_TOKEN_SCOPE_INSUFFICIENT" + # message 填充 600 字符,把 details 中的 marker 挤到第 500 字符之后 + body = ( + f'{{"error":{{"message":"{"x" * 600}","status":"PERMISSION_DENIED",' + f'"details":[{{"reason":"{marker}"}}]}}}}' + ).encode() + assert body.decode().index(marker) > 500 # 前置条件:marker 确在 500 之后 + + vendor = AntigravityVendor(AntigravityConfig(), FailoverConfig(), ModelMapper([])) + vendor._token_manager.get_token = AsyncMock(return_value="tok") + vendor._discover_project_id = AsyncMock(return_value="") # 避免真实网络发现 + vendor._client = httpx.AsyncClient( + base_url=vendor._base_url, + transport=httpx.MockTransport( + lambda _req: httpx.Response( + 403, content=body, headers={"content-type": "application/json"} + ) + ), + ) + + with pytest.raises(httpx.HTTPStatusError): + async for _ in vendor.send_message_stream( + { + "model": "claude-sonnet-4-20250514", + "messages": [{"role": "user", "content": "Hi"}], + }, + {}, + ): + pass + + await vendor.close() + + diagnostics = vendor.get_diagnostics() + assert diagnostics["token_manager"]["error_kind"] == "insufficient_scope" + + def test_antigravity_supports_request_with_tools_thinking_and_metadata(): vendor = AntigravityVendor(AntigravityConfig(), FailoverConfig(), ModelMapper([])) supported, reasons = vendor.supports_request( diff --git a/tests/test_app_routes.py b/tests/test_app_routes.py index 5c0aa53..e059d65 100644 --- a/tests/test_app_routes.py +++ b/tests/test_app_routes.py @@ -1067,6 +1067,52 @@ def test_reset_reorder_also_resets_circuit_breaker_and_rate_limit(): assert not anthropic_tier.is_rate_limited +def test_reset_preserves_quota_usage(): + """核心:/api/reset 复位守卫状态但**不**清空滑动窗口用量. + + 旧实现在 QuotaGuard.reset() 里连带 `_entries.clear()` + `_total = 0`, + 使 Dashboard 的配额百分比归零且无法自愈(基线仅在进程启动时回填)。 + """ + app = _make_reorder_app() + router = app.state.router + tier = router.tiers[0] + qg = tier.quota_guard + assert qg is not None + qg._enabled = True + qg._budget = 100_000 + qg.record_usage(40_000) + qg.notify_cap_error() # 制造 cap 卡死态 + assert qg.get_info()["state"] == "quota_exceeded" + + with TestClient(app) as client: + resp = client.post("/api/reset") + assert resp.status_code == 200 + + after = qg.get_info() + assert after["state"] == "within_quota" # 状态已复位 + assert after["window_usage_tokens"] == 40_000 # 用量原样保留 + assert after["usage_percent"] == 40.0 + assert qg.can_use_primary() is True # cap 卡死标志已清除 + + +def test_reset_preserves_quota_usage_with_reorder(): + """带 -v 重排序的 /api/reset 同样不得清空用量(CLI `coding reset -v ...` 路径).""" + app = _make_reorder_app() + qg = app.state.router.tiers[0].quota_guard + assert qg is not None + qg._enabled = True + qg._budget = 100_000 + qg.record_usage(45_000) + + with TestClient(app) as client: + resp = client.post( + "/api/reset", json={"vendors": ["anthropic", "zhipu", "copilot"]} + ) + assert resp.status_code == 200 + + assert qg.get_info()["window_usage_tokens"] == 45_000 + + def test_reorder_tiers_shared_reference(): """验证 reorder_tiers 使用切片赋值,Executor 立即可见.""" from coding.proxy.routing.router import RequestRouter @@ -1235,3 +1281,32 @@ def test_dashboard_serves_pointer_drag_reorder_and_no_cache(): # 已移除脆弱的原生 HTML5 DnD(不再渲染 draggable 属性 / 监听 dragstart) assert 'draggable="true"' not in html assert "addEventListener('dragstart'" not in html + + +def test_dashboard_serves_vendor_status_reset_control(): + """供应商状态卡片标题栏交付「状态复位」按钮,且走无 body 的 POST /api/reset. + + 无 body 是语义关键:服务端据此跳过重排序(routes.py 的 vendor_names 守卫), + 因此复位不会改动供应商优先级。 + """ + with _make_app() as client: + html = client.get("/dashboard").text + # 按钮随卡片标题栏交付 + assert 'id="btn-vendor-reset"' in html + assert "btn-card-action" in html + assert "resetVendorStatus(this)" in html + # 处理器在位,且为无 body 的 POST(不触发重排序) + assert "function resetVendorStatus(btn)" in html + assert "fetch('/api/reset', { method: 'POST' })" in html + assert "_vendorResetInFlight" in html # 防并发守卫 + + +def test_dashboard_rate_limit_badge_reads_correct_key(): + """「限速中」徽章须读后端真实键名 is_rate_limited(tier.get_rate_limit_info). + + 旧实现读 rlInfo.limited —— 后端从未产出该键,徽章永远不渲染。 + """ + with _make_app() as client: + html = client.get("/dashboard").text + assert "rlInfo.is_rate_limited" in html + assert "rlInfo.limited " not in html diff --git a/tests/test_quota_guard.py b/tests/test_quota_guard.py index 9d3ed9a..a2b6df0 100644 --- a/tests/test_quota_guard.py +++ b/tests/test_quota_guard.py @@ -90,14 +90,80 @@ def test_probe_success_restores_within_quota(): assert qg.get_info()["state"] == "within_quota" -def test_reset_clears_all_state(): +def test_reset_preserves_window_usage(): + """复位只清状态机,滑动窗口用量计数须原样保留.""" qg = _make_guard() qg.record_usage(500) qg.notify_cap_error() qg.reset() info = qg.get_info() assert info["state"] == "within_quota" - assert info["window_usage_tokens"] == 0 + assert info["window_usage_tokens"] == 500 + + +def test_reset_clears_cap_error_stall(): + """cap 错误卡死态:复位清除 _cap_error_active,预算未超时立即恢复放行.""" + qg = _make_guard(token_budget=1000) + qg.record_usage(100) + qg.notify_cap_error() + assert qg.can_use_primary() is False + qg.reset() + assert qg.can_use_primary() is True + assert qg.get_info()["window_usage_tokens"] == 100 + + +def test_reset_does_not_unblock_genuinely_exhausted_quota(): + """用量确实超阈值时,复位不放行也不伪造用量——保持 EXCEEDED,不做状态回环.""" + qg = _make_guard(token_budget=1000, threshold_percent=99.0) + qg.record_usage(995) + assert qg.can_use_primary() is False + qg.reset() + assert qg.get_info()["state"] == "quota_exceeded" + assert qg.can_use_primary() is False + assert qg.get_info()["window_usage_tokens"] == 995 + + +def test_reset_does_not_delay_probe_when_over_budget(): + """回归:用量仍超阈值时复位不得推后探测时钟. + + 若 reset() 走 WITHIN_QUOTA → 下一次判定再回落 QUOTA_EXCEEDED 的回环, + `_transition_to(EXCEEDED)` 会把 `_last_probe` 刷成当前时刻,令探测恢复 + 凭空推迟一个 probe_interval;反复点击「状态复位」即可无限饿死探测。 + """ + qg = _make_guard( + token_budget=1000, threshold_percent=99.0, probe_interval_seconds=300 + ) + base = time.monotonic() + with patch("coding.proxy.routing.quota_guard.time") as mock_time: + mock_time.monotonic.return_value = base + qg.record_usage(995) + assert qg.can_use_primary() is False # → EXCEEDED,_last_probe = base + + # 距下次探测仅剩 10s 时复位 + mock_time.monotonic.return_value = base + 290 + qg.reset() + + # 原定探测点(base+300)之后应照常放行探测,而非被推到 base+590 + mock_time.monotonic.return_value = base + 301 + assert qg.can_use_primary() is True + + +def test_reset_still_clears_cap_stall_when_over_budget(): + """用量超阈值但由 cap 错误拉长了探测间隔时,复位须还原 probe_interval.""" + qg = _make_guard( + token_budget=1000, threshold_percent=99.0, probe_interval_seconds=300 + ) + base = time.monotonic() + with patch("coding.proxy.routing.quota_guard.time") as mock_time: + mock_time.monotonic.return_value = base + qg.record_usage(995) + qg.notify_cap_error(retry_after_seconds=3600) # 探测间隔被拉到 3960s + assert qg.can_use_primary() is False + + qg.reset() + mock_time.monotonic.return_value = base + 301 + # 若 _effective_probe_interval 未还原为 300,此处仍会被拒 + assert qg.can_use_primary() is True def test_get_info_returns_correct_data(): diff --git a/tests/test_vendor_helpers.py b/tests/test_vendor_helpers.py new file mode 100644 index 0000000..322da7a --- /dev/null +++ b/tests/test_vendor_helpers.py @@ -0,0 +1,107 @@ +"""供应商工具函数测试 — 聚焦 decode_error_body 的编码正确性与流式错误日志回归. + +背景:上游 4xx/5xx 错误的流式日志曾将 ``bytes`` 响应体经 ``%s`` 直接格式化, +Python 对 ``bytes`` 走 ``repr()``,导致非 ASCII 的 UTF-8 字节被转义为 +``\\xe6\\x82\\xa8`` 之类的不可读序列(中文乱码)。本测试锚定该根因并守护修复。 +""" + +import logging + +import httpx +import pytest + +from coding.proxy.config.schema import AnthropicConfig, FailoverConfig +from coding.proxy.model.vendor import decode_error_body +from coding.proxy.vendors.anthropic import AnthropicVendor + +# ── 单元测试:decode_error_body ────────────────────────────── + +_ZH_TEXT = "您的账户已达到速率限制,请您控制请求频率" + + +class TestDecodeErrorBody: + def test_chinese_bytes_decoded_readable(self): + """中文 UTF-8 bytes 应解码为可读字符,不残留转义序列或 bytes 前缀.""" + raw = f'{{"message":"[1302][{_ZH_TEXT}]"}}'.encode() + out = decode_error_body(raw) + assert _ZH_TEXT in out + assert "\\x" not in out # 无字节转义 + assert not out.startswith("b'") # 非 bytes repr + assert isinstance(out, str) + + def test_truncates_by_characters(self): + """limit 语义为字符数;超长输入按字符截断.""" + raw = ("你" * 1000).encode() + out = decode_error_body(raw, limit=100) + assert len(out) == 100 + assert out == "你" * 100 # 未在多字节边界切断 + + def test_invalid_bytes_do_not_raise(self): + """非法字节以 errors='replace' 降级为占位符,绝不抛异常.""" + raw = b"\xff\xfe invalid" + out = decode_error_body(raw) + assert isinstance(out, str) + assert "�" in out # U+FFFD replacement character + + def test_empty_bytes(self): + assert decode_error_body(b"") == "" + + def test_ascii_passthrough(self): + raw = b'{"error":{"type":"rate_limit_error"}}' + assert decode_error_body(raw) == '{"error":{"type":"rate_limit_error"}}' + + def test_regression_repr_vs_decode(self): + """回归对照:复现旧 bug(%s 对 bytes 走 repr)并证明新实现修复之.""" + raw = _ZH_TEXT.encode() + # 旧行为:bytes 经 %s → repr → 转义字节序列(刻意保留 % 格式化以精确 + # 复现旧 logger.warning("...%s...", error_body) 的缺陷路径,故 noqa UP031) + assert "\\x" in "%s" % raw # noqa: UP031 + # 新行为:先解码 → 可读中文,无转义 + assert "\\x" not in decode_error_body(raw) + + +# ── 集成测试:BaseVendor 流式错误日志路径 ──────────────────── + + +@pytest.mark.asyncio +async def test_stream_error_log_decodes_chinese(caplog): + """经 BaseVendor.send_message_stream 的流式错误日志应输出可读中文. + + 以 AnthropicVendor(纯继承 BaseVendor,无重试/包装)具象化, + 注入挂载 MockTransport 的 AsyncClient 返回 400 + 含中文的 body, + 断言 WARNING 日志文本含中文且不含字节转义序列。 + """ + error_payload = f'{{"error":{{"message":"{_ZH_TEXT}"}}}}'.encode() + + def _handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 400, + content=error_payload, + headers={"content-type": "application/json; charset=utf-8"}, + ) + + vendor = AnthropicVendor(AnthropicConfig(), FailoverConfig()) + # 注入 MockTransport 客户端(base_url 与真实一致,仅替换 transport) + vendor._client = httpx.AsyncClient( + base_url=vendor._base_url, + transport=httpx.MockTransport(_handler), + ) + + with caplog.at_level(logging.WARNING): + with pytest.raises(httpx.HTTPStatusError): + async for _ in vendor.send_message_stream( + {"model": "claude-opus-4-6", "messages": []}, + {"authorization": "Bearer sk-test"}, + ): + pass + + await vendor.close() + + stream_errors = [ + r.getMessage() for r in caplog.records if "stream error" in r.getMessage() + ] + assert stream_errors, "未捕获到 stream error 日志" + msg = stream_errors[0] + assert _ZH_TEXT in msg # 中文可读 + assert "\\x" not in msg # 无字节转义 + assert "body=b'" not in msg # 非 bytes repr diff --git a/uv.lock b/uv.lock index b9197bf..ca13d2b 100644 --- a/uv.lock +++ b/uv.lock @@ -74,7 +74,7 @@ wheels = [ [[package]] name = "coding-proxy" -version = "0.5.2a5" +version = "0.5.2a8" source = { editable = "." } dependencies = [ { name = "aiosqlite" },