Compare commits

...
12 Commits
Author SHA1 Message Date
ZyyandCodex 5205c07d60 chore: bump version to 3.4.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-12 00:24:21 +08:00
ZyyandCodex 60eb08cb96 docs(release): prepare v3.4.1 notes
Summarize quota reset, downstream transport isolation, compressed request decoding, and weekly/monthly usage window fixes since v3.4.0.

Co-authored-by: Codex <noreply@openai.com>
2026-09-12 00:24:04 +08:00
ZyyandCodex fd4a49e64f test(openai): merge websocket proxy header regression (#67)
Preserve the original contributor commit and its two-frame Cloudflare header regression. Resolve overlap with the existing transport-header fix while keeping the identity-encoding fixture and ingress decompression documentation from main.

Validation: 385 API, protocol, and application tests, Rustfmt, and strict workspace Clippy passed. The new regression also passed independently. No new live Cloudflare/OpenAI end-to-end run was performed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-12 00:17:39 +08:00
ZyyandCodex be2ee7f3b8 fix(openai): bound request decompression and validate encodings
Stream all supported encodings through a bounded decoder, cap buffer growth and the zstd window, and consume every gzip member. Borrow uncompressed bodies, reject stacked encodings, and remove the unused error variant.

Add decoding and HTTP boundary regressions, preserve downstream transport-header isolation, and document the ingress compression contract.

Validation: 384 API, protocol, and application tests, Rustfmt, and strict workspace Clippy passed. Twenty real HTTP requests passed with synthetic upstream execution. An 8,210-byte zstd probe now stops at the 64 MiB output limit, with a largest Rust allocation of 64 MiB instead of 256 MiB. Official Codex client fallback and real upstream execution were not exercised.

Co-authored-by: Codex <noreply@openai.com>
2026-09-12 00:10:23 +08:00
xbz123andCodex 757abdb4d1 fix(openai): strip downstream proxy headers from upstream requests
Co-authored-by: Codex <noreply@openai.com>
2026-09-11 17:55:11 +02:00
ZyyandCodex cb5e0ba4c0 fix(openai): merge request content encoding support (#66)
Preserve the original contribution before hardening its decoding limits and regression coverage.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 23:54:42 +08:00
ZyyandCodex d2b418f8cc fix(admin): use weekly and monthly quota windows for usage
Prefer account-wide weekly usage windows, falling back to monthly windows when weekly usage is unavailable. Require complete window boundaries and do not fall back to short-term or historical totals.

Expose the selected window label in account responses, update usage panels and Token tooltips, and align summary text baselines. Preserve quota bar and Dashboard selection behavior.

Validation: 421 related and application tests, Rustfmt, strict workspace Clippy, and frontend lint/type checks/build passed. Browser checks with synthetic weekly, monthly, and empty fixtures passed in dark, light, and narrow layouts.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 23:27:34 +08:00
zhangwentian 2390783900 fix(openai): decode gzip/deflate/zstd request bodies on POST /v1/responses
官方 Codex 客户端对较大的 /v1/responses 请求体默认 zstd 压缩(level 3)并
携带 Content-Encoding: zstd,也会使用 gzip。网关此前只在出站方向按官方指纹
做 zstd 压缩(client_sse.rs),入站方向却不接受压缩正文,压缩请求一律命中
400 invalid_json("Request body must be valid JSON.")。官方后端接受压缩
请求,因此直连官方可用的会话经网关转发时会在 HTTP 回退路径稳定失败。

现在 POST /v1/responses 入站解码按 Content-Encoding 解压后再进入 JSON
解析:

- 支持 gzip、deflate、zstd;未压缩与 identity 原样透传
- 解压结果超过 64 MiB 上限时拒绝,防御解压炸弹
- 未知 Content-Encoding 返回 400 unsupported_content_encoding
- 损坏的压缩载荷统一按 invalid_json 返回,不泄露内部细节

WebSocket 帧路径不受影响(permessage-deflate 由传输层协商处理)。
2026-09-11 23:21:45 +08:00
ZyyandCodex c75f0e7fd9 fix(openai): isolate downstream transport headers
Share transport-header classification between API ingress and OpenAI encoding. Strip proxy metadata and downstream compression negotiation while preserving opaque business extensions and upstream response diagnostics.

Add protocol, decoder, HTTP, and WebSocket regressions and document the transport boundary.

Validation: 946 related and application tests, Rustfmt, and strict workspace Clippy passed. Application tests passed with incremental compilation disabled after a compiler cache panic. Real-account HTTP/WebSocket checks and a two-turn WebSocket session through Cloudflare Tunnel passed.

Refs #63, #64

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 22:58:38 +08:00
ZyyandCodex d5e9360ed7 fix(admin): support quota resets over plain HTTP
Generate canonical UUIDv4 redeem IDs with crypto.getRandomValues so reset
confirmation works on non-secure HTTP origins. Preserve the pending
operation ID when retrying uncertain consumption.

Validation: frontend lint/type checks/build, browser composable checks
with mocked APIs on HTTP/localhost/HTTPS, and 10 related backend tests
passed. No real reset credits were consumed.

Refs #62

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 20:41:32 +08:00
ZyyandCodex 3fcb8e5f2d docs(release): translate v3.4.0 notes into Chinese
Translate the release summary and upgrade instructions while preserving
the version, technical identifiers, and documented behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 17:25:57 +08:00
ZyyandCodex 8cdaf8b2e6 chore: add contribution workflows and repository skills
Define shared contribution and review standards, add a YAML Issue Form,
and guard AGENTS.md changes using PR metadata.

Move project skills into .agents/skills and add PR, Issue, and release
workflows with consistent names and scoped triggers. Keep generic
engineering guidance in the personal skill directory.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 17:25:57 +08:00
51 changed files with 1567 additions and 483 deletions
+34
View File
@@ -0,0 +1,34 @@
---
name: dev-guide
description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关、Vue 管理端、账号与出站代理、接口协议、部署、CI、发布或文档时使用,定位权威规范和验证入口。Use for development in the repository containing this skill; not for generic Rust/Vue questions in unrelated repositories.
---
# 开发指南
先读根目录的 [AGENTS.md](../../../AGENTS.md) 和 [CONTRIBUTING.md](../../../CONTRIBUTING.md)。
本技能只组织工作入口,架构、协议和视觉规则从下列权威文档读取,不维护第二份副本。
## 按任务读取
| 任务 | 文档入口 |
| --- | --- |
| 用户能力、快速开始 | [README](../../../README.md) |
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
| 管理端组件、主题 Token、视觉验证 | [管理端主题](../../../docs/theme.md) |
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
| 验证命令与提交规范 | [贡献与审查](../../../CONTRIBUTING.md) |
| 创建或审查 PR | [github-pr](../github-pr/SKILL.md) |
| 整理或提交 Issue | [github-issue](../github-issue/SKILL.md) |
| 发布说明、版本规划与发版 | [release](../release/SKILL.md) |
## 执行顺序
1. 从仓库根目录确认工作区、当前任务范围和相关运行实例;保留已有改动。
2. 按 `AGENTS.md` 的 CodeGraph 约定定位入口、调用链和职责归属,再阅读相关实现。
3. 结合上表读取本次需要的文档,先找可复用的同类实现,再修改所属模块。
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;同步受影响的说明。
5. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。
发布任务按 `release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
@@ -0,0 +1,4 @@
interface:
display_name: "dev-guide"
short_description: "从仓库规范与架构文档定位实现、验证和交付的正确入口"
default_prompt: "使用 $dev-guide 按当前仓库规范与架构完成开发、验证或发布。"
+55
View File
@@ -0,0 +1,55 @@
---
name: github-issue
description: 整理与提交 GitHub Issue(问题反馈 / 功能建议)。用于“提个 Issue”“把这个问题反馈到仓库”“帮我报个 bug”“整理成 Issue”,以及通过 Issue 提议功能或协作规则的场景;区分草拟正文和实际提交。Use for drafting or filing bug reports and feature requests; not for debugging-only, editing Issue Forms/templates, or skill-authoring requests.
---
# 整理与提交 Issue
本仓库流程见 [贡献与审查](../../../CONTRIBUTING.md),问题反馈使用当前的
[YAML Issue Form](../../../.github/ISSUE_TEMPLATE/bug_report.yml)。从表单读取字段与必填项,不在技能中复制一份字段清单。
用户指定其他仓库时,使用该仓库当前的贡献规则与 Issue 模板,不能把这里的表单套过去。
## 确定目标和动作
- 根据用户 URL、仓库上下文和 remote 确认接收 Issue 的仓库。fork 场景核对用户要反馈给自己的 fork 还是上游。
- “写一份、整理内容、草拟 Issue”只准备正文;明确要求“提交、创建 Issue”才发布。
已经授权提交且信息足够时直接完成,不重复索要确认;仅自动选中技能不能授权发布。
- “有个 bug”“这个报错怎么修”本身是排障语境,不能推断要发 Issue;结合已有反馈任务识别省略的目标和动作。
- 缺少无法推断的目标或关键复现事实时,只问必要信息,并先整理其余已有证据。
- 使用可用的 GitHub 连接器或 `gh`。不要为提交 Issue 安装新工具、修改仓库配置或启用额外服务。
## 证据与去重
1. 读取目标仓库实际 Issue 模板和配置,区分缺陷、功能建议与协作规则提案。
本仓库后两类允许普通 Issue;其他仓库按其配置选择入口。
2. 先搜索 open 和 closed Issues,结合关键词、错误信息与触发条件检查重复,而不只比较标题。
同一问题已有记录且没有新增证据时返回已有链接;用户仍明确要求新建时按其要求处理。
不因发现重复而擅自评论、重开或关闭已有 Issue。
3. 从用户描述、当前版本与已有诊断中整理事实:区分实际结果、预期结果、稳定复现条件和未经证实的推测。
不把本地开发版本当作发生问题的部署版本;无法复现时如实说明已知条件。
4. 只收集定位所需信息。正文、日志片段和附件去除密钥、Cookie、令牌、代理认证、私人请求内容等敏感信息;
不直接附完整配置或请求转储。记录与复现相关的代理、TUN、证书条件,不擅自修改这些环境来收集材料。
## 编写正文
- 标题写具体症状或所需行为,不预先断言尚未证明的根因。语言与目标仓库及用户要求一致。
- YAML Issue Form 按字段顺序使用 `attributes.label` 作为 Markdown 小标题,填写相应答案。
`gh issue create --body-file` 直接提交正文,不会替你完成网页表单校验;手动核对必填字段、选项和必选确认项。
不把原始 YAML、占位文字或尚未确认的勾选项当成答案提交。
- 无法获知的信息明确写未提供或不适用,并说明理由;如果该事实关系到能否理解或复现问题,发布前补齐。
功能或规则提案说明当前场景、缺少的能力、建议结果和必要取舍,不强行填写缺陷复现步骤。
- 附件只使用用户授权发布且实际可访问的地址;本机 `/tmp/...` 路径不能作为 GitHub 附件。
没有可用附件时保留操作描述并说明缺口,不声称截图已上传。
- 使用模板规定或用户要求的 labels、assignees;先确认它们在目标仓库存在,不自行猜测。
## 提交与收尾
把核对后的 UTF-8 正文写入临时文件,或直接传给连接器的结构化字段。CLI 变量先从已确认的目标与正文赋值:
```bash
gh issue create --repo "$issue_repo" --title "$issue_title" --body-file "$issue_body_file"
```
发布前快速复核目标、必填内容、脱敏及重复记录。网络超时或返回不明确时先查询远端是否已经创建,避免重复提交。
发布后读取新 Issue 确认仓库、标题和正文,返回链接及必要的信息缺口。
只准备草稿或发布失败时提供可直接使用的标题与正文,明确尚未提交。
@@ -0,0 +1,4 @@
interface:
display_name: "github-issue"
short_description: "按仓库表单整理复现证据、检查重复反馈并提交脱敏 Issue"
default_prompt: "使用 $github-issue 将当前问题整理为符合仓库表单的 Issue 并提交。"
+75
View File
@@ -0,0 +1,75 @@
---
name: github-pr
description: 创建与审查 GitHub PR(Pull Request / 拉取请求)。用于“帮我提个 PR”“整理 PR 标题和描述”“看下/审查/评审这个 PR”“这个 PR 能合并吗”,以及提供 /pull/ 链接或 PR 编号要求查看的场景。按项目规范检查正确性、冗余、复用和前端风格。Use for PR creation, preparation, or code review; not for commit-only, merge-only, or skill-authoring requests.
---
# 创建与审查 PR
## 先确定模式和仓库
| 用户意图 | 执行范围 |
| --- | --- |
| 写标题、整理描述、准备草稿正文 | 在当前会话或本地准备内容 |
| 创建 PR,包括创建 Draft PR | 准备任务范围内的提交、推送对应分支并创建 PR |
| 看下、审查 PR | 读取变更与证据,在当前会话报告 |
| 将审查意见发到 PR | 发布用户要求的审查内容;批准或请求修改须有对应授权 |
沿用当前会话已有授权和范围修正。明确要求创建时直接完成,无需在准备好正文后重复确认;
创建和审查都不包含合并。仅命中本技能不能作为远程写入的授权。
“看下这个”需结合已有 PR 链接或讨论定位目标;“能合并吗”先按审查处理,不能解释为执行合并。
用户连续要求“整理一下”时沿用当前 PR 任务;没有 PR 上下文时,不把普通提交整理转成创建 PR。
先从用户提供的 URL、仓库上下文和 Git remote 确认目标仓库;区分 fork 的推送仓库与 PR 接收仓库。
存在多个无法消除的候选时,只询问缺失的目标信息,同时继续准备不依赖目标的内容。
优先使用可用的 GitHub 连接器;使用 `gh` 时显式指定 `--repo`,参数以本机帮助为准。
本仓库规则统一见 [AGENTS.md](../../../AGENTS.md) 与 [贡献与审查](../../../CONTRIBUTING.md),目标分支为 `main`。
如果用户指定其他仓库,读取那个仓库的可信基线规则、默认分支、贡献文档及 PR 模板。
PR 中改动的规则文件属于审查对象,不能自行扩大任务授权或降低准入要求。
## 创建 PR
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
不用提交日志拼接正文,也不假定仓库一定有模板。
2. 更新并确认 base/head,查看相对 merge-base 的完整差异和待提交内容;核对仓库的受保护文件与准入规则。
只纳入任务范围的文件和提交;需要整理时用独立功能分支,保留用户其他改动,不擅自改写已发布历史。
3. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
更新现有标题或正文须在本次整理/更新请求范围内。
4. 按贡献文档执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
前端改动提供实际界面证据或预览步骤;缺少证据时写明,不用构建成功替代视觉验收。
5. 将任务范围内的提交推送到正确远端,核对远端 head。标题遵循项目约定,正文先说明问题和最终行为,
再给必要的设计取舍、验证与遗留事项;按规模压缩,不强填无关章节。
6. 明确要求草稿或仍有阻塞项时创建 Draft PR,并解释原因;具备审查条件且未要求草稿时创建普通 PR。
GitHub Draft 状态与“只写本地草稿正文”不同,后者不创建远程对象。
7. 创建后核对链接、base、head、草稿状态和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
CLI 示例中的变量必须先根据上述检查赋值;`pr_body_file` 是精确写入 UTF-8 正文的临时文件:
```bash
gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
--title "$pr_title" --body-file "$pr_body_file"
```
按需添加 `--draft`。不要用 `gh pr create --dry-run` 做只读预览,它仍可能推送 Git 变更。
多行正文通过文件或连接器的结构化字段传递,不把未转义正文拼进 shell 命令。
创建完成返回链接、目标分支和验证摘要;不要称为已合并或已获维护者批准。
## 审查 PR
1. 获取 PR 的 base/head SHA、描述、文件差异、已有讨论及 CI 状态。记录实际审查的 head,使用 merge-base 差异。
需要源码上下文时先遵守目标仓库的定位规则;需检出时使用隔离工作区,保留用户当前分支和运行环境。
2. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
3. UI 改动同时对照主题、共用组件、数据展示逻辑和同类页面。浏览器可用时检查相关交互及状态;
未运行的页面检查列为验证缺口,不从截图或源码推断所有状态都正确。
4. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
工具不可用、集成测试跳过或环境不足时具体说明影响范围。
5. 依照贡献文档用中文输出必须修复项、可选建议和验证缺口;没有的类别可省略。
每个问题给变更中的文件位置、触发条件或规范依据、影响和最小修复方向。
复用建议指出具体已有实现,不把个人偏好写成缺陷;未发现问题时直接说明并交代覆盖范围。
6. 结束前核对 head 是否变化。发生变化时检查新增差异并更新结论,或明确结论只适用于原 SHA。
审查本身不修改源码、不发表评论、不提交 GitHub 的批准或请求修改状态。
用户明确要求发布时,先复核当前 head 和重复评论,再通过结构化字段或 `--body-file` 发布对应内容;
需要绑定具体提交时使用支持 commit ID 的审查接口。不要在旧提交结论上批准新的 head。
@@ -0,0 +1,4 @@
interface:
display_name: "github-pr"
short_description: "按项目规范创建拉取请求,或基于明确提交审查代码与界面"
default_prompt: "使用 $github-pr 审查当前 PR,按仓库规范报告问题与验证缺口。"
+79
View File
@@ -0,0 +1,79 @@
---
name: release
description: 准备或执行当前仓库发版,维护中文发布说明。用于“发版”“发个新版本”“准备 vX.Y.Z”“推荐版本号”“整理更新日志/发布说明”“更新 release/notes.md”“打版本 tag”等发行任务。Use for version planning, release notes, and publishing a repository release; not for deploying/restarting an instance, publishing application data, or authoring a release skill.
---
# 发版
## 识别任务范围
| 用户意图 | 执行范围 |
| --- | --- |
| 推荐版本号 | 根据本次变更与已有版本给出建议及理由 |
| 整理更新日志、写 note、准备发版 | 核对变更、编写说明、检查发布条件;未要求提交时保留本地改动 |
| 正式发布、发布指定版本、打版本 tag | 在已授权范围内执行对应步骤;仅打 tag 的请求不自动扩展为完整发版 |
| 重试失败的发版 | 核对上次执行状态,从未完成的步骤恢复 |
沿用当前会话明确的版本、范围与授权;用户已要求正式发版且条件满足时直接执行,不重复确认。
“准备好了”“可以发版吗”属于准备或检查,不能解释成正式发布;创建本技能也不触发发版。
发行版发布与运行实例升级分开判断,“上线”需结合上下文确定用户指哪一种。
## 读取当前流程
先读 [AGENTS.md](../../../AGENTS.md) 与 [贡献与审查](../../../CONTRIBUTING.md),再按任务检查:
| 事实 | 权威入口 |
| --- | --- |
| 当前版本 | [release/version.yaml](../../../release/version.yaml) |
| 本次发布说明及语言约定 | [release/notes.md](../../../release/notes.md)、贡献文档的项目约定 |
| 版本提交、tag、推送与工作流触发 | [release/publish](../../../release/publish) |
| 构建、检查、GitHub Release 和镜像发布 | [发布工作流](../../../.github/workflows/release.yml) |
| 平台与产物清单 | [release/platforms.yaml](../../../release/platforms.yaml) |
命令从仓库根目录执行;每次以当前脚本和工作流为准,不另写发布脚本或绕过已有质量检查。
用户明确指定其他仓库时先读取其发布流程,不能直接套用这里的路径和命令。
## 准备版本与说明
1. 确认发布分支、upstream、目标仓库、工作区和完整待推送范围。本仓库沿用 `main`;
未提交或未跟踪内容分别核对,不因脚本只检查已跟踪文件就认定所有内容都已纳入发布。
2. 读取版本文件、已有 tag 和实际发行记录,确认上一发布基线与本次目标提交。
根据基线到目标的提交和最终差异归纳变更,剔除回退、重复和未进入发布范围的内容。
不能只用当前版本文件或最近几条提交判断“本次更新”。
3. 使用用户指定或当前会话已确定的目标版本;需要建议时按兼容性、能力新增和修复范围说明理由。
目标 tag 必须未被占用;遇到已有同名版本先判断是重试还是新发版,不自动递增版本掩盖冲突。
4. 按贡献文档维护 `release/notes.md`。正文写中文,保留版本号和必要技术标识;
按用户可感知的最终行为归并,不逐条翻译 commit,也不把排查过程或未验证承诺写入说明。
可按新增功能、问题修复、体验优化、升级说明组织,省略没有内容的章节。
5. 替换为本次版本说明,首行与目标 tag 一致且正文非空。升级事项应能追溯到实际迁移、配置或兼容变化;
敏感信息脱敏,未合入功能不宣称已发布。已有准确说明仅需翻译时保持其事实和范围。
6. 按贡献规范检查变更与现有 CI 证据,明确通过、失败、跳过和未执行。
只修改说明时无需重建前后端;正式发版仍要跟踪发布工作流针对目标提交的检查与产物。
## 执行正式发版
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
当前脚本要求先提交 `release/notes.md`,再由脚本更新 `release/version.yaml` 并创建版本提交,
不要提前把版本文件改成目标版本,否则脚本会判定该版本已设置。
本任务需要的提交按贡献文档保留英文 Conventional Commits 与相应署名。
在准备完成且用户已授权正式发布后,将 `release_version` 设为已确定的版本,执行:
```bash
release/publish "$release_version"
```
该命令会创建版本提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
## 恢复与验收
- 脚本中断或网络超时时,先核对本地提交与 tag、远端分支与 tag、对应工作流 run 及 Release,确定已完成到哪一步。
若本地已创建版本提交或 tag,不直接重跑入口,也不通过删 tag、回退版本或强推来消除报错。
- 提交和 tag 已推送但工作流未触发时,核对目标后使用脚本提示的 dispatch 命令补触发;
工作流已存在则跟踪或恢复对应 run,避免重复发布。发现 tag 指向不同提交时报告冲突,不移动已发布 tag。
- 跟踪与本次 tag 对应的 run ID,核对工作流解析的目标提交。触发工作流成功只表示已开始,不能当作发布完成。
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
Release 和镜像已生成也不表示用户的运行实例已经升级。
@@ -0,0 +1,4 @@
interface:
display_name: "release"
short_description: "按仓库流程整理中文发布说明、核对版本并完成发行与验证"
default_prompt: "使用 $release 准备下一个版本的中文发布说明,并核对发版条件。"
+111
View File
@@ -0,0 +1,111 @@
name: 问题反馈
description: 提交可复现的问题,帮助定位和修复
body:
- type: markdown
attributes:
value: |
请提供最小复现信息。请求、日志和截图发布前请移除密钥、令牌、Cookie、代理认证及私人内容。
- type: dropdown
id: issue-type
attributes:
label: 问题类型
options:
- 接口请求或响应异常
- 页面或交互异常
- 配置或部署异常
- 其他问题
validations:
required: true
- type: textarea
id: description
attributes:
label: 问题描述
description: 说明发生了什么、实际结果以及出现的频率。
validations:
required: true
- type: input
id: version
attributes:
label: 服务版本
description: 填写具体版本号;从源码运行时填写提交号。
placeholder: 版本号或提交号
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: 部署方式
options:
- Docker Compose
- 其他容器部署
- 二进制运行
- 源码运行
- 其他方式(请在环境信息中说明)
validations:
required: true
- type: textarea
id: environment
attributes:
label: 环境信息
description: 填写操作系统与版本;页面问题同时填写浏览器与版本。
placeholder: |
操作系统与版本:
浏览器与版本(页面问题):
validations:
required: true
- type: textarea
id: account-client
attributes:
label: 账号与客户端
description: 接口问题请填写平台、认证类型、模型和客户端信息,不要提供账号凭据。
placeholder: |
平台与认证类型:
模型名称:
客户端名称与版本:
- type: textarea
id: reproduction
attributes:
label: 复现步骤
description: 描述触发问题的操作;无法稳定复现时,说明已知触发条件。
placeholder: |
1. 使用什么配置或输入
2. 执行什么操作
3. 出现什么结果
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: 预期行为
description: 说明原本期望得到的结果。
validations:
required: true
- type: textarea
id: request-response
attributes:
label: 请求与响应
description: |
接口问题请提供脱敏后的 cURL 或最小请求体、HTTP 状态码、响应片段,以及请求 ID / 发生时间。
如需详细报文,可按部署文档开启 `host.logging.request_dump`,仅摘取定位所需片段并脱敏,不要直接上传完整转储。
render: text
- type: textarea
id: screenshots
attributes:
label: 截图或录屏
description: 页面或交互问题可在此拖入相关截图、录屏,并说明对应的操作。
- type: textarea
id: additional-context
attributes:
label: 补充信息
description: 最近的升级或配置变更;网络问题补充系统代理、TUN 和自定义证书等相关环境。
+75
View File
@@ -0,0 +1,75 @@
name: Agent Instructions Guard
on:
pull_request_target:
types:
- opened
- synchronize
- reopened
- edited
permissions:
contents: read
pull-requests: write
concurrency:
group: agents-md-guard-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
guard:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# 只查询 PR 元数据,禁止检出或执行贡献分支中的代码。
- name: Enforce agent instructions policy
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
const params = { owner, repo, pull_number: pr.number };
const files = await github.paginate(github.rest.pulls.listFiles, {
...params,
per_page: 100,
});
const isInstructions = path => typeof path === 'string'
&& (path === 'AGENTS.md' || path.endsWith('/AGENTS.md'));
// 同时检查重命名前后的路径,避免通过移走规则文件绕过检查。
if (!files.some(file => isInstructions(file.filename) || isInstructions(file.previous_filename))) {
core.info('No agent instruction changes detected.');
return;
}
// 旧事件不处理后续提交,也不重复处理已关闭的 PR。
const { data: current } = await github.rest.pulls.get(params);
if (current.state !== 'open' || current.head.sha !== pr.head.sha) {
core.info('Pull request state has changed; skipping this event.');
return;
}
const marker = '<!-- agents-md-guard -->';
const body = [
marker,
'此 PR 修改了 `AGENTS.md`,已按贡献规则关闭。',
'',
'PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或重命名。',
'请撤销相关变更后重新打开 PR;如需调整协作规则,请先提交 Issue 说明原因和建议,由维护者处理。',
].join('\n');
try {
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pr.number, per_page: 100,
});
const notified = comments.some(comment => comment.user?.login === 'github-actions[bot]'
&& comment.body?.includes(marker));
if (!notified) {
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
}
} catch (error) {
core.warning(`Unable to post policy explanation: ${error.message}`);
}
await github.rest.pulls.update({ ...params, state: 'closed' });
core.setFailed('Pull requests must not change AGENTS.md.');
-1
View File
@@ -33,7 +33,6 @@ Thumbs.db
# Local tools and worktrees
/.codegraph/
/.codex-ws-audit/
/.agents/
/.reasonix/
/.claude
/.worktrees/
+45
View File
@@ -0,0 +1,45 @@
# 项目协作约定
开发和审查先阅读 [CONTRIBUTING.md](CONTRIBUTING.md),使用其中的协作流程和审查标准。
模块职责以 [系统架构](docs/architecture.md) 为准,界面约定以 [管理端主题](docs/theme.md) 为准;
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则。
<!-- CODEGRAPH_START -->
## CodeGraph
In repositories indexed by CodeGraph (a `.codegraph/` directory exists at the repo root), reach for it BEFORE grep/find or reading files when you need to understand or locate code:
- **MCP tools** (when available): `codegraph_explore` answers most code questions in one call — the relevant symbols' verbatim source plus the call paths between them. `codegraph_node` returns one symbol's source + callers, or reads a whole file with line numbers. If the tools are listed but deferred, load them by name via tool search.
- **Shell** (always works): `codegraph explore "<symbol names or question>"` and `codegraph node <symbol-or-file>` print the same output.
If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is the user's decision.
<!-- CODEGRAPH_END -->
## 工作方式
- 先定位变更所属模块、同类实现和上下游调用关系,再选择最小合理改动。复用已有能力,也避免为尚不存在的需求增加抽象。
- 代码注释使用中文,解释原因与边界;提交信息使用英文,沿用历史中的 Conventional Commits 格式。
- 按变更范围执行验证,记录命令、结果和缺口。跳过的测试不算通过;界面与集成行为需要对应运行证据,构建通过不能替代实际验收。
- 审查请求默认只读。修复、提交、推送和合并按用户当前授权执行;“本地验证通过”不等于用户已经审阅批准。
- 不读取或输出无关凭据,不把真实密钥、代理认证、账号令牌或请求转储放入提交、截图及审查报告。
## Code Review Rules
### 审查范围
- 确认目标分支与当前变更范围,结合调用方、被调用方及已有测试审查;结论对应实际审查的提交或工作区状态。
- 按 CONTRIBUTING.md 的维度检查设计、正确性、安全、复杂度、性能、可读性、验证、界面和文档,深度与变更风险相称。
- 重点核对现有架构和数据合同:共享业务事实是否重复解释,异步与并发操作是否破坏状态,权限和敏感数据是否越界,协议适配是否影响兼容性。
- 修改既有特殊分支时先查场景和历史;放宽检查、修改测试或新增例外需要业务依据,不能仅以让检查通过为理由。
### 代码与界面一致性
- 对重复逻辑、冗余状态和多余抽象给出具体位置及影响;建议复用时指出已有实现,不能仅凭行数或语法相似判定质量。
- UI 变更对照项目基础组件、主题和同类页面,检查交互、状态、响应式与可访问性。局部问题是否应在共用组件修复,按职责判断,不一律禁止局部样式。
- 核对截图、预览和实际交互证据;未检查的状态明确列为验证缺口,不推断视觉验收已经通过。
### 审查输出
- 用中文报告本次变更新引入或加重的、可定位的问题,包含文件位置、触发场景或规范依据、影响和最小修复方向。
- 区分必须修复的问题、可选建议和验证缺口。个人偏好标为建议,不作为必须修复项;没有问题时不要凑数。
- 严重程度按实际影响判断,不为绕过工具的优先级过滤而升级普通规范问题。说明审查和验证的实际覆盖范围,AI 未报告问题不等于维护者批准。
+114
View File
@@ -0,0 +1,114 @@
# 贡献与审查
贡献应解决明确的问题,并保持或改善项目的可维护性。提交者、自动检查和审查者使用同一套标准,让重复问题在提交和初审阶段得到处理。
本文定义协作流程与审查标准;模块职责以 [系统架构](docs/architecture.md) 为准,视觉规则以 [管理端主题](docs/theme.md) 为准。没有明确约定时遵循相邻实现;具体页面的布局决定不自动成为全项目限制。
## 提交 Issue
问题反馈使用仓库的 [Issue 表单](.github/ISSUE_TEMPLATE/bug_report.yml),填写问题类型、描述、版本与环境、复现步骤和预期结果。接口问题补充平台与认证类型、模型、客户端,以及脱敏后的请求、响应和请求 ID;页面问题附相关截图或录屏。
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议与协作规则调整可直接提交 Issue,说明场景、原因和建议方案。
## PR 流程
1. **准备变更。** 找到问题所属模块、相关调用链和最接近的现有实现,在功能分支完成修改与自查。涉及架构、公开合同或较大交互变化时,先说明方案与取舍。
2. **发起 PR。** 目标分支使用 `main`。说明触发场景、改动后的行为和实际验证结果;界面改动提供截图、录屏或可复现的预览方式。一个 PR 围绕一个明确目标,必要重构说明关联原因。
3. **自动检查与初审。** CI 执行适用的格式、静态分析、测试和构建检查;启用 AI 审查后,由它按本规范辅助检查语义、复用和一致性。提交者先处理明确问题,维护者重点审阅方案、风险与用户体验。
4. **修改与合并。** 更新代码后重新执行受影响的检查,确认审查意见已处理,验证证据对应当前变更。维护者审阅批准后再合并;需要升级说明的变更同步相关文档。
AI 开发和审查入口是根目录的 [AGENTS.md](AGENTS.md)。在仓库上下文中要求“审查 PR #编号”时,应按下列标准执行,无需再次列举要求。
### 项目技能
仓库技能位于 `.agents/skills/`,随代码共享。技能提供执行流程,协作与审查标准仍以本文和 `AGENTS.md` 为准。
| 技能 | 用途 | 调用示例 |
| --- | --- | --- |
| [dev-guide](.agents/skills/dev-guide/SKILL.md) | 项目开发与文档导航 | `$dev-guide 修复当前问题` |
| [github-pr](.agents/skills/github-pr/SKILL.md) | 创建或审查 PR | `$github-pr 创建当前分支的 PR` / `$github-pr 审查 PR #编号` |
| [github-issue](.agents/skills/github-issue/SKILL.md) | 整理或提交 Issue | `$github-issue 提交当前问题` |
| [release](.agents/skills/release/SKILL.md) | 准备发布说明或正式发版 | `$release 整理本次发布说明` / `$release 发布 vX.Y.Z` |
审查请求默认在当前会话报告;明确要求创建 PR、提交 Issue 或正式发版时才执行对应远程操作。技能安装不等于启用 GitHub 自动审查。
### 规则文件保护
PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或重命名。调整这些文件时先提交 Issue 说明原因和建议,由维护者处理;[规则保护工作流](.github/workflows/agents-md-guard.yml) 会提示并关闭包含此类变更的 PR。撤销相关变更后可以重新打开 PR,继续正常检查。
源码变更遵守现有架构边界与迁移冻结规则,由对应 CI 检查执行;不额外设立整块业务目录的 PR 禁改清单。构建、测试和安全扫描复用现有工作流。
## 审查标准
按改动涉及的范围选择检查深度,无需为每个 PR 填写所有维度的报告。
| 维度 | 检查重点 |
| --- | --- |
| 设计与架构 | 职责归属、依赖方向、公开接口是否合理;是否融入已有体系,有无绕过边界的旁路 |
| 正确性与兼容性 | 正常、异常和边界输入;并发、取消、重试与副作用;是否改变既有 API、协议或数据合同 |
| 安全与数据 | 权限、输入校验、敏感信息、状态隔离和持久化一致性;日志与诊断是否符合脱敏边界 |
| 简洁性与复用 | 重复业务规则、冗余状态、无效代码和过早抽象;能否用更直接的实现解决当前问题 |
| 性能与资源 | 实际数据规模下的查询、请求、内存与连接成本;资源释放、背压和异常时的生命周期 |
| 可读性与风格 | 命名是否准确,控制流是否易懂,格式是否一致;注释解释约束与原因而非复述代码 |
| 测试与验证 | 是否覆盖真实触发条件和结果,能否发现回归;证据是否对应本次变更,有无跳过或缺失项 |
| 界面与交互 | 共用组件和主题、信息层级、状态反馈、响应式、键盘操作及可访问性;是否有实际页面证据 |
| 文档与升级 | 行为、配置、API 和部署说明是否同步;数据迁移和兼容影响是否交代清楚 |
审查意见区分三类:
- **必须修复:** 有具体触发条件的缺陷、违反现有合同或明确项目规范的问题。说明位置、依据、影响和最小修复方向。
- **建议:** 不影响合同或规范的替代实现、命名偏好和局部优化。个人偏好不能单独阻止合并,也不应强求与本次目标无关的重构。
- **验证缺口:** 尚未检查的行为、状态或运行环境。说明缺少什么证据,由维护者评估是否需要补齐,不能把未验证当作已通过或已发现缺陷。
复杂度不按行数判断,复用不只看语法相似。建议合并重复逻辑时应指出共享的业务含义;建议保留特殊分支时应能说明它服务的场景。例外应有依据,不能仅为绕过检查而放宽规则。
## 项目约定
- Rust 模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置。
- Vue 使用 TypeScript、`<script setup>` 和现有 ESLint 配置;API 请求通过已有模块及 `request` 封装,沿用逐行请求配置格式。
- 业务数据的解析、校验和展示优先复用已有逻辑。新增抽象放在拥有该职责的模块,避免重复事实源或无实际需求的通用层。
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并;确由 Codex 参与的提交保留 `Co-authored-by: Codex <noreply@openai.com>`。
- 发布说明写入 `release/notes.md`,标题首行为目标版本的 `# vX.Y.Z`,章节标题和正文使用中文;命令、配置键、协议名等保留原文。只说明本次版本的实际变化,按影响交代升级事项。
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理。行为或合同变更同步到架构文档指定的文档位置,发布说明按维护者的发布安排更新。
## 界面验证
开发前查找同类页面及 `components/base` 中的基础组件,优先使用已有主题 Token、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。
提交者在浏览器中验证受影响路径,提供前后截图、录屏或预览步骤,并标明实际覆盖的状态。按变更选择浅色/深色、窄窗口、空/加载/错误状态、大量数据及键盘操作;共用主题或基础组件变更按主题文档扩大验证范围。模拟数据与预览材料不包含真实凭据。
## 验证
选择与变更相关的检查,纯文档修改无需重建前后端。行为修复补充能覆盖触发条件和结果的测试,不增加仅复述实现的断言。
| 范围 | 现有检查 | 仍需提供的证据 |
| --- | --- | --- |
| Rust | Rustfmt、严格 Clippy、后端测试、架构边界检查 | 相关失败场景和集成行为 |
| 前端 | ESLint、类型检查与构建 | 页面一致性、交互和实际数据状态 |
| 数据库迁移 | 冻结清单校验与 PR 基线比对 | 升级行为及兼容说明 |
| 依赖安全 | Cargo Audit、pnpm Audit | 本次变更的权限、数据与信任边界 |
| 工作流与容器 | Actionlint、相关容器检查 | 改动涉及的运行行为 |
前端从仓库根目录执行:
```bash
pnpm --dir frontend format:check
pnpm --dir frontend build
git diff --check
```
`build` 已包含类型检查;按当前架构约定,不维护独立前端测试代码。后端的工具链、命令与线程栈设置统一见 [修改与验收](docs/architecture.md#12-修改与验收)。
依赖 PostgreSQL / Redis 的测试使用 [专用测试环境](backend/migrations/README.md#本地测试库),未配置导致跳过时明确记录。CI 中已有对应服务和检查,见 [质量工作流](.github/workflows/_quality.yml) 与 [安全扫描](.github/workflows/security-scan.yml)。
集成变更应验证实际业务路径及结果,不能只凭健康检查或探测成功认定全链路正常。协议和网络改动按需核对认证、证书、代理、流式交付及客户端结果;记录影响复现的环境条件。缺少运行条件时说明缺口,不用模拟结果代替真实集成结论。
## 自动检查与合并条件
[CI](.github/workflows/ci.yml) 已按变更范围调用现有质量检查。新增自动规则优先使用现有工具,要求依据明确、结果稳定、误报可控;无法可靠判定的设计与视觉问题保留审查判断,避免把具体源码写法锁成测试。
AI 自动审查需要在所用工具中单独启用。其支持的检查范围和优先级过滤以工具说明为准;即使能读取 `AGENTS.md`,也不能保证所有可维护性和视觉问题都会被报告。配置参考 [Codex GitHub 审查文档](https://learn.chatgpt.com/docs/third-party/github)。
要由 GitHub 强制执行合并条件,维护者需配置对应分支的必需状态检查、审阅批准和讨论解决要求,并按需要设置新提交后的重新审阅。配置前核对实际检查名称与触发范围,见 [GitHub 分支保护](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches)。规则文档本身不会启用 AI 服务或更改分支设置。
+1
View File
@@ -123,6 +123,7 @@ curl http://127.0.0.1:8080/v1/models \
- [系统架构](docs/architecture.md)
- [管理端主题](docs/theme.md)
- [数据库迁移](backend/migrations/README.md)
- [贡献与审查](CONTRIBUTING.md)
## 社区
+2
View File
@@ -1458,6 +1458,7 @@ dependencies = [
"base64 0.23.1",
"bytes",
"chrono",
"flate2",
"futures",
"gateway-admin",
"gateway-core",
@@ -1473,6 +1474,7 @@ dependencies = [
"tracing",
"url",
"uuid",
"zstd",
]
[[package]]
@@ -771,6 +771,13 @@ pub struct ProviderQuotaWindow {
pub provider_data: Option<ProviderDocument>,
}
/// 账号用量统计周期,按周优先、月次之选择;短期限流窗口不参与统计面板。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum AccountUsagePeriod {
Weekly,
Monthly,
}
/// Provider 解释 quota 所需的公共请求事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderQuotaRequest {
@@ -921,23 +928,34 @@ impl ProviderQuota {
}
}
/// 返回账号展开面板使用的当前额度窗口用量。
/// 选择账号用量统计的周/月窗口,同时返回展示所需的周期事实。
///
/// Provider 已提供账号级本地用量时,固定窗口和无固定重置点的滚动窗口
/// 都可作为代表用量;优先级与 Dashboard 代表性额度窗口一致,
/// 同一优先级保持 Provider 投影顺序。
/// 只使用边界完整、可归属到整个账号的窗口;周优先于月,同周期保持
/// Provider 投影顺序。没有符合条件的窗口时不回退到短期或历史累计。
#[must_use]
pub fn representative_window_usage(&self) -> Option<&AccountUsage> {
pub fn usage_window(&self) -> Option<(&ProviderQuotaWindow, AccountUsagePeriod)> {
self.windows
.iter()
.enumerate()
.filter(|(_, window)| {
window.local_usage_attribution == QuotaLocalUsageAttribution::AccountWide
&& window.window_seconds.is_some()
&& window.local_usage.is_some()
.filter_map(|(index, window)| {
if window.local_usage_attribution != QuotaLocalUsageAttribution::AccountWide
|| window.reset_at.is_none()
|| window.local_usage.is_none()
{
return None;
}
let seconds = window.window_seconds?;
let period = if is_week_window(seconds) {
AccountUsagePeriod::Weekly
} else if window.group == "monthly" && seconds >= 7 * 24 * 60 * 60 {
AccountUsagePeriod::Monthly
} else {
return None;
};
Some((index, window, period))
})
.min_by_key(|(index, window)| (quota_usage_priority(window), *index))
.and_then(|(_, window)| window.local_usage.as_ref())
.min_by_key(|(index, _, period)| (*period, *index))
.map(|(_, window, period)| (window, period))
}
/// 返回 Dashboard 使用的代表性额度比例。
@@ -279,7 +279,9 @@ impl DefaultAccountsService {
std::slice::from_mut(&mut quota),
)
.await?;
let usage = quota.representative_window_usage().cloned();
let usage = quota
.usage_window()
.and_then(|(window, _)| window.local_usage.clone());
Ok(AccountDirectoryItem {
plan_type_display: self.providers.resolve_account_plan(
stored.account.provider_kind.as_str(),
@@ -372,7 +374,9 @@ impl AccountsService for DefaultAccountsService {
.into_iter()
.zip(quotas)
.map(|(mut item, quota)| {
let usage = quota.representative_window_usage().cloned();
let usage = quota
.usage_window()
.and_then(|(window, _)| window.local_usage.clone());
AccountDirectoryItem {
plan_type_display: self.providers.resolve_account_plan(
item.account.provider_kind.as_str(),
+3 -3
View File
@@ -157,7 +157,7 @@ fn representative_quota_should_prefer_account_wide_window_over_model_specific_wi
}
#[test]
fn representative_window_usage_should_accept_account_wide_rolling_window_without_reset() {
fn usage_window_should_not_use_daily_rolling_usage_for_weekly_statistics() {
let usage = AccountUsage {
account_id: "acct_xai".to_owned(),
request_count: 5,
@@ -194,14 +194,14 @@ fn representative_window_usage_should_accept_account_wide_rolling_window_without
used_percent: None,
reset_at: None,
limit_reached: false,
local_usage: Some(usage.clone()),
local_usage: Some(usage),
provider_data: None,
}],
limit_reached: false,
provider_data: None,
};
assert_eq!(quota.representative_window_usage(), Some(&usage));
assert_eq!(quota.usage_window(), None);
}
#[test]
@@ -1,12 +1,135 @@
use gateway_admin::model::provider_credentials::{
AuthorizationMutationTarget, AuthorizationOwnerBinding, PendingAuthorizationMutation,
ProviderQuota, ProviderQuotaWindow, ProviderQuotaWindowRole, QuotaLocalUsageAttribution,
AccountUsagePeriod, AuthorizationMutationTarget, AuthorizationOwnerBinding,
PendingAuthorizationMutation, ProviderQuota, ProviderQuotaWindow, ProviderQuotaWindowRole,
QuotaLocalUsageAttribution,
};
use gateway_admin::model::{MutationActor, MutationContext};
use gateway_core::account::ProviderAccountId;
use gateway_core::routing::ProviderKind;
use serde_json::{Value, json};
#[test]
fn usage_window_should_prefer_weekly_without_changing_dashboard_selection() {
let quota = usage_quota(vec![
usage_window("short", "shortTerm", 18_000),
usage_window("month", "monthly", 30 * 86_400),
usage_window("week", "shortTerm", 7 * 86_400),
]);
assert_eq!(
selected_usage_window(&quota),
Some(("week", AccountUsagePeriod::Weekly))
);
assert_eq!(
quota
.representative_window()
.map(|window| window.key.as_str()),
Some("short")
);
}
#[test]
fn usage_window_should_use_monthly_when_weekly_is_unavailable() {
let mut model_week = usage_window("model-week", "shortTerm", 7 * 86_400);
model_week.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
let mut incomplete_week = usage_window("incomplete-week", "shortTerm", 7 * 86_400);
incomplete_week.reset_at = None;
let mut unknown_usage = usage_window("unknown-week", "shortTerm", 7 * 86_400);
unknown_usage.local_usage = None;
let quota = usage_quota(vec![
model_week,
incomplete_week,
unknown_usage,
usage_window("month", "monthly", 30 * 86_400),
]);
assert_eq!(
selected_usage_window(&quota),
Some(("month", AccountUsagePeriod::Monthly))
);
}
#[test]
fn usage_window_should_not_fallback_to_short_daily_or_incomplete_windows() {
let mut missing_duration = usage_window("unknown-month", "monthly", 30 * 86_400);
missing_duration.window_seconds = None;
let quota = usage_quota(vec![
usage_window("short", "shortTerm", 18_000),
usage_window("day", "shortTerm", 86_400),
usage_window("invalid-month", "monthly", 86_400),
missing_duration,
]);
assert_eq!(quota.usage_window(), None);
}
#[test]
fn usage_window_should_preserve_provider_order_for_equal_periods() {
let quota = usage_quota(vec![
usage_window("first-week", "shortTerm", 7 * 86_400),
usage_window("second-week", "shortTerm", 7 * 86_400),
]);
assert_eq!(
selected_usage_window(&quota),
Some(("first-week", AccountUsagePeriod::Weekly))
);
}
fn selected_usage_window(quota: &ProviderQuota) -> Option<(&str, AccountUsagePeriod)> {
quota
.usage_window()
.map(|(window, period)| (window.key.as_str(), period))
}
fn usage_quota(windows: Vec<ProviderQuotaWindow>) -> ProviderQuota {
ProviderQuota {
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
windows,
limit_reached: false,
provider_data: None,
}
}
fn usage_window(key: &str, group: &str, seconds: u64) -> ProviderQuotaWindow {
ProviderQuotaWindow {
key: key.to_owned(),
group: group.to_owned(),
label: key.to_owned(),
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(seconds),
used_percent: Some(50.0),
reset_at: Some(chrono::Utc::now()),
limit_reached: false,
local_usage: Some(gateway_admin::model::accounts::AccountUsage {
account_id: "acct_test".to_owned(),
request_count: 0,
success_count: 0,
input_tokens: None,
output_tokens: None,
cached_tokens: None,
cache_write_tokens: None,
reasoning_tokens: None,
image_input_tokens: None,
image_output_tokens: None,
image_request_count: 0,
image_request_failed_count: 0,
total_tokens: None,
cost_coverage: Default::default(),
costs: Vec::new(),
last_used_at: None,
request_buckets: Vec::new(),
models: Vec::new(),
}),
provider_data: None,
}
}
#[test]
fn dashboard_quota_should_preserve_unknown_and_actual_window_facts() {
let mut quota = ProviderQuota {
@@ -1754,10 +1754,8 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
.as_ref()
.expect("quota window local usage");
assert_eq!(usage.total_tokens, Some(4_330_000));
assert_eq!(
item.usage.as_ref().and_then(|usage| usage.total_tokens),
Some(4_330_000),
);
// 短期额度条保留自己的本地用量,但不作为周/月统计面板的回退。
assert!(item.usage.is_none());
let queries = store.quota_window_queries();
assert_eq!(queries.len(), 1);
@@ -1767,6 +1765,98 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
assert_eq!(queries[0].range.end, reset_at);
}
#[tokio::test]
async fn accounts_list_and_quota_refresh_should_select_the_same_weekly_or_monthly_usage() {
let provider = FakeProviderAdmin::new("openai", events());
let store = FakeAccountStore::new("openai", events());
let services = accounts_service(provider.clone(), store.clone()).await;
let account_id = ProviderAccountId::new("acct_test").expect("account id");
let reset_at = Utc::now() + TimeDelta::days(1);
let short = ProviderQuotaWindow {
key: "short".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(18_000),
used_percent: Some(50.0),
reset_at: Some(reset_at),
limit_reached: false,
local_usage: None,
provider_data: None,
};
let week = ProviderQuotaWindow {
key: "week".to_owned(),
label: "周额度".to_owned(),
window_seconds: Some(7 * 86_400),
..short.clone()
};
let month = ProviderQuotaWindow {
key: "month".to_owned(),
group: "monthly".to_owned(),
label: "月额度".to_owned(),
window_seconds: Some(30 * 86_400),
..short.clone()
};
for (windows, selected_key, tokens, duration) in [
(vec![short.clone(), month.clone(), week], "week", 700, 7),
(vec![short, month], "month", 3000, 30),
] {
provider.set_quota(ProviderQuota {
windows,
..empty_quota()
});
store.set_quota_window_usage(
[("short", 50), ("week", 700), ("month", 3000)]
.into_iter()
.map(|(key, total)| AccountUsageWindowResult {
account_id: account_id.to_string(),
key: key.to_owned(),
usage: quota_local_usage(account_id.as_str(), total),
})
.collect(),
);
let page = services
.accounts()
.list(AccountListQuery {
page: 1,
page_size: gateway_admin::model::PageSize::new(20).expect("page size"),
provider_kind: None,
group_filter: None,
search: None,
status: None,
sort: None,
})
.await
.expect("list accounts");
let refreshed = services
.accounts()
.quota(&account_id, true)
.await
.expect("refresh quota");
let expected = quota_local_usage(account_id.as_str(), tokens);
for item in [&page.items[0], &refreshed] {
let usage = item.usage.as_ref().expect("selected window usage");
assert_eq!(usage.total_tokens, expected.total_tokens);
assert_eq!(
item.quota
.usage_window()
.map(|(window, _)| window.key.as_str()),
Some(selected_key)
);
}
let queries = store.quota_window_queries();
let selected = queries
.iter()
.find(|query| query.key == selected_key)
.expect("selected query");
assert_eq!(selected.range.start, reset_at - TimeDelta::days(duration));
assert_eq!(selected.range.end, reset_at);
}
}
#[tokio::test]
async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_windows() {
let provider = FakeProviderAdmin::new("openai", events());
+2
View File
@@ -16,6 +16,7 @@ axum = { version = "0.8.9", default-features = false, features = ["json", "query
base64 = "0.23.0"
bytes = "1.11.1"
chrono = { version = "0.4.45", features = ["serde"] }
flate2 = "1.1.9"
futures = "0.3.32"
gateway-admin = { path = "../gateway-admin" }
gateway-core = { path = "../gateway-core" }
@@ -29,6 +30,7 @@ tower-http = { version = "0.7.0", features = ["cors", "fs", "request-id", "timeo
tracing = "0.1.44"
url = "2.5.8"
uuid = { version = "1.23.3", features = ["v7"] }
zstd = "=0.13.3"
[dev-dependencies]
async-trait = "0.1.89"
@@ -26,13 +26,13 @@ use gateway_admin::model::{
},
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
CredentialDeletion, CredentialDeletionResult, CredentialImportResult, CredentialMutation,
CredentialMutationResult, ImportCredentials, ProviderDocument, ProviderModels,
ProviderProfileActivityInsights, ProviderProfileAvatar, ProviderProfileDailyUsage,
ProviderProfileInvocation, ProviderProfileStatistics, ProviderProfileStatisticsSummary,
ProviderQuota, ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult,
ProviderResetCredits, RotateCredential, StartAuthorization,
AccountUsagePeriod, AuthorizationStarted, CompleteAuthorization,
ConsumeProviderResetCredit, CredentialDeletion, CredentialDeletionResult,
CredentialImportResult, CredentialMutation, CredentialMutationResult, ImportCredentials,
ProviderDocument, ProviderModels, ProviderProfileActivityInsights, ProviderProfileAvatar,
ProviderProfileDailyUsage, ProviderProfileInvocation, ProviderProfileStatistics,
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaWindow, ProviderResetCredit,
ProviderResetCreditResult, ProviderResetCredits, RotateCredential, StartAuthorization,
},
};
use gateway_core::{
@@ -69,6 +69,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
let expires_at = account.access_token_expires_at.as_ref().map(china_rfc3339);
let added_at = china_rfc3339(&account.created_at);
let updated_at = china_rfc3339(&account.updated_at);
let usage_period = quota.usage_window().map(|(_, period)| period);
let (quota, refresh_token_expires_at) = account_quota_view(quota, rate_limited_until, now);
AccountView {
id: account.id.clone(),
@@ -117,7 +118,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
updated_at,
updated_at_display: china_datetime(&account.updated_at),
quota,
usage: account_usage_view(usage, now),
usage: account_usage_view(usage, usage_period, now),
}
}
@@ -211,6 +212,7 @@ pub(super) fn quota_local_usage(usage: &AccountUsage) -> Value {
pub(super) fn account_usage_view(
usage: Option<AccountUsage>,
period: Option<AccountUsagePeriod>,
now: DateTime<Utc>,
) -> AccountUsageView {
let Some(usage) = usage else {
@@ -228,6 +230,12 @@ pub(super) fn account_usage_view(
"known"
};
AccountUsageView {
window_label_display: match period {
Some(AccountUsagePeriod::Weekly) => "周额度窗口",
Some(AccountUsagePeriod::Monthly) => "月额度窗口",
None => "周/月额度窗口",
}
.to_owned(),
request_count: Some(usage.request_count),
request_count_display: format_number(usage.request_count),
input_tokens: usage.input_tokens,
@@ -347,6 +355,7 @@ pub(super) fn display_optional_tokens(value: Option<u64>) -> String {
pub(super) fn empty_account_usage() -> AccountUsageView {
AccountUsageView {
window_label_display: "周/月额度窗口".to_owned(),
request_count: None,
request_count_display: "—".to_owned(),
input_tokens: None,
@@ -316,6 +316,7 @@ pub struct AccountQuotaWindowView {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageView {
pub window_label_display: String,
pub request_count: Option<u64>,
pub request_count_display: String,
pub input_tokens: Option<u64>,
@@ -40,6 +40,15 @@ pub enum RequestDecodeError {
/// 请求不是合法 JSON。
#[error("request body must be valid JSON")]
MalformedJson,
/// 请求体解压后超过允许上限。
#[error("decompressed request body is too large")]
DecompressedBodyTooLarge,
/// 请求使用了网关不支持的 `Content-Encoding`。
#[error("request content-encoding `{encoding}` is not supported")]
UnsupportedContentEncoding {
/// Content-Encoding 标识。
encoding: String,
},
/// 顶层不是 object。
#[error("request body must be a JSON object")]
ExpectedObject,
@@ -99,6 +108,16 @@ impl RequestDecodeError {
"Request body must be valid JSON.".to_owned(),
None,
),
Self::DecompressedBodyTooLarge => (
"request_too_large",
"Decompressed request body exceeds the allowed size.".to_owned(),
None,
),
Self::UnsupportedContentEncoding { encoding } => (
"unsupported_content_encoding",
format!("Content-Encoding `{encoding}` is not supported."),
None,
),
Self::ExpectedObject => (
"invalid_request_body",
"Request body must be a JSON object.".to_owned(),
@@ -1,12 +1,13 @@
//! OpenAI Responses JSON 到 Core 路由事实与不透明 wire payload 的单一解码边界。
use std::{fmt, net::IpAddr};
use std::{borrow::Cow, fmt, net::IpAddr};
use axum::http::HeaderMap;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload, ProviderSessionState};
use gateway_protocol::openai::{
X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER, X_OPENAI_MEMGEN_REQUEST_HEADER,
is_transport_managed_request_header,
};
use serde_json::{Map, Value};
@@ -322,12 +323,87 @@ impl fmt::Debug for DecodedResponsesRequest {
///
/// # Errors
///
/// JSON 非法、顶层不是 object,或网关必须解释的路由字段无效时返回安全错误。
/// 编码不支持、压缩正文损坏或超限、JSON 非法,或路由字段无效时返回安全错误。
pub fn decode_request_with_headers(
body: &[u8],
headers: &HeaderMap,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
decode_request_inner(body, &OpenAiRequestHeaders::from_headers(headers))
let body = decompress_request_body(body, headers)?;
decode_request_inner(&body, &OpenAiRequestHeaders::from_headers(headers))
}
/// 下游请求体解压后的最大字节数;防御解压炸弹。
const MAX_DECOMPRESSED_REQUEST_BYTES: usize = 64 * 1024 * 1024;
/// 按 `Content-Encoding` 解压下游请求体。
///
/// 未压缩与 `identity` 借用原始正文;压缩正文在读取过程中限制展开大小,
/// 避免先完整分配再检查。只接受单一编码,重复头和叠加编码不能只解释第一项。
fn decompress_request_body<'a>(
body: &'a [u8],
headers: &HeaderMap,
) -> Result<Cow<'a, [u8]>, RequestDecodeError> {
let encoding = headers
.get_all(axum::http::header::CONTENT_ENCODING)
.iter()
.map(|value| {
value
.to_str()
.map_err(|_| RequestDecodeError::MalformedJson)
})
.collect::<Result<Vec<_>, _>>()?
.join(",");
match encoding.trim().to_ascii_lowercase().as_str() {
"" | "identity" => Ok(Cow::Borrowed(body)),
"gzip" => {
let decoder = flate2::read::MultiGzDecoder::new(body);
read_bounded(decoder).map(Cow::Owned)
}
"deflate" => {
let decoder = flate2::read::ZlibDecoder::new(body);
read_bounded(decoder).map(Cow::Owned)
}
"zstd" => {
let mut decoder = zstd::stream::read::Decoder::with_buffer(body)
.map_err(|_| RequestDecodeError::MalformedJson)?;
// 输出有界之外,也限制压缩帧声明的回溯窗口,防止解码器内部过量分配。
decoder
.window_log_max(MAX_DECOMPRESSED_REQUEST_BYTES.ilog2())
.map_err(|_| RequestDecodeError::MalformedJson)?;
read_bounded(decoder).map(Cow::Owned)
}
other => Err(RequestDecodeError::UnsupportedContentEncoding {
encoding: other.to_owned(),
}),
}
}
fn read_bounded<R: std::io::Read>(mut reader: R) -> Result<Vec<u8>, RequestDecodeError> {
let mut decoded = Vec::new();
let mut chunk = [0; 8192];
loop {
let remaining = MAX_DECOMPRESSED_REQUEST_BYTES - decoded.len();
let read_limit = chunk.len().min(remaining + 1);
let read = reader
.read(&mut chunk[..read_limit])
.map_err(|_| RequestDecodeError::MalformedJson)?;
if read == 0 {
return Ok(decoded);
}
if read > remaining {
return Err(RequestDecodeError::DecompressedBodyTooLarge);
}
// 默认 Vec 扩容和 read_to_end 的 EOF 探测可能突破输出上限;
// 容量增长也受相同边界约束,越界探测只使用栈上分块缓冲区。
let required = decoded.len() + read;
if required > decoded.capacity() {
let capacity = (decoded.capacity() * 2)
.max(required)
.min(MAX_DECOMPRESSED_REQUEST_BYTES);
decoded.reserve_exact(capacity - decoded.len());
}
decoded.extend_from_slice(&chunk[..read]);
}
}
pub(super) fn decode_request_inner(
@@ -458,7 +534,7 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
if connection_headers
.iter()
.any(|connection_header| connection_header.eq_ignore_ascii_case(name))
|| name.starts_with("sec-websocket-")
|| is_transport_managed_request_header(name)
|| name.starts_with("x-grok-")
|| name.starts_with("x-xai-")
{
@@ -467,30 +543,8 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
!matches!(
name,
// 逐跳头、上游 authority 和重序列化后的实体长度由 transport 重建。
"connection"
| "keep-alive"
| "proxy-connection"
| "proxy-authenticate"
| "proxy-authorization"
| "te"
| "trailer"
| "transfer-encoding"
| "upgrade"
| "host"
| "content-length"
// 下游连接诊断事实只用于本地观测,不能伪装成 OpenAI 请求事实。
| "forwarded"
| "x-forwarded-for"
| "x-forwarded-host"
| "x-forwarded-proto"
| "x-forwarded-port"
| "x-real-ip"
| "true-client-ip"
| "cf-connecting-ip"
| "x-request-id"
// 下游鉴权和账号 cookie 绝不能成为上游账号身份。
| "authorization"
// 下游鉴权和账号 cookie 绝不能成为上游账号身份。
"authorization"
| "x-api-key"
// Codex 的服务端托管认证标记只用于客户端能力判断,不代表上游身份。
| "x-openai-actor-authorization"
@@ -340,6 +340,7 @@ mod response {
#[test]
fn account_usage_view_should_keep_unobserved_numbers_null() {
let view = AccountUsageView {
window_label_display: "周/月额度窗口".to_owned(),
request_count: None,
request_count_display: "-".to_owned(),
input_tokens: None,
@@ -374,6 +375,7 @@ mod response {
models: Vec::new(),
};
let value = serde_json::to_value(view).expect("serialize account usage");
assert_eq!(value["windowLabelDisplay"], "周/月额度窗口");
assert!(value["inputTokens"].is_null());
assert!(value["totalTokens"].is_null());
assert!(value["reasoningTokens"].is_null());
@@ -112,6 +112,7 @@ fn test_tree_should_match_frozen_rust_mirror() {
"tests/openai/endpoint.rs",
"tests/openai/responses/http.rs",
"tests/openai/responses/mod.rs",
"tests/openai/responses/request.rs",
"tests/openai/responses/websocket/connection.rs",
"tests/openai/responses/websocket/mod.rs",
"tests/openai/responses/websocket/protocol.rs",
@@ -1,4 +1,5 @@
use std::collections::VecDeque;
use std::io::Read;
use std::net::{IpAddr, SocketAddr};
use std::sync::{
Arc, Mutex,
@@ -11,7 +12,7 @@ use axum::{
extract::connect_info::ConnectInfo,
http::{
HeaderMap, HeaderValue, Request, StatusCode,
header::{AUTHORIZATION, CONTENT_TYPE},
header::{AUTHORIZATION, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE},
},
};
use bytes::Bytes;
@@ -451,9 +452,26 @@ async fn captured_client_context(
async fn captured_http_request(
provider_name: &str,
body: Value,
mut headers: HeaderMap,
headers: HeaderMap,
peer_address: Option<SocketAddr>,
) -> CapturedClientContext {
let (response, observed) = http_request_with_body(
provider_name,
body.to_string().into(),
headers,
peer_address,
)
.await;
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
observed.expect("captured request context")
}
async fn http_request_with_body(
provider_name: &str,
body: Bytes,
mut headers: HeaderMap,
peer_address: Option<SocketAddr>,
) -> (axum::response::Response, Option<CapturedClientContext>) {
headers.insert(
AUTHORIZATION,
"Bearer sk_context_test".parse().expect("authorization"),
@@ -464,7 +482,7 @@ async fn captured_http_request(
client: authenticated_client_for_provider("sk_context_test", provider_name),
});
let mut request = Request::post("/v1/responses")
.body(Body::from(body.to_string()))
.body(Body::from(body))
.expect("context request");
*request.headers_mut() = headers;
if let Some(peer_address) = peer_address {
@@ -475,12 +493,75 @@ async fn captured_http_request(
.oneshot(request)
.await
.expect("context response");
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
observed
.lock()
.expect("context capture lock")
.clone()
.expect("captured request context")
let observed = observed.lock().expect("context capture lock").clone();
(response, observed)
}
#[tokio::test]
async fn compressed_http_requests_should_preserve_execution_context_without_transport_headers() {
let body = json!({
"model": "model-a", "input": "hello", "previous_response_id": "resp_previous",
"prompt_cache_key": "cache-key", "client_metadata": {"source": "test"}
});
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
headers.insert("session_id", HeaderValue::from_static("session-test"));
let expected = captured_http_request("openai", body.clone(), headers.clone(), None).await;
for encoding in ["gzip", "deflate", "zstd"] {
let compressed = super::request::encode_body(encoding, body.to_string().as_bytes());
let mut headers = headers.clone();
headers.insert(CONTENT_ENCODING, HeaderValue::from_static(encoding));
headers.insert(
CONTENT_LENGTH,
HeaderValue::from_str(&compressed.len().to_string()).expect("compressed length"),
);
let (response, observed) =
http_request_with_body("openai", compressed.into(), headers, None).await;
// 捕获执行器在解码成功后主动返回 500;正文和业务头应与未压缩请求一致,
// 压缩编码及长度不能泄漏到已解压正文的上游协议上下文。
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(observed.as_ref(), Some(&expected), "{encoding}");
}
}
#[tokio::test]
async fn invalid_compressed_http_requests_should_fail_before_execution() {
for (encoding, body, code, message) in [
(
"gzip",
Bytes::from_static(b"broken gzip"),
"invalid_json",
"Request body must be valid JSON.",
),
(
"br",
Bytes::from_static(b"{}"),
"unsupported_content_encoding",
"Content-Encoding `br` is not supported.",
),
(
"zstd",
super::request::encode_body("zstd", std::io::repeat(0).take(64 * 1024 * 1024 + 1))
.into(),
"request_too_large",
"Decompressed request body exceeds the allowed size.",
),
] {
let headers = HeaderMap::from_iter([
(CONTENT_ENCODING, HeaderValue::from_static(encoding)),
(CONTENT_TYPE, HeaderValue::from_static("application/json")),
]);
let (response, observed) = http_request_with_body("openai", body, headers, None).await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{encoding}");
assert!(observed.is_none(), "invalid body must not start execution");
let body = to_bytes(response.into_body(), 4096)
.await
.expect("error body");
assert_eq!(
serde_json::from_slice::<Value>(&body).expect("error JSON"),
json!({"error": {"type": "invalid_request_error", "code": code, "message": message}})
);
}
}
async fn captured_http_compaction(provider_name: &str) -> CapturedClientContext {
@@ -1,4 +1,5 @@
mod http;
mod request;
mod websocket;
use axum::http::{HeaderMap, HeaderValue};
@@ -369,6 +370,47 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
}
}
#[test]
fn decoder_should_exclude_downstream_transport_headers_from_opaque_context() {
let mut headers = HeaderMap::new();
for name in [
"cf-visitor",
"cf-connecting-ip",
"cf-connecting-ipv6",
"cf-pseudo-ipv4",
"cf-ray",
"cf-ipcountry",
"cf-warp-tag-id",
"cf-worker",
"cf-ew-via",
"cdn-loop",
"via",
"forwarded",
"x-forwarded-for",
"x-forwarded-prefix",
"accept-encoding",
] {
headers.insert(name, HeaderValue::from_static("downstream-only"));
}
headers.insert(
"CF-Visitor",
HeaderValue::from_static(r#"{"scheme":"https"}"#),
);
headers.insert("accept-encoding", HeaderValue::from_static("br, gzip"));
// 正文未压缩;入口现在消费 Content-Encoding,但仍不得向上游透传。
headers.insert("content-encoding", HeaderValue::from_static("identity"));
headers.insert("x-openai-future-mode", HeaderValue::from_static("keep"));
let decoded =
decode_request_with_headers(br#"{"model":"smart-code","input":"hello"}"#, &headers)
.expect("decode request behind a reverse proxy");
assert_eq!(
openai_protocol_context(&decoded).get("opaque_request_headers"),
Some(&json!([["x-openai-future-mode", STANDARD.encode(b"keep")]])),
);
}
#[test]
fn decoder_should_preserve_unknown_nested_values_without_debug_disclosure() {
let secret = "nested-private-value";
@@ -0,0 +1,174 @@
use std::io::{Read, Write, copy, repeat};
use axum::http::{HeaderMap, HeaderValue, header::CONTENT_ENCODING};
use gateway_api::openai::responses::{RequestDecodeError, decode_request_with_headers};
use serde_json::json;
use super::openai_wire_body;
const ENCODINGS: [&str; 3] = ["gzip", "deflate", "zstd"];
const REQUEST: &[u8] = br#"{"model":"gpt-test","input":"hello","future_field":{"value":1}}"#;
const DECOMPRESSED_LIMIT: u64 = 64 * 1024 * 1024;
pub(super) fn encode_body(encoding: &str, mut data: impl Read) -> Vec<u8> {
match encoding {
"gzip" => {
let mut encoder =
flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
copy(&mut data, &mut encoder).expect("gzip encode");
encoder.finish().expect("gzip finish")
}
"deflate" => {
let mut encoder =
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
copy(&mut data, &mut encoder).expect("deflate encode");
encoder.finish().expect("deflate finish")
}
"zstd" => zstd::stream::encode_all(data, 3).expect("zstd encode"),
_ => panic!("unsupported fixture encoding"),
}
}
fn headers(encoding: &str) -> HeaderMap {
HeaderMap::from_iter([(
CONTENT_ENCODING,
HeaderValue::from_str(encoding).expect("encoding header"),
)])
}
#[test]
fn http_decode_should_preserve_compressed_request_fields() {
let expected = serde_json::from_slice::<serde_json::Value>(REQUEST).expect("fixture JSON");
for encoding in ENCODINGS {
let compressed = encode_body(encoding, REQUEST);
let decoded = decode_request_with_headers(&compressed, &headers(encoding)).expect(encoding);
assert_eq!(
openai_wire_body(&decoded),
expected.as_object().expect("object")
);
}
}
#[test]
fn http_decode_should_accept_plain_identity_and_case_insensitive_encodings() {
for request_headers in [HeaderMap::new(), headers(""), headers(" identity ")] {
assert!(decode_request_with_headers(REQUEST, &request_headers).is_ok());
}
let compressed = encode_body("gzip", REQUEST);
assert!(decode_request_with_headers(&compressed, &headers(" GZip ")).is_ok());
}
#[test]
fn http_decode_should_reject_unknown_and_stacked_encodings() {
let mut repeated = headers("identity");
repeated.append(CONTENT_ENCODING, HeaderValue::from_static("gzip"));
for request_headers in [headers("br"), headers("gzip, zstd"), repeated] {
let error = decode_request_with_headers(REQUEST, &request_headers)
.expect_err("unsupported encoding");
assert!(matches!(
error,
RequestDecodeError::UnsupportedContentEncoding { .. }
));
assert_eq!(
error.protocol_body().into_value()["error"]["code"],
"unsupported_content_encoding"
);
}
}
#[test]
fn http_decode_should_reject_corrupted_and_truncated_compressed_bodies() {
for encoding in ENCODINGS {
let mut truncated = encode_body(encoding, REQUEST);
truncated.truncate(truncated.len() - 4);
for body in [b"not-a-compressed-stream".as_slice(), truncated.as_slice()] {
let error = decode_request_with_headers(body, &headers(encoding)).expect_err(encoding);
assert_eq!(
error.protocol_body().into_value(),
json!({"error": {
"type": "invalid_request_error", "code": "invalid_json",
"message": "Request body must be valid JSON."
}})
);
}
}
}
#[test]
fn http_decode_should_read_all_gzip_members_and_zstd_frames() {
for encoding in ["gzip", "zstd"] {
let mut compressed = encode_body(encoding, &REQUEST[..12]);
compressed.extend(encode_body(encoding, &REQUEST[12..]));
let decoded = decode_request_with_headers(&compressed, &headers(encoding)).expect(encoding);
assert_eq!(openai_wire_body(&decoded)["input"], "hello");
}
}
#[test]
fn http_decode_should_not_ignore_invalid_trailing_members() {
for encoding in ["gzip", "zstd"] {
let mut compressed = encode_body(encoding, REQUEST);
compressed.extend(encode_body(encoding, b"invalid trailing JSON".as_slice()));
assert_eq!(
decode_request_with_headers(&compressed, &headers(encoding)).expect_err(encoding),
RequestDecodeError::MalformedJson
);
}
}
#[test]
fn http_decode_should_reject_zstd_frames_with_an_excessive_window() {
let mut encoder = zstd::stream::write::Encoder::new(Vec::new(), 3).expect("zstd encoder");
encoder.window_log(27).expect("128 MiB window");
encoder.write_all(REQUEST).expect("write JSON");
// 提前输出帧头,避免编码器根据最终小正文缩减窗口。
encoder.flush().expect("flush frame header");
let compressed = encoder.finish().expect("finish frame");
assert_eq!(
zstd::stream::decode_all(compressed.as_slice()).expect("valid zstd frame"),
REQUEST
);
assert_eq!(
decode_request_with_headers(&compressed, &headers("zstd")).expect_err("oversized window"),
RequestDecodeError::MalformedJson
);
}
#[test]
fn http_decode_should_accept_the_exact_decompressed_limit() {
let padding = repeat(b' ').take(DECOMPRESSED_LIMIT - REQUEST.len() as u64);
let compressed = encode_body("zstd", REQUEST.chain(padding));
let decoded = decode_request_with_headers(&compressed, &headers("zstd")).expect("exact limit");
assert_eq!(decoded.metadata().requested_model(), "gpt-test");
}
#[test]
fn http_decode_should_reject_bodies_above_the_decompressed_limit() {
for encoding in ENCODINGS {
let compressed = encode_body(encoding, repeat(0).take(DECOMPRESSED_LIMIT + 1));
let error =
decode_request_with_headers(&compressed, &headers(encoding)).expect_err(encoding);
assert_eq!(
error,
RequestDecodeError::DecompressedBodyTooLarge,
"{encoding}"
);
}
}
#[test]
fn http_decode_should_stop_at_limit_before_reading_invalid_trailing_frames() {
for encoding in ["zstd", "gzip"] {
// 尾部故意损坏:若先完整解压再检查长度,就会错误地返回 invalid_json,
// 这个断言验证越界时已经停止读取,而不只是最终返回了某个超限错误。
let mut compressed = encode_body(encoding, repeat(0).take(DECOMPRESSED_LIMIT + 1));
compressed.extend_from_slice(b"invalid trailing frame");
let error =
decode_request_with_headers(&compressed, &headers(encoding)).expect_err(encoding);
assert_eq!(
error,
RequestDecodeError::DecompressedBodyTooLarge,
"{encoding}"
);
}
}
@@ -1,8 +1,53 @@
use axum::http::{HeaderMap, HeaderValue};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use gateway_api::openai::responses::{OpenAiRequestHeaders, decode_response_create_with_context};
use gateway_core::operation::Operation;
use serde_json::json;
use super::decode_response_create;
#[test]
fn response_create_should_exclude_cloudflare_headers_on_every_frame() {
let mut headers = HeaderMap::new();
for (name, value) in [
("CF-Visitor", r#"{"scheme":"https"}"#),
("cdn-loop", "cloudflare; loops=1"),
("cf-warp-tag-id", "downstream-warp"),
("cf-ipcountry", "US"),
("cf-worker", "worker.example"),
("cf-ew-via", "15"),
("cf-future-proxy-field", "downstream-only"),
] {
headers.insert(name, HeaderValue::from_static(value));
}
headers.append("x-openai-future-mode", HeaderValue::from_static("first"));
headers.append("x-openai-future-mode", HeaderValue::from_static("second"));
let request_headers = OpenAiRequestHeaders::from_headers(&headers);
// 连接级头会用于同一 WebSocket 的每一帧,后续帧也不能恢复下游链路元数据。
for input in ["hello", "continue"] {
let decoded = decode_response_create_with_context(
&json!({"type": "response.create", "model": "smart-code", "input": input}).to_string(),
&request_headers,
)
.expect("decode response.create behind Cloudflare");
let Operation::Generate(request) = decoded.operation() else {
panic!("Responses must map to Generate");
};
assert_eq!(
request
.protocol_payload()
.context()
.get("opaque_request_headers"),
Some(&json!([
["x-openai-future-mode", STANDARD.encode(b"first")],
["x-openai-future-mode", STANDARD.encode(b"second")],
])),
);
assert_eq!(request.protocol_payload().body()["input"], input);
}
}
#[test]
fn latest_official_codex_response_create_fixture_decodes_unchanged() {
// Audited against openai/codex main 94cbbddafc1776d5e377bca1b05932c697e82238.
@@ -0,0 +1,36 @@
//! OpenAI 请求头的业务协议与传输边界。
/// 判断小写请求头是否属于传输层管理的字段,不得作为业务扩展头透传。
///
/// API 入站和 Provider 编码共用此分类;`Connection` 动态声明的逐跳头由入站额外剥离。
/// 只用于请求方向,不影响上游响应中的代理诊断信息。
#[must_use]
pub fn is_transport_managed_request_header(name: &str) -> bool {
// 代理命名空间描述的是下游链路;包括未知扩展也不能冒充上游连接事实。
name.starts_with("cf-")
|| name.starts_with("x-forwarded-")
|| name.starts_with("sec-websocket-")
|| matches!(
name,
"connection"
| "keep-alive"
| "proxy-connection"
| "proxy-authenticate"
| "proxy-authorization"
| "te"
| "trailer"
| "transfer-encoding"
| "upgrade"
| "host"
| "content-length"
// 请求实体与响应压缩能力属于各段 transport,不能继承下游协商。
| "content-encoding"
| "accept-encoding"
| "forwarded"
| "via"
| "cdn-loop"
| "x-real-ip"
| "true-client-ip"
| "x-request-id"
)
}
@@ -13,6 +13,7 @@ pub const X_OPENAI_MEMGEN_REQUEST_HEADER: &str = "x-openai-memgen-request";
pub mod codex;
/// OpenAI/Codex 事件语义、用量与限流字段编解码。
pub mod events;
mod headers;
/// Server-Sent Events 帧的解析与编码。
pub mod sse;
@@ -20,3 +21,4 @@ pub use codex::{
CodexResponsesRequestSemantics, codex_responses_request_semantics,
codex_responses_request_semantics_with_turn_metadata, codex_session_id, codex_thread_id,
};
pub use headers::is_transport_managed_request_header;
@@ -0,0 +1,68 @@
use gateway_protocol::openai::is_transport_managed_request_header;
#[test]
fn transport_headers_should_include_proxy_namespaces_and_compression() {
for name in [
"cf-visitor",
"cf-connecting-ip",
"cf-connecting-ipv6",
"cf-pseudo-ipv4",
"cf-ray",
"cf-ipcountry",
"cf-warp-tag-id",
"cf-worker",
"cf-ew-via",
"cf-future-proxy-field",
"cdn-loop",
"via",
"forwarded",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-proto",
"x-forwarded-port",
"x-forwarded-prefix",
"x-forwarded-future-field",
"x-real-ip",
"true-client-ip",
"x-request-id",
"accept-encoding",
"content-encoding",
"content-length",
"host",
"connection",
"keep-alive",
"proxy-connection",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"sec-websocket-key",
"sec-websocket-extensions",
] {
assert!(is_transport_managed_request_header(name), "missing {name}");
}
}
#[test]
fn transport_headers_should_leave_business_extensions_to_the_protocol_owner() {
for name in [
"x-openai-future-mode",
"x-custom-extension",
"x-client-request-id",
"session-id",
"thread-id",
"x-codex-turn-state",
"x-codex-beta-features",
"openai-beta",
"accept",
"content-type",
"x-cf-business-field",
] {
assert!(
!is_transport_managed_request_header(name),
"unexpected {name}"
);
}
}
@@ -1,3 +1,4 @@
mod codex;
mod events;
mod headers;
mod sse;
@@ -4,7 +4,9 @@ use std::io;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use gateway_core::operation::GenerateRequest;
use gateway_protocol::openai::WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY;
use gateway_protocol::openai::{
WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY, is_transport_managed_request_header,
};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde::Serialize as _;
use serde_json::{Map, Value};
@@ -619,32 +621,12 @@ fn decode_passthrough_headers(context: &Map<String, Value>) -> HeaderMap {
}
fn provider_managed_header(name: &str) -> bool {
name.starts_with("sec-websocket-")
is_transport_managed_request_header(name)
|| name.starts_with("x-grok-")
|| name.starts_with("x-xai-")
|| matches!(
name,
"connection"
| "keep-alive"
| "proxy-connection"
| "proxy-authenticate"
| "proxy-authorization"
| "te"
| "trailer"
| "transfer-encoding"
| "upgrade"
| "host"
| "content-length"
| "forwarded"
| "x-forwarded-for"
| "x-forwarded-host"
| "x-forwarded-proto"
| "x-forwarded-port"
| "x-real-ip"
| "true-client-ip"
| "cf-connecting-ip"
| "x-request-id"
| "authorization"
"authorization"
| "x-api-key"
| "x-openai-actor-authorization"
| "cookie"
@@ -7,16 +7,27 @@ use tokio_tungstenite::tungstenite::http::HeaderMap as WsHeaderMap;
use super::*;
const DOWNSTREAM_TRANSPORT_HEADERS: &[(&str, &str)] = &[
("CF-Visitor", r#"{"scheme":"https"}"#),
("cf-connecting-ip", "192.0.2.1"),
("cf-connecting-ipv6", "2001:db8::1"),
("cf-pseudo-ipv4", "240.0.0.1"),
("cf-ray", "downstream-ray"),
("cf-ipcountry", "US"),
("cf-warp-tag-id", "downstream-warp"),
("cf-worker", "worker.example"),
("cf-ew-via", "15"),
("cdn-loop", "cloudflare; loops=1"),
("via", "1.1 downstream-proxy"),
("forwarded", "for=192.0.2.1;proto=https"),
("x-forwarded-for", "192.0.2.1"),
("x-forwarded-prefix", "/gateway"),
("accept-encoding", "br, gzip"),
("content-encoding", "downstream-encoding"),
];
fn request_with_opaque_headers(use_websocket: bool) -> CodexResponsesRequest {
let payload = ProtocolPayload::json_object(
"openai",
json!({"model": "gpt-test", "input": "hello"})
.as_object()
.expect("request object")
.clone(),
)
.expect("OpenAI payload")
.with_context(Map::from_iter([
let mut context = Map::from_iter([
(
"opaque_request_headers".to_owned(),
json!([
@@ -61,7 +72,25 @@ fn request_with_opaque_headers(use_websocket: bool) -> CodexResponsesRequest {
]),
),
("turn_state".to_owned(), json!("typed-turn-state")),
]));
]);
context
.get_mut("opaque_request_headers")
.and_then(Value::as_array_mut)
.expect("opaque headers")
.extend(
DOWNSTREAM_TRANSPORT_HEADERS
.iter()
.map(|(name, value)| json!([name, STANDARD.encode(value.as_bytes())])),
);
let payload = ProtocolPayload::json_object(
"openai",
json!({"model": "gpt-test", "input": "hello"})
.as_object()
.expect("request object")
.clone(),
)
.expect("OpenAI payload")
.with_context(context);
let mut request = encode_generate_request(
&GenerateRequest::from_protocol_payload(payload),
"gpt-routed",
@@ -352,7 +381,7 @@ async fn backend_websocket_should_forward_context_headers_and_preserve_payload_f
}
#[tokio::test]
async fn backend_http_should_restore_opaque_multivalue_header_bytes_and_lease_identity() {
async fn backend_http_should_preserve_business_headers_without_downstream_transport_headers() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind opaque HTTP server");
@@ -394,6 +423,17 @@ async fn backend_http_should_restore_opaque_multivalue_header_bytes_and_lease_id
.expect("opaque HTTP response");
let raw = server.await.expect("opaque HTTP server task");
for &(name, _) in DOWNSTREAM_TRANSPORT_HEADERS {
// HTTP 正文由 transport 重编码为 zstd,不能沿用下游 Content-Encoding。
if name == "content-encoding" {
assert_eq!(raw_header_values(&raw, name), vec![b"zstd".to_vec()]);
} else {
assert!(
raw_header_values(&raw, name).is_empty(),
"unexpected {name}"
);
}
}
assert_eq!(
raw_header_values(&raw, "x-openai-future-mode"),
vec![b"future-ascii".to_vec(), b"\x80\xff".to_vec()]
@@ -450,7 +490,7 @@ async fn backend_http_should_restore_opaque_multivalue_header_bytes_and_lease_id
}
#[tokio::test]
async fn backend_websocket_should_drop_only_unrepresentable_opaque_header_values() {
async fn backend_websocket_should_preserve_business_headers_without_downstream_transport_headers() {
let received = Arc::new(Mutex::new(WsHeaderMap::new()));
let listener = TcpListener::bind("127.0.0.1:0")
.await
@@ -520,6 +560,17 @@ async fn backend_websocket_should_drop_only_unrepresentable_opaque_header_values
.collect::<Vec<_>>()
};
for (name, _) in DOWNSTREAM_TRANSPORT_HEADERS {
assert!(values(name).is_empty(), "unexpected {name}");
}
assert!(
received
.get("sec-websocket-extensions")
.expect("upstream WebSocket compression negotiation")
.to_str()
.expect("extension value")
.contains("permessage-deflate")
);
assert_eq!(
values("x-openai-future-mode"),
vec![b"future-ascii".to_vec()]
+22 -5
View File
@@ -113,8 +113,8 @@ body 不进入这个通用信封。稳定业务码如下:
## 3. OpenAI 数据面与模型目录
Responses、Images 和 standalone Search HTTP body、WebSocket message 和 frame 不设置网关私有长度上限;
协议可接受性由上游决定。
除下述 Responses 入站解压保护外,Responses、Images 和 standalone Search HTTP body、
WebSocket message 和 frame 不设置网关私有长度上限;协议可接受性由上游决定。
| 方法 | 路由 | 说明 |
| --- | --- | --- |
@@ -129,6 +129,20 @@ Responses、Images 和 standalone Search HTTP body、WebSocket message 和 frame
Codex 的 review 等子代理请求仍使用 `/v1/responses`,并通过 `x-openai-subagent` 请求头携带子代理类型;
网关不提供独立的子代理请求路径。
`POST /v1/responses` 在鉴权后按 `Content-Encoding` 解压,再解析 JSON;支持单一 `gzip`、
`deflate`(zlib 封装)和 `zstd`,缺省、空值或 `identity` 直接使用原始正文。gzip 多成员与 zstd
多帧连续解码,整体展开结果最多 64 MiB,超限在继续展开前返回 `400 request_too_large`;zstd
回溯窗口同样最多 64 MiB,不能满足该限制的帧按解码失败处理。这个限制保护入站解压资源,不是
模型上下文或 Token 上限,也不新增未压缩正文的长度限制。
不支持的编码、逗号分隔的叠加编码和重复 `Content-Encoding` 头返回
`400 unsupported_content_encoding`;压缩正文损坏、截断或解压后不是合法 JSON 返回
`400 invalid_json`。本地错误不包含原始正文或解压库细节。WebSocket 文本帧不经过这条解压路径。
Responses 不透传下游的逐跳头、反代元数据(如 `cf-*`、`x-forwarded-*`、`forwarded`、`via`、
`cdn-loop`)以及 `Accept-Encoding` / `Content-Encoding`。链路元数据和编解码能力
由各段传输层独立管理;其余业务扩展头继续透传,不使用固定业务头白名单。
此规则同时适用于上游 HTTP 和 WebSocket,不影响上游响应的 `cf-ray` 等诊断信息。
Responses WebSocket 仅接受文本 `response.create`,同一连接串行执行。当前响应期间收到的后续业务帧
留在有界接收队列中,待当前响应完成终结和写出后再逐条校验、准入与执行,不因请求提前到达而断开。
这对齐 Codex 客户端 `stream_request` 持锁至本轮结束的串行行为,不表示支持额外控制消息类型。
@@ -397,9 +411,12 @@ OAuth start 使用:
OAuth refresh 的明确永久错误写入。
- 正常 Responses 请求会解析上游响应的 rate-limit headers,合并进同一 quota 快照并同步状态。Free、
K12 等套餐共用该状态机;套餐只参与账号展示和按套餐隔离的模型目录 cache,不存在 K12 专属额度路径。
- 账号展开区的 Token 结构和模型排行使用代表性账号级额度窗口聚合,查询边界严格为
`[resetAt - windowSeconds, resetAt)`;额度刷新若返回了更早的重置时间,会按新边界重新聚合。无法取得
完整窗口边界或只有模型专属额度时显示无数据,不回退成历史累计。金额原值保持完整精度,USD 展示值
- 账号展开区的 Token 结构、模型排行和列表 Token 汇总优先使用账号级周额度窗口,无可统计的周窗口时
使用月额度窗口;`usage.windowLabelDisplay` 随选中的窗口返回“周额度窗口”或“月额度窗口”。查询边界
严格为 `[resetAt - windowSeconds, resetAt)`,不是自然周/月或最近 7/30 天;额度刷新若返回了更早的
重置时间,会按新边界重新聚合。没有边界完整、可归属到账号的周/月窗口时显示无数据,标签为
“周/月额度窗口”,不回退到 5 小时、日窗口或历史累计。各额度条与 Dashboard 的百分比选择不受影响。
金额原值保持完整精度,USD 展示值
小于 1 美元时最多保留四位小数,其余保留两位。
- 账号页没有定时静默轮询。手工额度刷新只替换响应中的账号行并同步状态汇总,不触发整页 loading;若
新状态不符合当前筛选,该行从当前页移除。请求驱动或后台任务产生的状态变化,需要下一次显式查询账号
+3
View File
@@ -174,6 +174,9 @@ Core 只理解 `Operation`、能力要求、Provider 候选、稳定错误和 ca
- OpenAI 是透明边界。Responses 请求保留未知字段和字段顺序;SSE、WebSocket、Images 与 standalone
Search 的业务正文按原始字节转发。canonical facts 从同一数据旁路提取,只用于路由、观测和计费。
- Responses 的业务扩展头保留原始多值字节;传输与反代请求头分类由 `gateway-protocol` 统一定义,
API 入站与 OpenAI Provider 编码共同使用。下游链路元数据和压缩协商不跨越该边界,
上游认证、请求画像与传输字段仍由 Provider 生成;响应方向的诊断头不受请求过滤规则影响。
- xAI 是翻译边界。Provider 把 Grok wire 转换为 Responses wire;上游结构化错误的 message/code/type
可以透出,但账号指纹会先脱敏。
- response ID 是不透明 UTF-8 bytes,不假设 UUID、固定长度或跨 Provider 可复用。
+1
View File
@@ -75,6 +75,7 @@ export interface AccountModelUsage {
}
export interface AccountUsage {
windowLabelDisplay: string
requestCount: number | null
requestCountDisplay: string
inputTokens: number | null
@@ -37,7 +37,7 @@ const additionalEntryCount = computed(() => Math.max(summaryEntries.value.length
>
<span
class="flex min-w-0 items-baseline gap-1 font-mono tabular-nums"
title="当前额度窗口总 Token"
:title="`${account.usage.windowLabelDisplay}总 Token`"
>
<strong class="truncate text-cp-xs font-heavy text-cp-text">
{{ account.usage.totalTokensDisplay }}
@@ -37,7 +37,7 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
<h3 class="m-0 text-cp-lg font-heavy text-cp-text">
Tokens 结构
</h3>
<span class="text-cp-xs font-emphasis text-cp-text-quaternary">当前额度窗口</span>
<span class="text-cp-xs font-emphasis text-cp-text-quaternary">{{ account.usage.windowLabelDisplay }}</span>
</div>
<div class="grid gap-2 xl:min-h-0 xl:flex-1 xl:grid-rows-5">
<div class="flex items-center justify-between rounded-lg bg-cp-green-container px-3 py-2">
@@ -79,7 +79,7 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
模型使用排行
</h3>
<div class="ml-auto flex items-center gap-4">
<div class="ml-auto flex items-baseline gap-4">
<div v-if="hasUsageSummary" class="flex items-baseline gap-1.5 whitespace-nowrap">
<Sigma class="size-3.5 self-center text-cp-text-tertiary" :stroke-width="1.75" />
<span title="总 Token">
@@ -96,7 +96,7 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
</span>
</span>
</div>
<span class="whitespace-nowrap text-cp-xs font-emphasis text-cp-text-quaternary">当前额度窗口</span>
<span class="whitespace-nowrap text-cp-xs font-emphasis text-cp-text-quaternary">{{ account.usage.windowLabelDisplay }}</span>
</div>
</div>
@@ -147,7 +147,7 @@ export function useAccountResetCredits(options: {
accountId,
creditId: credit.id,
credit,
redeemRequestId: globalThis.crypto.randomUUID(),
redeemRequestId: generateRedeemRequestId(),
hasTransportFailure: false,
}
}
@@ -257,6 +257,15 @@ export function useAccountResetCredits(options: {
}
}
function generateRedeemRequestId() {
// 普通 HTTP 管理端没有 randomUUID;getRandomValues 仍可生成密码学安全的 UUIDv4。
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16))
bytes[6] = (bytes[6] & 0x0F) | 0x40
bytes[8] = (bytes[8] & 0x3F) | 0x80
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
function isAmbiguousConsumeError(error: unknown) {
if (!(error instanceof ApiError))
return false
+12 -20
View File
@@ -1,26 +1,18 @@
# v3.4.0
# v3.4.1
## What's new
## 问题修复
- Add a dedicated proxy management page to create, edit, and test outbound proxies, with connection latency and exit IP information.
- Test new or edited proxy URLs before saving, without changing saved connection settings or linked accounts.
- Select managed proxies when importing accounts or editing individual and multiple accounts. Proxy changes apply to linked accounts without restarting the gateway.
- Search and paginate accounts linked to a proxy, with account identity, provider and authentication type, plan, and group information. Remove an account from its proxy to restore a direct connection.
- 修复通过普通 HTTP 访问管理端时,额度重置点击“下一步”因 `crypto.randomUUID` 不可用而无响应的问题;保留请求重试时的幂等标识。
- 修复 Cloudflare Tunnel 等反向代理注入的 `cf-visitor`、`cf-warp-tag-id` 等链路头被透传到上游、导致 HTTP 或 WebSocket 请求遭遇 403 的问题。统一过滤下游传输头与压缩协商,继续保留业务扩展头及上游响应诊断信息。
- `POST /v1/responses` 支持接收 `gzip`、`deflate`(zlib 封装)和 `zstd` 压缩正文,避免将压缩数据直接当作 JSON 而返回 `invalid_json`。
- 加固请求解压的内存边界,支持完整读取 gzip 多成员和 zstd 多帧,拒绝损坏、超限及叠加编码的请求,避免静默忽略后续内容。
## Fixes
## 体验优化
- Apply the same certificate trust configuration to proxy tests and OpenAI requests, including `CODEX_CA_CERTIFICATE`, so usable proxies are not rejected by the selector because of inconsistent certificate handling.
- Protect credential imports from concurrent proxy changes, preserving credentials exchanged during import and their intended proxy binding.
- Correct the upstream WebSocket stream flag for non-streaming OpenAI client requests.
- Prevent table background seams after dragging dialogs.
- 账号用量统计优先使用周额度窗口,周窗口不可用时使用月额度窗口;没有符合条件的窗口时,不再回退到短期限流窗口或历史累计值。额度比例和 Dashboard 的窗口选择保持不变。
- Tokens 结构和模型使用排行统一显示“周额度窗口”或“月额度窗口”,并修正汇总数字与窗口标签的水平对齐。
## Improvements
## 升级说明
- Keep proxy creation and editing consistent, with separate test and save actions and clearer input prompts. Display masked connection addresses in account selectors and preserve saved proxy credentials when the connection address is left unchanged.
- Refine proxy table column widths and compact actions, remove the redundant list refresh button, and reuse account plan labels across account management and linked-account dialogs.
- Make sidebar navigation scroll independently with an automatically hidden scrollbar, keeping the logo and footer controls accessible.
- Trim unnecessary trailing zeros from API key budget amounts and refine budget details and reset-time displays. Exact amounts remain available in the details popover.
## Upgrade
The database migration automatically adds existing account proxy URLs to the managed proxy catalog and preserves account bindings. Existing connections continue to work. Run a connection test before selecting a migrated proxy for a new or changed account binding.
- 从 v3.4.0 升级无需新增数据库迁移或配置调整。
- 压缩的 Responses HTTP 请求展开后最多为 64 MiB,zstd 解码窗口也限制为 64 MiB;超限或无法满足解码限制的请求会被拒绝。本次不新增未压缩正文或 WebSocket 帧的长度限制。
+1 -1
View File
@@ -1 +1 @@
version: "3.4.0"
version: "3.4.1"
-92
View File
@@ -1,92 +0,0 @@
---
name: codex-proxy-rs
description: Codex Proxy RS 仓库开发与审计指南。Use for its Rust gateway, Vue admin, OpenAI/xAI providers, account routing, Responses/Images transport, PostgreSQL/Redis state, workers, deployment, release, CI, or project documentation.
---
# Codex Proxy RS
## 开始工作
1. 从仓库根目录检查 `git status --short`,保留用户已有改动。
2. `.codegraph/` 存在时先用 CodeGraph 定位 owner、调用链和动态分发,再读未覆盖的源码。
3. 先确定事实属于 API、Core、Admin、Store、Host、Provider 还是 frontend,不跨 owner 打补丁。
4. 行为与文档冲突时以当前源码、配置和测试为准,并在同一任务中修正文档。
仓库根目录没有 Cargo manifest;后端命令必须进入 `backend/` 或显式传
`--manifest-path backend/Cargo.toml`。
## 文档路由
| 需要了解 | 权威入口 |
| --- | --- |
| 用户能力与快速开始 | `README.md` |
| HTTP 路由、DTO、敏感导入/导出 | `docs/api.md` |
| 系统边界、数据流、状态 owner、不变量 | `docs/architecture.md` |
| Compose、密码、备份、更新与恢复 | `deploy/README.md` |
| Codex 配置、auth.json 与原生生图 | `deploy/README.md#客户端配置` |
| 主题配置与 Token | `docs/theme.md` |
| 迁移冻结与测试库 | `backend/migrations/README.md` |
| 具体目录和常见改动入口 | `references/repo-guide.md` |
README 保持用户导向;不要把上游 URL、重试常量、数据库字段清单或实现时间线堆进 README/架构文档。
## 不可破坏的边界
- `gateway-api` 只做 HTTP/WS/SSE 适配;`gateway-core` 编排请求;Provider 独占 credential、catalog、
quota 与 transport;`gateway-store` 实现持久化端口;具体实现只在 `apps/gateway` 组合。
- Provider 的一次 `execute` 只选择一个 credential。换号、业务 retry 和跨 Provider fallback 只能由 Core
在发送/交付边界内决定。
- PostgreSQL 是持久化权威;Redis 只保存可重建、可过期的协调状态。
- 当前只有 `openai` 与 `xai`,不存在 Provider Instance 层;Client Key 绑定账号分组而不是 Provider。
- 已应用迁移按字节冻结;schema 变化新增编号迁移,并同步 `.frozen-sha256`。
- 真实 secret 不进入普通日志、Debug、fixture、audit details 或文档示例;明文 Admin 响应只能出现在
账号导出、Key reveal、备份设置等明确敏感合同中。
- 后端测试放在各 package 的 `tests/`,统一挂载到单一 `main` 集成测试目标;生产 `src` 不写 test-only 代码。
- 不添加兼容 shim、第二套状态机、重复配置或跨层旁路。
## 当前关键合同
- 数据面公开 Responses JSON/SSE/WS(含 Codex 子代理语义)、Images generation/edit、standalone Search 和模型目录;不提供 Chat Completions。
- OpenAI Responses/Images/Search 保持业务 wire 透明;xAI 在 Provider 内完成 Grok/Responses 转换。
- Images 固定走 OpenAI Provider 自有端点,不要求模型,也不参与文本模型映射。
- Codex 生图模板保留代理密钥 `auth.json`,使用服务端托管认证标记;该标记不能代替 Bearer 鉴权,
并在 API 与 Provider 层被过滤。客户端 WebSocket 开关与上游传输策略相互独立。
- OpenAI 账号支持 OAuth、AT/RT 和 OAuth JSON;xAI 只接受 OAuth session/账号 JSON,
不接受 API Key。
- 主动额度重置卡只由 OpenAI 上游持有。查询由用户显式触发,消费使用 UUIDv4 幂等键;不得写本地卡库存
或直接改 quota reset 时间。
- credential 与 quota 是独立事实;刷新 token 不等于刷新额度,quota 401/403 也不等于 RT 永久失效。
- `0001_initial.sql` 建立基线,后续编号迁移继续演进;完整清单以 `.frozen-sha256` 为准。
- 运行拓扑是单副本;worker lease 不是完整多副本 leader election,自更新也只替换当前进程。
## 前端约束
- 使用 Vue 3、`<script setup lang="ts">`、现有基础组件和主题 token。
- `frontend/src/api` 只保留 wire DTO 与请求函数;页面查询、缓存和交互状态留在对应 view/composable。
- 修改页面前先检查共享组件和相邻调用方;复用既有视觉语言,保持紧凑、低噪声和键盘可访问。
- 上游查询不得由列表渲染或隐藏轮询意外触发。不可逆动作需要明确确认、loading 和不确定结果恢复路径。
- 当前不维护前端测试代码或独立测试命令,使用 lint、类型检查、构建和必要的界面核验。
## 验证
按改动风险选择相关命令;完整门禁为:
```bash
cargo +1.97.0 fmt --all --manifest-path backend/Cargo.toml -- --check
RUST_MIN_STACK=16777216 cargo +1.97.0 clippy --manifest-path backend/Cargo.toml --all-targets --all-features --locked -- -D warnings
RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml --test main --locked
pnpm --dir frontend format:check
pnpm --dir frontend build
docker compose -f deploy/compose.yaml config --quiet
```
`gateway-store` 的 PostgreSQL/Redis 集成测试使用 `CPR_TEST_DATABASE_URL` 和 `CPR_TEST_REDIS_URL`;
本地未设置时静默跳过,CI 缺失则失败。自动化测试不发送真实对话、不轮换生产 RT。
## Git 与发布
- 提交前检查 status、cached/unstaged diff 和 `git diff --check`。
- 使用简短 Conventional Commit subject;按用户要求添加 `Co-authored-by: Codex <noreply@openai.com>`。
- 发布权威是 `release/version.yaml` 与带注释的 `vX.Y.Z` tag。
- 发版前替换并提交 `release/notes.md`,首行必须为目标版本的 `# vX.Y.Z`,正文只记录本次更新。
- 正式发布使用 `release/publish <version>`;工作流读取 tag 中的发版说明。提交、Release 和实例升级是三个独立状态。
-4
View File
@@ -1,4 +0,0 @@
interface:
display_name: "Codex Proxy RS"
short_description: "按当前架构维护 Rust/Vue 多账号 Responses 网关"
default_prompt: "使用 $codex-proxy-rs 按当前源码与架构完成开发、验证或发布。"
@@ -1,172 +0,0 @@
# Codex Proxy RS 项目指南
这是面向仓库修改者的导航,不复制完整 API 或架构说明。行为以当前源码和测试为准;长期边界见
`docs/architecture.md`。
## 权威入口
| 主题 | 入口 |
| --- | --- |
| 顶层装配与启动顺序 | `backend/apps/gateway/src/bootstrap.rs` |
| 客户端路由 | `backend/crates/gateway-api/src/openai/router.rs` |
| 管理路由 | `backend/crates/gateway-api/src/admin/` |
| 请求生命周期 | `backend/crates/gateway-core/src/engine/` |
| 快照与路由计划 | `backend/crates/gateway-core/src/routing/snapshot.rs` |
| OpenAI Provider | `backend/crates/providers/openai/src/` |
| xAI Provider | `backend/crates/providers/xai/src/` |
| PostgreSQL/Redis adapter | `backend/crates/gateway-store/src/` |
| Worker 贡献与监督 | 各 Bundle 的 `worker_contributions`、`gateway-host/src/workers/` |
| Vue 管理端 | `frontend/src/views/`、`frontend/src/components/base/` |
| 部署与发布 | `deploy/`、`release/` |
存在 `.codegraph/` 时,先查询符号和调用链;CodeGraph 未覆盖的目标再用 `rg` 和定点读取补齐。
## Package owner
| Package | 拥有的事实 | 不应拥有 |
| --- | --- | --- |
| `gateway-protocol` | 共享 wire/canonical contract | 数据库、Provider、业务策略 |
| `gateway-core` | operation、routing、attempt、commit/send、accounting | HTTP DTO、SQL、具体上游协议 |
| `gateway-admin` | 管理命令、用例、审计、备份策略、抽象端口 | Axum、具体数据库实现 |
| `gateway-api` | Axum route、鉴权解码、wire presenter、响应交付 | SQL、具体 Provider 分支 |
| `gateway-store` | PostgreSQL、Redis、S3、`pg_dump` adapter | 路由/重试/Provider 业务规则 |
| `gateway-host` | 配置、日志、serve/drain、worker、update | 数据面路由与 Provider 逻辑 |
| `provider-openai` | OpenAI credential/quota/catalog/transport | Client Key admission、跨 Provider fallback |
| `provider-xai` | xAI credential/quota/catalog/transport 与协议转换 | Client Key admission、跨 Provider fallback |
| `apps/gateway` | 具体实现装配 | 业务状态机 |
仓库根目录没有 Cargo manifest。后端 workspace 是 `backend/Cargo.toml`,Rust 版本为 1.97,edition 2024。
## 数据面入口
公开路由由 `gateway-api/src/openai/router.rs` 固定:
- Responses:`POST /v1/responses`、WebSocket `GET /v1/responses`;
- Images:`POST /v1/images/generations`、`POST /v1/images/edits`;
- Codex 独立搜索:`POST /v1/alpha/search`;
- 模型:`GET /v1/models`、`GET /v1/models/{model_id}`。
Responses adapter 解析参与路由的最少语义,并保留原始请求;review 等子代理仍走 `/v1/responses`,
类型由 `x-openai-subagent` 携带。Images adapter 不解析模型或重建 JSON,直接创建
`GenerateImage` operation。Search 同样保留原始正文,不参与模型映射。
这些请求都进入同一 Core execution lifecycle。
定位请求问题时按以下顺序追踪:
```text
gateway-api adapter
-> ExecutionService
-> RuntimeSnapshot::plan / plan_provider_endpoint
-> AttemptCoordinator
-> Provider::execute
-> API delivery
-> asynchronous observations
```
重点边界:
- `UpstreamSendState` 区分 `not_sent/sent/ambiguous`;
- downstream commit 后不能重试;
- Provider execute 一次只返回一个账号的一条冷流;
- account retry 与跨 Provider 候选推进由 Core 管理;
- response ID 和原始 wire 不因观测模型而重写。
OpenAI 保持 Responses/SSE/WS/Images/Search 业务字节透明;xAI 在自己的 Provider 内完成 Grok/Responses 转换。
Images 和 Search 固定调用 OpenAI Provider 自有端点,候选不带 `upstream_model`。
## 路由与 continuation
`RuntimeSnapshot` 聚合运行设置、账号目录、分组 membership、Client Key scope、Provider 模型目录和
`config_revision`。Client Key 零分组表示全部账号,非空分组表示已启用成员并集;空池 fail closed。
文本模型先做全局精确映射,再在 scope 内按 Provider 能力生成候选。普通 retry 需要同时满足发送状态、
重放安全、attempt 预算和未 commit;传输不明确不自动重放。
OpenAI session affinity 是粘性提示;native continuation pin 才固定 Client Key、scope、Provider 与账号。
OpenAI continuation 按 native/replay-owner/replay-any 推进,xAI 使用客户端提交的完整历史。
## 账号控制面
账号路由和 DTO 在 `gateway-api/src/admin/accounts/` 的 handlers、credentials、wire 与 presenter 中,业务编排在
`gateway-admin/src/use_case/accounts.rs`,Provider 解析/验证后由 Store 事务提交。
### 导入与授权
- 公共导入合同始终是 `{ provider, data }`,其中 `data` 为 Provider-owned JSON object。
- OpenAI 支持 OAuth JSON、`accounts` 数组、AT-only、RT-only 和 AT+RT+可选 ID Token;
最多 200 项。token 字段接受 camelCase 和官方 snake_case;RT-only 先交换 AT,AT-only 不具备刷新能力。
- OpenAI OAuth 身份来自 `parse_chatgpt_jwt_claims`,不信任导入 object 顶层的 user/account ID。
- 前端 AT/RT 标签只把“每行一个 token”转换成 `accounts` JSON,没有第二条导入 API。
- xAI 接受 OAuth 账号 object/数组,逐项验证;API Key 不是 credential。
- 新账号未分组;匹配既有上游身份的导入/重新授权保留调度事实。
### Credential、quota 与恢复
- credential refresh、quota refresh、正常请求的 rate-limit 观测是独立链路。
- 账号五态由 credential/quota + Redis cooldown 统一投影,不保存重复 `status`。
- `/accounts/recover` 是管理员清除本地错误、quota 和 cooldown 的强制动作,不证明上游已经恢复。
- 展开区用量按代表性额度窗口 `[reset_at - window_seconds, reset_at)` 聚合,不回退到全历史累计。
### 主动重置卡
- `GET/POST /api/admin/accounts/reset-credits` 只对 OpenAI 有效。
- 列表和消费直接访问上游;不建 PostgreSQL/Redis 卡库存。
- POST 使用 canonical UUIDv4 `redeemRequestId`,账号级串行;401 credential refresh 后复用同一命令。
- 发送后结果不明确时,调用方只能复用原键重试;成功后另行刷新卡列表和 quota。
- 前端只在弹窗打开/手工刷新时查询,最近结果仅缓存于当前浏览器会话。
## 存储与迁移
PostgreSQL 业务 schema 由 `0001_initial.sql` 和后续编号迁移共同组成。`.frozen-sha256` 覆盖全部迁移,
已应用文件不可回改。
主要状态边界:
- PostgreSQL:账号、credential、quota、分组、Client Key、设置、请求、审计、备份;
- Redis:admission、credential/worker lease、cooldown、circuit、session affinity、continuation、OAuth
pending、catalog/cache 和 runtime change;
- `.runtime/data`:会话身份、更新状态与备份暂存;
- `.runtime/logs`:普通日志和含原始 AT/RT 的独立 OAuth 恢复文件集。
数据面 observation 使用有界 OpsFlush 队列;它可丢失、可观测,但不是第二份业务权威。native
continuation 等正确性依赖的协调写入不经该 best-effort 队列。
## Worker 与生命周期
组合根按 Store → Core → OpenAI → xAI → Admin 收集 worker contributions,Host 统一校验、监督、健康
暴露和关闭:
- Store:stale request recovery、retention、PostgreSQL/Redis flush daemons;
- Core:snapshot reconciliation 和 change subscription;
- OpenAI/xAI:OAuth refresh、quota/catalog 和版本/etag 检查;
- Admin:单个备份 daemon。
应用只支持单副本。周期 lease 是单周期互斥,不是集群 leader election;备份和在线更新同样依赖此边界。
关闭先 drain HTTP/WS,再 join workers。
## 前端定位
- API DTO/请求:`frontend/src/api/modules/`;
- 页面状态:对应 `views/*/composables/`;
- 页面展示转换:对应组件或相邻 presenter/util;
- 通用交互:`components/base/`;
- 主题派生:`frontend/src/theme/`;样式桥接在 `styles/index.css`,`styles/tokens.css` 只保存静态基元。
- Codex 配置:`views/api-keys/utils/codexConfig.ts`,界面与 CCSwitch 共用;
保留 API Key auth.json,只用 Provider 的 `supports_websockets` 控制客户端 WebSocket。
不要把页面状态塞进 API module,也不要为单一页面创造第二套通用层。修改共享组件前检查所有调用方;
异步上游动作必须区分 loading、明确失败和结果不确定。
## 验证与发布
```bash
cargo +1.97.0 fmt --all --manifest-path backend/Cargo.toml -- --check
RUST_MIN_STACK=16777216 cargo +1.97.0 clippy --manifest-path backend/Cargo.toml --all-targets --all-features --locked -- -D warnings
RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml --test main --locked
pnpm --dir frontend format:check
pnpm --dir frontend build
docker compose -f deploy/compose.yaml config --quiet
```
PostgreSQL/Redis 集成测试需要 `CPR_TEST_DATABASE_URL`、`CPR_TEST_REDIS_URL`。发布使用
`release/publish <version>`;不要把本地 commit、GitHub Release 和在线实例 revision 混为同一状态。
-77
View File
@@ -1,77 +0,0 @@
---
name: engineering-standards
description: 证据优先、owner 清晰的长期维护工作流。Use for audits, bug fixes, refactors, production incidents, reference-project comparisons, documentation synchronization, or requests that reject compatibility shims and code smell.
---
# 工程规范
## 先确定任务合同
| 用户要求 | 默认交付 |
| --- | --- |
| 解释、审计、诊断 | 查明证据和边界,只报告结论,不擅自修改 |
| 修复、实现、重构 | 在已证明的 owner 内修改,并按风险验证 |
| 文档、计划 | 只更新被授权的文档/计划,不顺手实现功能 |
| 生产排障 | 先核对实际 revision、配置、数据和日志;写操作需要明确授权 |
最新的用户范围修正优先。不要用“顺便优化”扩大 mutation、部署、数据清理或外部操作范围。
## 事实优先级
1. 当前可复现输入与运行结果。
2. 当前部署 revision、配置、数据库和日志。
3. 当前源码、测试与公开合同。
4. 架构和项目文档。
5. 记忆、历史提交和参考项目。
低优先级来源只能帮助定位,不能覆盖当前事实。观察到相关性时标为线索;只有调用链、状态变化或可复现
实验闭合后才称为根因。
## 工作流
1. 检查工作树和运行环境,区分用户改动与本任务范围。
2. 找到入口,沿调用链追踪状态的创建、持久化、读取、转换和输出。
3. 标出协议、领域、存储、异步任务和外部依赖之间的 owner。
4. 用最小证据证明断点;如果现象已经由环境/数据解释,不先写代码。
5. 在 owner 内做最小完整修复;行为变化通过公开接口或外置集成测试固定。
6. 同步受影响的 API、架构、部署、迁移或 skill 文档。
7. 运行与风险相称的格式、lint、测试和静态检查,再复核最终 diff。
简单文案和机械修改直接完成,不套审计模板;高风险或不可逆操作先做只读检查并明确影响范围。
## 设计约束
- 一个事实只能有一个权威 owner;缓存、projection 和 display 必须能追溯到它。
- 不添加兼容 shim、重复 API、双状态机、第二套配置或临时旁路。
- 不用大范围重构掩盖尚未理解的问题,也不为局部问题修改无关模块。
- 抽象必须减少重复职责或稳定边界;没有复用/所有权收益时保留直接实现。
- 错误应保留作出判断所需的原始上游事实,同时在日志和响应边界脱敏 secret。
- 不把可恢复观测升级成业务权威,也不让遥测失败改写已经确定的业务结果。
- 并发和重试必须先定义幂等键、发送边界、commit 边界和结果不确定语义。
- 测试遵守生产目录边界;不为测试把 mock、fixture 或开关塞进生产源码。
## 参考项目
采用外部实现前同时证明:
1. 当前项目确实缺少该行为;
2. 当前架构存在匹配 owner;
3. 参考行为与当前上游/版本仍一致;
4. 引入后不会复制已有链路;
5. 用户授权范围包含这项实现。
参考代码用于验证机制和请求画像,不用于替代当前项目证据。若参考项目与当前源码冲突,先解释差异,
不要静默移植。
## 验证与收尾
验证目标是证明改动的合同,而不是堆命令。优先运行最窄相关测试,再按风险扩展到 package、workspace、
frontend build 或部署配置;用户明确要求不运行验证时,只做必要的只读 diff/一致性复核并如实说明。
最终交付只报告:
- 已改变的行为和 owner;
- 能证明结果的验证;
- 明确未做、未验证或仍受外部状态阻塞的事项。
不要把排查时间线、空洞原则、未证实猜测或工具输出堆成结论。
@@ -1,4 +0,0 @@
interface:
display_name: "工程规范"
short_description: "证据优先、边界清晰、拒绝兼容旁路的长期工程维护工作流"
default_prompt: "使用 $engineering-standards 追清当前链路,并在唯一所有权边界内完成干净修复。"