Compare commits

...
34 Commits
Author SHA1 Message Date
ZyyandCodex 2221f400da chore(release): v3.29.2
Co-authored-by: Codex <noreply@openai.com>
2026-10-18 13:45:14 +08:00
ZyyandCodex 38bcfb9da1 fix(dashboard): correct yesterday trend comparisons
Read yesterday card counters independently using deployment calendar boundaries. Hide trends when today has no requests or cache rates are unavailable, and label arrows as comparisons with yesterday.

Co-authored-by: Codex <noreply@openai.com>
2026-10-18 13:35:05 +08:00
ZyyandCodex 10f122aac8 fix(frontend): align theme preview data and input hints
Match the supplied Codex Desktop identity reference and avoid undefined verification labels. Remove ellipses from input placeholders while preserving OAuth callback URL examples.

Co-authored-by: Codex <noreply@openai.com>
2026-10-18 13:32:51 +08:00
ZyyandCodex 598d56baed fix(frontend): stabilize async state and session lifecycles
Preserve plugin pages during session renewal, restore pagination after failed requests, and cancel plugin resources on reload or disposal. Reuse request state for quota forecasts and disable logout while authentication changes.

Validated with frontend lint and build, 23 temporary behavioral checks, and browser checks using simulated pagination and authentication responses.

Co-authored-by: Codex <noreply@openai.com>
2026-10-18 13:32:46 +08:00
ZyyandCodex 5f61309941 refactor(frontend): standardize API request contracts
Use named request and response types with consistent data arguments and request configuration. Update affected callers while retaining request options where needed.

Co-authored-by: Codex <noreply@openai.com>
2026-10-18 13:32:27 +08:00
ZyyandCodex ab8c61b118 chore(toolchain): upgrade Node to 26.12.0 and Rust to 1.99.1
Align Docker images, CI inputs, Rust requirements and development commands.
Adapt strict compiler checks and reuse owned buffers in UTF-8 conversion.

Validate 4,021 backend tests, SDK feature configurations, Clippy, frontend
checks, and the amd64 container build and startup.

Co-authored-by: Codex <noreply@openai.com>
2026-10-18 04:57:48 +08:00
ZyyandCodex c3146dc10a fix(usage): align request tables and detail dialogs
Share data columns and status filters between admin and key usage. Keep final error summaries in the error view and focus error details in a separate dialog.

Use logical request outcomes for key usage records and keep concurrency lists at a stable height without pagination.

Co-authored-by: Codex <noreply@openai.com>
2026-10-18 04:28:41 +08:00
ZyyandCodex 72bdc234ca docs(skills): streamline routing and clarify development boundaries
Co-authored-by: Codex <noreply@openai.com>
2026-10-18 04:14:22 +08:00
ZyyandCodex 733e9aa8a0 fix(release): guard publication and consolidate release tooling
Co-authored-by: Codex <noreply@openai.com>
2026-10-18 04:02:17 +08:00
ZyyandCodex 743a3aba70 chore(release): v3.29.1
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 22:01:49 +08:00
ZyyandCodex 349e983eb7 fix(ui): correct account labels, proxy actions and usage defaults
Reuse account identity labels in concurrency details and use a text button
for reset-credit lead time settings. Disable row tests during batch proxy
testing while retaining loading feedback for individual tests.

Default the usage date range to today.

Co-authored-by: Codex <noreply@openai.com>
2026-10-17 22:00:16 +08:00
ZyyandCodex 45954899e5 chore(release): v3.29.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 21:31:14 +08:00
ZyyandCodex 78f533d9d3 chore(db): consolidate unreleased migrations for v3.29.0
Merge migrations 0033-0035 into one migration while preserving published migrations 0001-0032. Verified PostgreSQL 18 fresh installation, upgrade from v3.28.0, existing data defaults, and schema equivalence with the original migration sequence.

Co-authored-by: Codex <noreply@openai.com>
2026-10-17 21:30:59 +08:00
ZyyandCodex 376305fed4 docs: align reference documentation with current behavior
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 21:20:33 +08:00
a82bcdb8f8 fix(providers): preserve upstream failures across account selection (#462)
* fix(openai): distinguish session policy exclusions from unavailable accounts

Co-authored-by: Codex <noreply@openai.com>

* fix(providers): preserve upstream failures after selection exhaustion

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: 2233224230 <111681546+2233224230@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-10-17 21:19:00 +08:00
ZyyandCodex 3d072fedcf fix(deploy): preserve runtime ownership on installer reruns
Limit application data and log ownership changes to their top-level directories. Add a container check that reapplies installer permissions and verifies application startup.

Validation: first-install and repeated-install filesystem permission checks, Bash syntax, ShellCheck, Actionlint, and diff checks passed. Full local container validation was blocked by Docker Desktop bind-mount permission mapping.

Co-authored-by: Codex <noreply@openai.com>
2026-10-17 21:10:39 +08:00
ZyyandCodex a0e708071a perf(plugins)!: optimize RPC and simplify host boundaries
Move business JSON out of control metadata, encode request snapshot headers
compactly, and reuse encoded frames. Cache compiled configuration schemas
and prepare plugin instances with bounded concurrency.

Ignore unused plugin result fields and remove redundant business validation
while preserving host authorization, resource ownership, and capacity limits.
Keep response decoding failures local to their calls.

Seal callback side effects before completing or cancelling RPC calls, and
recover abandoned state migrations without allowing old owners to promote.

Validation: SDK 112 tests and its doctest, Runtime 203 tests, Core 542 tests,
40 PostgreSQL plugin tests, and 16 architecture checks passed. Strict Clippy,
formatting, and diff checks passed.

BREAKING CHANGE: RPC protocol v4 changes frame payload layout and request
snapshot header encoding. Plugins must update their SDK and protocol
manifest version and be rebuilt.

Co-authored-by: Codex <noreply@openai.com>
2026-10-17 20:54:41 +08:00
ZyyandCodex e9ad1811dd feat(usage): add bounded date range picker
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 20:38:02 +08:00
ZyyandCodex 615e8d66f4 fix(openai): enforce account model access for standalone search
Apply model allowlists and denylists before selecting search accounts.
Use unrestricted accounts when the request model cannot be determined,
and preserve plugin body rewrites after selection.

Co-authored-by: Codex <noreply@openai.com>
2026-10-17 20:36:58 +08:00
ZyyandCodex d79c76a105 feat(accounts): add optional weekly quota limit for reset credits
Skip new automatic redemptions above the configured remaining weekly
quota limit, leaving it unset by default and preserving pending retries.
Keep temporarily ineligible credits scheduled and expose the setting
beside the redemption lead time.

Co-authored-by: Codex <noreply@openai.com>
2026-10-17 20:15:21 +08:00
ZyyandCodex 1bf47f71b6 chore(deps): align workbench with UI v0.5.0
Keep the host, UI submodule, and plugin workbench lockfile on the same UI release for source development.

Co-authored-by: Codex <noreply@openai.com>
2026-10-17 20:09:14 +08:00
ZyyandCodex 2f3f2975ef feat(ui): upgrade UI to v0.5.0 and simplify health popovers
Use shared hover and dismissal handling for health timeline popovers, remove host workarounds and redundant footer copy, and pin the UI dependency and submodule to v0.5.0.

Co-authored-by: Codex <noreply@openai.com>
2026-10-17 20:03:36 +08:00
759935b75d feat(accounts): add concurrency details and SDK request context (#461)
* feat(accounts): add concurrency details and SDK request context

Co-authored-by: Codex <noreply@openai.com>

* fix(plugins): version request-context frames and preserve failed snapshots

Co-authored-by: Codex <noreply@openai.com>

* fix(dev): sync workbench with request-context contracts

Co-authored-by: Codex <noreply@openai.com>

* fix(accounts): refine concurrency and request context handling

Preserve entry context and recursion scopes in retry and public management callbacks. Encode queued RPC frames once and use a dedicated account existence query for concurrency polling. Sync workbench packaging tooling and CI through the plugin submodule.

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 19:45:49 +08:00
ZyyandCodex fcf4af0ddd fix(ui): let key usage page fill viewport width
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 19:27:59 +08:00
ZyyandCodex 8fb48dd6c3 fix(plugins): expose reset credit automation controls
Register typed account automation operations for server and CLI plugins, reusing the native account service. Preserve the initiating actor across service continuations and generate the SDK contract from host declarations.

Co-authored-by: Codex <noreply@openai.com>
2026-10-17 16:53:54 +08:00
5a81d61979 feat(accounts): automatically use expiring reset credits (#460)
* feat(accounts): automate expiring reset credits

Persist per-account plans and redemption keys, stagger daily discovery, and verify availability before consuming expiring credits.

Co-authored-by: Codex <noreply@openai.com>

* fix(ui): remove reset credit timing button background

Use the existing link variant to keep the control transparent in both idle and hover states.

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 16:25:12 +08:00
ZyyandCodex 1fadc75ba2 fix(ui): refine table labels and status alignment
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 15:17:16 +08:00
83f5d71853 fix(openai): bound WebSocket connections per outbound proxy (#458)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 14:56:30 +08:00
f4eceb076b fix(openai): adapt multipart image edits to native JSON (#457)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 14:52:13 +08:00
ZyyandCodex 0b02d0d98a fix(deps): upgrade zip to resolve security advisories
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 13:27:38 +08:00
a014da6916 feat(proxies): add batch APIs and streamline proxy management (#450)
* feat(proxies): add batch creation and geolocated testing

Co-authored-by: Codex <noreply@openai.com>

* feat(proxies): unify batch APIs and simplify management UI

Accept arrays on the existing create and test routes with ordered per-item
results, partial success, bounded concurrency and no automatic replay.
Streamline selected-proxy testing and creation forms, combine location
presentation, and generate bundled licenses from the npm flag package.

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-10-17 13:21:11 +08:00
ZyyandCodex 9d4c58f967 docs: prioritize official Codex behavior and clarify comments
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 12:36:15 +08:00
ZyyandCodex c67b081ec1 feat(settings): detect request location from server egress
Reuse proxy egress detection for global request location drafts and preserve cached client identity previews.

Co-authored-by: Codex <noreply@openai.com>
2026-10-16 20:02:12 +08:00
ZyyandCodex 1ce9710afa chore(deps): update plugins submodule to v0.4.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-16 19:35:40 +08:00
408 changed files with 14546 additions and 3748 deletions
+17 -30
View File
@@ -1,55 +1,42 @@
---
name: cpr-dev-guide
description: Codex Proxy RS 仓库的开发、修复、排障、代码审查和文档修改入口;PR、Issue、插件创作与发版按任务另用对应技能
description: Codex Proxy RS 前后端开发、排障、代码审查和文档修改入口;改页面、弹窗、样式、文案,修接口、调度、数据库、Rust、宿主 SDK 或 Runtime 时均使用;PR、Issue、独立网关插件与发版另配对应技能
---
# 开发指南
## 加载边界
## 先匹配任务与文件
- 遵守已提供的 [AGENTS.md](../../../AGENTS.md),上下文已有的规则不重复读取
- 下表是条件导航,不是必读清单,只加载当前任务命中的技能与章节,进入新阶段再补读
- 文档链接带 `#章节` 不代表读取工具会自动截取内容,先用 `rg -n '^#{1,4} ' 文件` 找边界,再按范围读取;常规定位不整份加载 CONTRIBUTING、架构、API、主题或全部 references,确需全文审查时再展开
- 每次补读先明确要解决的未知项,已有资料足够就继续工作;文档提及 Rust、Vue、插件或发布,不等于要加载对应开发流程
按用户要改的行为初判,定位后按实际修改或审查的文件复核,不要求用户说出“前端”“后端”或技能名。**命中多行就同时执行,先读取对应规范再修改或给出审查结论**;任务范围变化时补齐新命中项,已有上下文直接复用
## 按任务读取
| 当前任务 | 必读的相关部分 |
| 命中条件,操作或文件满足其一即可 | 必须执行 |
| --- | --- |
| 开发、排障、代码审查 | [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),需要定位代码时按 AGENTS 的 CodeGraph 约定执行 |
| 调整页面、弹窗、表单、按钮、文案、样式或交互;目标在 `frontend/`、`modules/ui/`、插件前端,或是 `.vue` 文件 | [前端流程](references/frontend.md),覆盖组件复用、文案、Token、样式、视觉状态和运行证据;局部修改同样执行 |
| 修改服务端接口、路由、调度、账号与用量规则、持久化、后台任务或协议行为;目标是 `backend/` 中的实现、配置、迁移,或 Rust 源码 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)、所属模块、[后端自审](../../../docs/architecture.md#后端自审);涉及 Rust 时使用 `$rust-best-practices` 和下方后端注释要求,验证时读[命令](../../../docs/architecture.md#验证命令) |
| 修改页面接口调用,或开发、排查、审查面向管理端或模型客户端的 HTTP/WS API 合同 | [API](../../../docs/api.md)对应章节;优先复用已有接口,核对调用方 → 服务端解析 → 实际查询或执行,不能只凭前端类型认定参数生效;按实际改动叠加前后端规范 |
| 模块或调用链变更 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)及所属模块章节,不通读整份架构 |
| Rust 开发或审查 | `$rust-best-practices`、所属模块、[后端自审](../../../docs/architecture.md#后端自审),验证时读[命令](../../../docs/architecture.md#验证命令) |
| 页面开发 | `$frontend-design`、[前端职责](../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../docs/theme.md#界面文案与信息层级)、[界面验证](../../../CONTRIBUTING.md#界面验证);修改已有页面前留存截图,主题算法仅在涉及时读取 |
| 接口、部署或迁移 | 分别定位 [API](../../../docs/api.md)、[部署](../../../deploy/README.md)、[迁移](../../../backend/migrations/README.md)的对应章节 |
| SDK、Runtime、宿主扩展或独立插件 | [插件职责边界](references/plugin-boundaries.md);只有创作、排查独立网关插件时使用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
| 修改部署行为、`deploy/` 配置或数据库迁移 | 按范围读取[部署](../../../deploy/README.md)、[迁移](../../../backend/migrations/README.md)对应章节 |
| 开发、排查或审查宿主 SDK、Runtime、扩展接口或独立插件;目标在 `backend/crates/gateway-plugin/` | [插件职责边界](references/plugin-boundaries.md)及所用 [SDK](../../../backend/crates/gateway-plugin/sdk/README.md) 类型与能力合同;宿主守住自身合同、资源与稳定性,不猜测或补偿插件业务错误;仅独立插件创作、排查另用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
| 修改或审查文档 | [文档检查](references/documentation.md)及目标章节,不默认读取代码开发、页面验收或发布流程 |
| 普通代码审查 | [审查标准](../../../CONTRIBUTING.md#审查标准);审查默认只读 |
| PR / Issue / 发版 | 按当前意图选 [PR](../cpr-github-pr/SKILL.md)、[Issue](../cpr-github-issue/SKILL.md)或[发版](../cpr-release/SKILL.md),普通提交不创建 PR |
纯文档任务按文档分支执行,不因文档所在目录或提及的路径、语言、功能加载开发流程。每次补读只解决当前未知项;文档链接带 `#章节` 不代表工具自动截取,先用 `rg -n '^#{1,4} ' 文件` 找边界,只读命中章节,不预读整份 CONTRIBUTING、架构、API、主题或全部 references,全文审查除外
## 参考仓库的优先级
- 涉及 Codex 行为、协议或兼容性时,以官方 [openai/codex](https://github.com/openai/codex) 对应版本的源码与调用链为第一手资料,先查官方实现
- 优先使用用户提供或本机已有的官方仓库,核对来源、版本或提交;资料不足时再查官方对应版本的源码、测试与文档
- 三方仓库只能提供排查线索或实现参考,不能代替官方行为依据;与官方不一致时回到官方源码核实,不直接照搬三方结论
- 结论注明适用版本或提交,区分官方事实、三方参考与本项目推断;官方证据不足时保留不确定性
开发、排障和代码审查先按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核实问题与方案。涉及 Codex 行为时执行其中的官方行为优先约束:对应版本的官方源码优先,以透明转发和最小必要适配为默认方案,先判断偏离是否必要再评价实现
## 执行与收尾
1. 确认工作区、任务范围与已有改动,先定位事实归属、同类实现和调用链,再修改;编码前只读[项目约定](../../../CONTRIBUTING.md#项目约定)的对应条目
2. 自审完整差异,处理本次引入的职责越界、重复规则、冗余状态与无依据分支;仅审查时报告,不自行修复
3. 按变更范围读取[验证](../../../CONTRIBUTING.md#验证),区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
4. 交付说明实际结果与验证缺口;页面证据和后端生命周期检查按上表执行,不把构建通过当作行为验收
1. 遵守已提供的 [AGENTS.md](../../../AGENTS.md),按其 CodeGraph 约定定位代码;确认工作区、任务范围与已有改动,找到事实归属、同类实现和调用链,补齐上表命中规范及[项目约定](../../../CONTRIBUTING.md#项目约定)对应条目后再修改
2. 自审完整差异,处理本次引入的职责越界、重复规则、冗余状态与无依据分支;删除或替换功能时一并清理失效的配置、类型、调用、测试与文档,已部署数据和公开合同仍遵守所属迁移及兼容规则;仅审查时报告,不自行修复
3. 常规文档只写当前状态;有文档差异时执行[文档检查](references/documentation.md),没有时只确认现有说明是否失真,普通开发不自动修改 `release/notes.md`
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,沿用本轮有效证据与用户已确定的验收分工,区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
5. 交付实际结果与验证缺口;前端按[前端流程](references/frontend.md)核对最终差异和运行证据,后端按上表检查生命周期,不把构建通过当作行为验收
## 后端注释
- 每个 Rust 文件顶层使用简短的中文 `//!` 注释说明文件职责,覆盖生产源码、测试与构建脚本;测试文件说明测试范围或辅助用途,生成文件同步维护生成器
- 注释不使用中文或英文句号,多句说明按语义分行;保留 URL、版本号、标识符和代码示例中有语义的点号
- 文件说明聚焦当前功能与职责边界,局部注释解释原因和约束,避免复述代码或记录修改经过
## 文档硬约束
**常规文档只描述当前状态,不写变更历史**,这项检查适用于每次开发收尾,不限于专门的文档任务
- 动笔前确定读者、所属章节与必须说明的当前事实,没有必要就不改文档
- 有文档差异时执行[文档检查](references/documentation.md),逐段决定保留、改写、删除或移入任务报告,未完成内容审查不能称文档已检查
- 没有文档差异时只确认现有说明是否失真;普通开发不自动修改 `release/notes.md`
@@ -1,6 +1,6 @@
interface:
display_name: "cpr-dev-guide"
short_description: "仓库开发、修复、排查、代码审查与文档修改的流程入口"
default_prompt: "使用 $cpr-dev-guide 按当前任务定位问题与现有实现,完成已授权的开发或审查,并自审、验证和报告结果。"
short_description: "前后端开发、排障与审查入口,按任务和文件匹配规范并验证"
default_prompt: "使用 $cpr-dev-guide 按请求和实际文件匹配前后端规范,读取命中项后完成已授权的开发或审查,并自审、验证和报告结果。"
policy:
allow_implicit_invocation: true
@@ -1,16 +1,10 @@
# 文档检查
仅在准备修改文档或最终差异含文档时读取,职责与行文依据是 [文档职责与更新条件](../../../../CONTRIBUTING.md#文档职责与更新条件),只读该节
修改或审查文档时读取,职责与行文统一按 [文档职责与更新条件](../../../../CONTRIBUTING.md#文档职责与更新条件),只读该节
## 写之前
先明确这段内容服务谁、解决哪个当前使用或维护问题、属于哪个现有章节,再决定详略
接口保留调用合同,架构保留职责与不变量,部署保留操作与排障;同一事实只在所属位置完整说明,其他位置链接
常规文档在原章节修订,不追加本次变更小节,不为了“同步文档”补写修复总结
内部重构、实现经过、前后对比、验证结果应留在 PR、Issue 或任务报告
先确定读者、必须说明的当前事实与所属章节,再决定是否需要修改及详略;文档职责与更新条件按上节核对
## 逐段验收
@@ -30,9 +24,8 @@
## 交付检查
- 检查新段落的必要性、归属、重复与当前状态表达,发现变更叙述先改完再交付
- 标题、短列表、表格和独立短段通常不加句号,长段按主题拆分,命令与字段保持准确
- 完成上述逐段验收及[行文检查](../../../../CONTRIBUTING.md#层次与行文),发现问题先处理,未完成内容审查不能称文档已检查
- 检查相对链接、章节锚点、表格与命令示例,`git diff --check` 或构建通过不能代替内容审查
- 报告实际覆盖和缺口即可,不把检查记录写进常规文档
只有明确的发布说明任务才进入 `cpr-release`,`release/notes.md` 用于该次发行,不向 README、API、架构或部署文档复制版本变化列表
只有明确的发布说明任务才进入 `cpr-release`;发布说明与常规文档的边界按[内容归属](../../../../CONTRIBUTING.md#内容归属)执行
@@ -0,0 +1,32 @@
# 前端开发与审查
适用于页面、组件、弹窗、表单及局部文案和样式修改,不以是否创建 PR 为触发条件;仅审查时核对和报告,不自行修改
## 开始前
读取[前端职责](../../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../../docs/theme.md#界面文案与信息层级)和[界面验证](../../../../CONTRIBUTING.md#界面验证),明确本次用户操作、原页面、最接近的同类实现及可复用基础组件;按本轮验收分工留存所需截图
用户要求看效果或自行验收时,按[源码联调](../../../../docs/development.md#源码联调)提供当前工作区的可访问预览与页面入口,确认热更新和依赖来源;需要模拟数据时用于实际页面并标明,不把静态稿、旧构建或仅有本机文件路径当作可用预览
按改动补读,不把“未修改主题算法”当作跳过页面样式规范的理由:
| 改动 | 规范入口 |
| --- | --- |
| Vue 逻辑、请求或状态 | [项目约定](../../../../CONTRIBUTING.md#项目约定)的前端条目及上述前端职责 |
| 颜色、间距、字体、边框、阴影、局部 CSS 或交互样式 | [通用颜色消费](../../../../docs/theme.md#通用颜色消费)、[样式与命名约定](../../../../docs/theme.md#样式与命名约定),核对无边常态及 Hover、Focus 等视觉状态 |
| 主题算法、编辑器或共用组件视觉行为 | 所改主题章节及[扩大验证范围](../../../../docs/theme.md#验证) |
| 明确指定设计技能,或建立、重塑视觉方向 | 使用指定技能或 `$frontend-design`,设计取舍仍以项目主题、用户任务和已有页面为依据 |
普通维护和新增业务页面沿用现有设计;设计技能中的配色、字体、布局探索不覆盖项目规范,主题算法只在涉及时读取
## 交付前
对照最终差异和实际页面逐项核对,不以读取过规范代替检查:
- **复用与范围**:是否沿用参考页面和基础组件,已有组件能承载时不手写替代控件;共用缺陷按职责修复,修复后清理本次已失效的页面补丁,不顺带重排无关区域或新增重复入口
- **文案与层级**:标点是否合规,标题、状态和帮助是否重复,详细说明与当前决策必需提示的位置是否合适
- **样式与状态**:是否使用对应 Token 与 utility,局部样式是否符合适用边界,受影响的主题、窄屏及交互状态是否正确
- **查询与动作**:区分首次加载、刷新、分页、单行与批量操作,核对 loading、禁用状态的影响范围;沿用请求层错误处理,不在页面叠加同一错误的提示
- **运行证据**:按界面验证核对截图与实际交互,证据对应当前实现;页面 PR 的可访问图片和 Draft 条件按该节执行
交付简述所复用的页面或组件、实际验证范围及缺口;环境不足时如实说明,不能仅凭构建、源码或截图推断未验证的行为
@@ -3,9 +3,13 @@
仅在开发或审查 SDK、Runtime、宿主扩展接口及独立网关插件时读取
先看[插件扩展](../../../../docs/architecture.md#31-插件扩展)中的职责与执行边界,不通读整份架构
**宿主可以暴露底层能力,不能替插件完成业务**
**宿主提供可组合能力,守住自身合同、资源与稳定性;插件业务正确性和兼容修复由插件负责**
- 宿主提供可独立组合的受控原生能力,负责授权、资源隔离、事务与审计,复用已有服务与状态归属
- 插件自身的触发条件、跨资源关联、策略和流程编排由插件实现,不能把某个使用场景做成宿主专属流程、状态或补偿逻辑
- 发现能力缺口时,界定可独立使用的基础接口,说明输入输出、权限、事务、失败及重复调用语义,不夹带插件私有数据模型或工作流
- 自审调用链、合同、测试与文档中的业务判断、自动串联和专属状态,仅换成通用名称不算解除耦合
- 插件行为不可预测,只保留宿主安全解码、权限、资源限制及一致性所需的检查,不为插件业务正确性增加语义校验、未知字段拒绝或重复检查;失败按已有调用、会话、实例边界及能力策略拒绝、隔离和回收,不把单次业务错误扩大为全局停用
- 不靠猜测插件意图、补造业务字段、修正业务结果、重放操作或增加插件专属兼容分支让错误插件继续工作;问题归插件时在插件侧修复,不把补偿搬进宿主 SDK 或 Runtime
- 通用协议与会话辅助、公开合同规定的默认值和失败恢复仍按既有合同执行,不能因拒绝业务兜底而删掉必要的宿主保护;故障隔离按[信任与资源边界](../../../../docs/architecture.md#信任与资源边界)评估,不假定完整信任的插件具有安全沙箱
- 开放已有能力时复用宿主服务与数据模型,不为插件的导入、修正、重算等编排新增表、字段、账本或任务系统;确有宿主能力缺口时,界定可独立使用的基础接口及输入输出、权限、事务、失败和重复调用语义,不夹带插件私有模型与工作流
- 新增防御、兼容、重试、缓存或状态前,说明要保护的宿主不变量、触发依据和最小处理范围;仅为迁就某个插件或猜测未来行为时不加入宿主
- 自审调用链、合同、测试与文档中的业务判断、自动串联和专属状态,仅换成通用名称不算解除耦合;验证失败与取消后的资源释放、状态一致性和其他调用受影响范围,不以插件恢复成功代替宿主边界验证
+3 -8
View File
@@ -20,7 +20,7 @@ description: 草拟、创建、补充或回复 GitHub Issue,包括缺陷、功
- 其他仓库读取它自己的相关规则与实际模板,长文档先定位标题再截取,不预读全部模板或 CONTRIBUTING
- 搜索同场景 open/closed Issues、相关 PR 和发布记录,区分主分支修复与已发布修复,不凭标题判断重复;没有新证据时返回已有链接,用户仍要求新建则按其要求处理
- 仅当需要额外排查或修改时使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),提交症状反馈不以找到根因为前提
- 引用上游行为或提出方案时读取[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考;注明适用版本、来源与缺口
- 引用上游行为或提出方案时执行[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),注明适用版本、来源与缺口
## 正文与证据
@@ -36,16 +36,11 @@ YAML 表单按 `attributes.label` 和字段顺序组织正文,手工核对必
附件须获授权且可访问,本机路径不算附件;labels 和 assignees 仅按模板或用户要求设置,并确认实际存在
Issue 可记录复现、排查和变更经过,不因此向常规文档追加历史;确需改文档时执行[文档检查](../cpr-dev-guide/references/documentation.md)
Issue 可记录复现、排查和变更经过;涉及常规文档修改时执行[文档检查](../cpr-dev-guide/references/documentation.md)
## 回复口吻
草拟和发布 Issue 回复时遵守以下约定:
- 用第一人称“我”表达,称呼对方为“佬友”,称呼自然出现即可,不逐句重复,也不用“我们”“本助手”代称自己
- 语气客气、委婉,直接回应对方的具体问题;补充信息或提出不同意见时说明缘由,避免命令式、责备式表达
- 使用自然短句,简单回复用短段,只有并列信息较多时才分点;少用句号,避免客服套话、机械模板和空泛保证
- 只把实际完成的排查、复现和验证写成“我已……”;未确认的事实如实说明,委婉表达也要说清结论、限制和下一步
草拟和发布 Issue 回复时,执行 [GitHub 回复口吻](../../../CONTRIBUTING.md#github-回复口吻)
## 发布与收尾
+9 -12
View File
@@ -15,36 +15,33 @@ description: 准备、创建、更新或审查 GitHub PR,含回复讨论与处
本仓库目标分支为 `main`,其他仓库使用其可信基线规则与模板,PR 内修改的规则不能降低审查要求
涉及上游行为的判断按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)执行
## 按模式读取
只加载当前模式,已有上下文不重读;长文档先定位标题再截取对应章节,不因链接存在而加载全文或所有 references
| 当前动作 | 读取与执行 |
| --- | --- |
| 只整理标题或描述 | 当前 PR、最终 diff、实际模板、[AI 披露](../../../CONTRIBUTING.md#ai-使用披露),在授权范围内改文案;不读取推送或审查流程 |
| 只整理标题或描述 | 当前 PR、最终 diff、实际模板、[AI 披露](../../../CONTRIBUTING.md#ai-使用披露),在授权范围内改文案,不自行修改实现;不读取推送或审查流程 |
| 创建或更新 PR | [创建流程](references/create.md)、[PR 流程](../../../CONTRIBUTING.md#pr-流程)与[规则文件保护](../../../CONTRIBUTING.md#规则文件保护) |
| 审查 PR | [审查流程](references/review.md)、[审查标准](../../../CONTRIBUTING.md#审查标准) |
| 回复讨论或审查意见 | 目标评论、相关上下文与实际处理结果,按下方回复口吻答复;仅回复不加载创建或完整审查流程 |
| 需要修改代码或文档 | 使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的对应分支,完成自审与验证 |
| 含页面改动 | [界面验证](../../../CONTRIBUTING.md#界面验证),必需截图须可访问且对应当前实现 |
| 含文档改动 | [文档检查](../cpr-dev-guide/references/documentation.md),常规文档只说明当前状态,变更经过留在 PR |
| 含前端改动 | [前端流程](../cpr-dev-guide/references/frontend.md),核对适用规范与当前实现的运行证据 |
| 含文档改动 | [文档检查](../cpr-dev-guide/references/documentation.md) |
## 回复口吻
草拟和发布 PR 讨论、审查意见的回复时遵守以下约定:
- 用第一人称“我”表达,称呼对方为“佬友”,称呼自然出现即可,不逐句重复,也不用“我们”“本助手”代称自己
- 语气客气、委婉,直接回应对方的具体问题;补充信息或提出不同意见时说明缘由,避免命令式、责备式表达
- 使用自然短句,简单回复用短段,只有并列信息较多时才分点;少用句号,避免客服套话、机械模板和空泛保证
- 只把实际完成的修改和验证写成“我已……”;未采纳的意见说明依据,待处理或未验证的内容如实交代,委婉表达也要说清结论和下一步
草拟和发布 PR 讨论、审查意见的回复时,执行 [GitHub 回复口吻](../../../CONTRIBUTING.md#github-回复口吻)
## 创建与审查的公共核对
- 按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核实触发条件、发生版本与目标分支现状;涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考
- 按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核实触发条件、发生版本与目标分支现状,分别判断问题是否成立、是否属于目标项目职责及方案是否必要
- 核对最新目标分支及同场景 open/closed PR、Issue,按实际行为和测试判断重复,不凭标题下结论,也不擅自关闭他人 PR
- 自审或审查覆盖完整差异与调用链,文档逐段检查必要性、职责、重复和当前状态表达,构建与链接检查不能代替内容判断
- 自审或审查覆盖完整差异与调用链,前端与文档按模式表检查
- 核对[AI 使用披露](../../../CONTRIBUTING.md#ai-使用披露),填写实际工具与模型,不猜型号;必需截图或型号待补时保留 Draft
- 验证结果对应实际 head,区分通过、失败、跳过与未执行;无文档改动时只检查现有说明是否失真
- 验证结果对应实际 head,区分通过、失败、跳过与未执行;无文档改动时只核对现有说明是否失真
远程操作优先使用可用连接器,`gh` 显式指定 `--repo`,多行正文使用结构化字段或 `--body-file`
@@ -6,19 +6,13 @@
不用提交日志拼接正文,也不假定仓库一定有模板。
2. 更新并确认 base/head,查看相对 merge-base 的完整差异和待提交内容;核对仓库的受保护文件与准入规则。
只纳入任务范围的文件和提交;需要整理时用独立功能分支,保留用户其他改动,不擅自改写已发布历史。
3. 按入口的公共核对,在目标分支基线核实问题,追踪官方与本项目的相关调用链,先确定解决方向再实现。
若改动已完成,也要据此复核问题与方案;需要修正时只修改已授权的范围。仅整理标题或正文时报告差异与缺口,
不自行修改实现。原问题不成立时不将它包装成已修复的缺陷;证据不足时明确保留结论。
按 `cpr-dev-guide` 完成实现自审;后端按 [后端自审](../../../../docs/architecture.md#后端自审) 核对归属、状态、失败出口与副作用,
页面先复用已有设计并检查文案密度。处理本次引入的冗余与越界问题,功能可用和检查通过不能替代可维护性自查。
3. 已完成的改动也须按入口公共核对复核问题与方案。原问题不成立时不包装成已修复的缺陷,证据不足时保留结论。
实现与自审按 [cpr-dev-guide](../../cpr-dev-guide/SKILL.md)执行,包括适用的后端生命周期和前端检查;需要修正时只修改已授权范围。
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
更新现有标题或正文须在本次整理/更新请求范围内。
5. 按[验证](../../../../CONTRIBUTING.md#验证)执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
有文档差异时按[文档检查](../../cpr-dev-guide/references/documentation.md)检查最终 diff;没有文档差异时,核对行为变化是否使现有说明失真。
页面改动按入口链接的界面验证要求补齐截图与实际交互证据,核对模型披露是否完整。
5. 按[验证](../../../../CONTRIBUTING.md#验证)执行适用检查,记录对应提交或工作区状态;文档、页面证据与模型披露按入口核对。
6. 将任务范围内的提交推送到正确远端,核对远端 head。标题遵循项目约定,正文先说明问题成立的依据和最终行为,
再给必要的设计取舍、自审结论、验证与遗留事项,按模板附页面截图和 AI 使用情况。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
本次实现与它的关系及必要的偏离原因;区分源码推断和实际复现,不用检查通过替代方案论证。按规模压缩,不强填无关章节。
再给必要的设计取舍、自审结论、验证与遗留事项,按模板附页面截图和 AI 使用情况。涉及上游行为时,按[问题与方案依据](../../../../CONTRIBUTING.md#问题与方案依据)写明官方源码位置、版本、处理方式及本次方案关系。按规模压缩,不强填无关章节。
7. 明确要求草稿或仍有阻塞项时创建或保持 Draft PR,并解释原因;必需截图或 AI 模型披露待补时也不标为可审阅。具备审查条件且未要求草稿时创建普通 PR。
GitHub Draft 状态与“只写本地草稿正文”不同,后者不创建远程对象。
8. 创建或更新后回读正文与附件,核对链接、base、head、草稿状态、模型披露和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
@@ -32,4 +26,4 @@ gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
按需添加 `--draft`。不要用 `gh pr create --dry-run` 做只读预览,它仍可能推送 Git 变更。
多行正文通过文件或连接器的结构化字段传递,不把未转义正文拼进 shell 命令。
创建完成返回链接、目标分支和验证摘要;不要称为已合并或已获维护者批准。
创建完成返回链接、目标分支和验证摘要,不称为已合并。
@@ -4,28 +4,18 @@
1. 获取 PR 的 base/head SHA、描述、文件差异、已有讨论及 CI 状态。记录实际审查的 head,使用 merge-base 差异。
需要源码上下文时先遵守目标仓库的定位规则;需检出时使用隔离工作区,保留用户当前分支和运行环境。
2. 先核实 PR 要解决的问题是否成立:提取触发场景、预期行为和声称的原因,在目标分支基线检查实际行为。
按入口的公共核对收集一手证据,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
3. 从官方方案评估解决方向:说明官方如何处理同一场景,检查 PR 是否遵循其协议语义和职责归属、
是否复用已有能力或正确传递官方合同;核对偏离官方方案的理由与运行证据。
审查 OpenAI 官方上游与 Codex 的对接时,默认官方返回与 Codex 兼容、直接对接正常,重点检查本项目是否误解、
改写或破坏官方合同。不得仅凭自行构造的异常上游数据揣测官方不兼容,也不据此报告缺陷、提出改进建议,
或要求增加防御分支与测试。讨论特定版本或场景的对接例外须有真实响应或实际复现证据,并区分代理链路与官方直连。
4. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
2. 按入口公共核对在目标分支基线核实问题,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
3. 按[问题与方案依据](../../../../CONTRIBUTING.md#问题与方案依据)评估解决方向,涉及上游时说明官方处理方式及本次方案关系;缺少必要性依据的偏离明确指出,不因测试通过就认定方案合理。
4. 按[审查标准](../../../../CONTRIBUTING.md#审查标准)追踪完整调用链,找到共享业务事实、同类实现及已有验证。
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
不能仅因功能可用、CI 通过而略过,也不把个人写法偏好判为缺陷。
5. UI 改动同时对照原页面、主题、共用组件和同类页面,核对结构变化的必要性、文案是否重复及帮助是否适合收进 Popover。
按页面类型检查必需截图是否齐全、可访问且对应待审实现;缺少必需截图时要求补齐并列为未具备审阅条件。
浏览器可用时检查相关交互;未运行的检查列为验证缺口,不从截图或源码推断所有状态都正确。
5. 前端改动执行[前端流程](../../cpr-dev-guide/references/frontend.md),浏览器可用时检查相关交互;缺少必需截图时要求补齐并列为未具备审阅条件。
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
有文档差异时按[文档检查](../../cpr-dev-guide/references/documentation.md)检查最终 diff,不能把构建或链接检查通过当作文档内容已经合格。
文档差异按[文档检查](../../cpr-dev-guide/references/documentation.md)核对最终 diff。
核对 AI 使用与模型披露,缺项明确指出。工具不可用、集成测试跳过或环境不足时具体说明影响范围。
7. 按 [审查标准](../../../../CONTRIBUTING.md#审查标准) 用中文先回答原问题是否成立、解决方向是否合理,
再列出实现问题与验证缺口,结论附具体依据。
实现问题区分必须修复项、可选建议和验证缺口;没有的类别可省略。
每个问题给变更中的文件位置、触发条件或规范依据、影响和最小修复方向。
复用建议指出具体已有实现,不把个人偏好写成缺陷;未发现问题时直接说明并交代覆盖范围。
7. 按审查标准用中文输出问题是否成立、方案是否合理及对应依据,再列实现问题与验证缺口;复用建议指出具体已有实现。
没有问题的类别可省略,未发现问题时直接说明并交代覆盖范围。
8. 结束前核对 head 是否变化。发生变化时检查新增差异并更新结论,或明确结论只适用于原 SHA。
审查本身不修改源码、不发表评论、不提交 GitHub 的批准或请求修改状态。
+2 -2
View File
@@ -14,7 +14,7 @@ description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改
- 确认用途、工程位置、目标宿主版本与平台,沿用已有工程;位置无法推断时只补齐该信息,不把插件源码塞进宿主 workspace
- 核对运行宿主、[SDK 版本](../../../backend/crates/gateway-plugin/sdk/Cargo.toml)、[宿主支持清单](../../../backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json)及所用 CLI,清单、进程和能力版本分别判断
- `contributes` 声明处理器,`bindings` 选择挂载与匹配范围;安装成功不等于功能生效。当前清单拒绝 `permissions`,安装者完整信任插件,`trustedProcess` 不提供操作系统沙箱或访问域隔离,见[完整信任](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#完整信任)
- 涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考;宿主能力以目标版本的 SDK 为准
- Codex 兼容性适配按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)执行,不因位于插件中扩大改写或补偿范围;业务改写限于用户授权,宿主能力以目标 SDK 为准
## 按当前步骤读取
@@ -29,7 +29,7 @@ description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改
| 构建或打包 | [构建与打包](references/development.md#构建与打包) |
| 实际验收 | [验证与交付](references/development.md#验证与交付),按实现范围验证 |
| 分发插件 | [发布与安装来源](references/development.md#发布与安装来源),不自动进入宿主发版流程 |
| 修改插件文档 | [文档检查](../cpr-dev-guide/references/documentation.md),只写当前用法、合同和限制,不附修复过程 |
| 修改插件文档 | [文档检查](../cpr-dev-guide/references/documentation.md) |
## 交付
@@ -58,7 +58,7 @@ cargo build --manifest-path /path/to/plugin/Cargo.toml --release --locked --targ
需要从目标宿主源码构建工具时,在主仓根目录运行以下命令,把 `--help` 换成实际参数即可,不必全局安装:
```bash
cargo +1.97.0 run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
cargo +1.99.1 run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
```
直接打包的示意命令:
@@ -1,8 +1,7 @@
# 插件页面
仅在创建或修改插件页面时读取,同时使用[开发入口](../../cpr-dev-guide/SKILL.md)的页面分支
仅在创建或修改插件页面时读取,先执行[前端流程](../../cpr-dev-guide/references/frontend.md),再核对以下插件特有合同
- 沿用已有插件页面与 [管理端主题](../../../../docs/theme.md#界面文案与信息层级) 的设计和文案层级,详细帮助使用现有 Popover;页面 PR 按 [界面验证](../../../../CONTRIBUTING.md#界面验证) 提供实际截图
- 优先参考独立示例仓库的 `examples/workbench/frontend/src/api/`:业务路由放在 `modules/`,`request.ts` 封装公开宿主桥;按需使用 `@codex-proxy/ui` 包出口,不跨仓导入宿主或 UI 的内部源码
- Vue 页面保留 SFC,逻辑、脚本和配置使用 TypeScript;只实现业务内容,标题和副标题由 `ManagementPage` 交给宿主显示
- 隔离页面通过 `window.codexProxyPlugin.request` 调用已注册管理路由;`models.responses` 通过普通请求链交付 JSON/SSE,支持取消且不暴露 Key 明文。不直接 `fetch` 宿主、不读取管理 Cookie 或借用宿主 Vue 实例
+2 -1
View File
@@ -31,8 +31,9 @@ description: Codex Proxy RS 的版本规划、发布说明、正式或预发行
## 必须保持的边界
- Codex 兼容性变化按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核对已有依据与验证范围,缺少依据列为发行风险,不以 CI 通过代替
- `release/publish` 会提交、打 tag、推送并触发工作流,没有只读预演模式,不能用于探测或演练
- 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突
- 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对
- `release/notes.md` 只承载当前发行的说明,常规文档只写当前状态,不追加版本历史;其变更执行[文档检查](../cpr-dev-guide/references/documentation.md)
- `release/notes.md` 只承载当前发行的说明;常规文档修改另按开发入口执行[文档检查](../cpr-dev-guide/references/documentation.md)
- 交付版本、提交、Release/工作流链接与实际结果,未完成构建或缺失产物时不称发布成功
+6 -1
View File
@@ -12,10 +12,15 @@ runs:
with:
package_json_file: frontend/package.json
- name: Read build toolchains
id: toolchains
shell: bash
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
node-version: ${{ steps.toolchains.outputs.node }}
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
@@ -154,6 +154,21 @@ runs:
"${compose[@]}" exec -T codex-proxy-rs sh -c \
'touch /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write && rm /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write'
- name: Verify application restart after installer permissions
shell: bash
run: |
set -euo pipefail
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/compose.yaml)
"${compose[@]}" stop --timeout 30 codex-proxy-rs
# 使用安装器原函数,验证已有备份暂存目录与日志不会因所有权变更而阻止启动
(
export INSTALL_DIR="$PWD"
source deploy/install.sh
fix_runtime_permissions
)
"${compose[@]}" up -d --no-build --wait codex-proxy-rs
- name: Verify clean logs and graceful shutdown
shell: bash
run: |
+79
View File
@@ -0,0 +1,79 @@
require 'digest'
require 'fileutils'
require 'json'
# 归档与镜像消费相同二进制和封口清单,不在打包时重新解释插件分发策略
module ReleasePackage
APP_NAME = 'codex-proxy-rs'
MANIFEST = 'dist/plugin-release-assets/plugin-release-manifest.json'
def self.install_manifest(directory)
manifest = JSON.parse(File.read(MANIFEST))
unless manifest['schema_version'] == 1 && manifest['sealed'] == true && manifest['plugins'] == []
raise 'Expected a sealed host manifest without bundled plugins'
end
FileUtils.mkdir_p(directory)
destination = File.join(directory, File.basename(MANIFEST))
FileUtils.cp(MANIFEST, destination)
File.chmod(0o444, destination)
File.chmod(0o555, directory)
end
def self.archives
platforms = JSON.parse(ENV.fetch('PLATFORM_MATRIX')).fetch('include')
release_dir = 'dist/release'
FileUtils.rm_rf(release_dir)
FileUtils.mkdir_p(release_dir)
# 上一次打包生成的只读清单目录需要先恢复写权限
FileUtils.chmod_R('u+w', 'package') if File.directory?('package')
FileUtils.rm_rf('package')
config = 'deploy/config.example.yaml'
FileUtils.cp(config, release_dir)
compose = File.read('deploy/compose.yaml')
image = "#{ENV.fetch('REGISTRY')}/#{ENV.fetch('IMAGE_NAME')}"
default_image = "${CPR_IMAGE:-#{image}:latest}"
raise 'Expected exactly one default application image in deploy/compose.yaml' unless compose.scan(default_image).length == 1
File.write("#{release_dir}/compose.yaml", compose.sub(default_image, "${CPR_IMAGE:-#{image}:#{ENV.fetch('DOCKER_VERSION')}}"))
FileUtils.cp(MANIFEST, release_dir)
platforms.each do |platform|
raise "Unsupported archive format: #{platform['archive']}" unless platform['archive'] == 'tar.gz'
name = "#{platform.fetch('os')}-#{platform.fetch('arch')}"
binary = platform.fetch('binary')
root = "package/#{name}/#{APP_NAME}"
FileUtils.mkdir_p(["#{root}/web/dist", "#{root}/deploy"])
FileUtils.cp("dist/binaries/#{APP_NAME}-#{name}/#{binary}", "#{root}/#{binary}")
File.chmod(0o755, "#{root}/#{binary}")
FileUtils.cp_r('dist/frontend/.', "#{root}/web/dist")
FileUtils.cp(config, "#{root}/deploy")
install_manifest("#{root}/plugins/official")
archive = "#{release_dir}/#{APP_NAME}_#{ENV.fetch('VERSION')}_#{platform['os']}_#{platform['arch']}.tar.gz"
raise "Failed to create #{archive}" unless system('tar', '-czf', archive, '-C', File.dirname(root), APP_NAME)
end
checksums = Dir.glob("#{release_dir}/*").sort.map { |path| "#{Digest::SHA256.file(path).hexdigest} #{File.basename(path)}\n" }
File.write("#{release_dir}/checksums.txt", checksums.join)
end
def self.image
platform = "#{ENV.fetch('MATRIX_OS')}-#{ENV.fetch('MATRIX_ARCH')}"
target = "dist/docker/#{platform}"
FileUtils.mkdir_p(target)
FileUtils.cp("dist/docker-artifacts/#{APP_NAME}-#{platform}/#{ENV.fetch('MATRIX_BINARY')}", "#{target}/#{APP_NAME}")
File.chmod(0o755, "#{target}/#{APP_NAME}")
install_manifest("#{target}/plugins/official")
end
end
if $PROGRAM_NAME == __FILE__
begin
case ARGV
when ['archives'] then ReleasePackage.archives
when ['image'] then ReleasePackage.image
else raise 'usage: package-release.rb archives|image'
end
rescue StandardError => error
warn "error: #{error.message}"
exit 1
end
end
+84
View File
@@ -0,0 +1,84 @@
require_relative 'release-metadata'
# 构建和验证可以并行;调用方必须在仓库级发布锁内执行本脚本
module ReleasePublication
def self.run(*command)
output, error, status = Open3.capture3(*command)
raise "#{command.first} failed: #{error.strip}" unless status.success?
output.strip
end
def self.latest_release(repository)
output, error, status = Open3.capture3('gh', 'api', '--include', "repos/#{repository}/releases/latest")
code = output.lines.first.to_s.split[1]
return nil if code == '404'
raise "Cannot read latest GitHub release: #{error.strip}" unless status.success? && code == '200'
JSON.parse(output.split(/\r?\n\r?\n/, 2).fetch(1)).fetch('tag_name')
end
def self.latest_image(image)
ref = "#{image}:latest"
output, error, status = Open3.capture3('docker', 'buildx', 'imagetools', 'inspect', ref, '--format', '{{json .Image}}')
# 只把明确的缺失标签当作首次发布,鉴权和网络错误必须终止提升
return nil if !status.success? && error.include?("#{ref}: not found")
raise "Cannot read latest image: #{error.strip}" unless status.success?
versions = JSON.parse(output).values.map do |config|
config.fetch('config').fetch('Labels').fetch('org.opencontainers.image.version')
end.uniq
raise 'Latest image must have one consistent version across platforms' unless versions.length == 1
versions.first
end
def self.can_promote?(version, current_versions)
return false if ReleaseMetadata.prerelease?(version)
current_versions.compact.all? do |current|
raise "Latest points to a prerelease: #{current}" if ReleaseMetadata.prerelease?(current)
comparison = ReleaseMetadata.sort_key(version) <=> ReleaseMetadata.sort_key(current)
# 构建元数据不决定版本先后;同优先级只允许相同版本重试
comparison.positive? || (comparison.zero? && ReleaseMetadata.version(current) == version)
end
end
def self.tag(image, digest, name)
ref = "#{image}:#{name}"
run('docker', 'buildx', 'imagetools', 'create', '-t', ref, "#{image}@#{digest}")
actual = run('docker', 'buildx', 'imagetools', 'inspect', ref, '--format', '{{.Manifest.Digest}}')
raise "Promoted image digest changed: #{ref}" unless actual == digest
end
def self.publish(version:, image:, digest:, repository:, release_id:)
version = ReleaseMetadata.version(version)
raise 'Invalid image digest' unless digest.match?(/\Asha256:[a-f0-9]{64}\z/)
raise 'Invalid GitHub release ID' unless release_id.match?(/\A\d+\z/)
endpoint = "repos/#{repository}/releases/#{release_id}"
# 附件上传及镜像证明、签名已完成,再公开版本入口;失败可从本 job 重跑
tag(image, digest, version.tr('+', '-'))
run('gh', 'api', '--method', 'PATCH', endpoint, '-F', 'draft=false', '-f', 'make_latest=false')
return if ReleaseMetadata.prerelease?(version)
current_versions = [latest_release(repository), latest_image(image)]
unless can_promote?(version, current_versions)
puts "Published #{version}; latest remains at #{current_versions.compact.join(', ')}"
return
end
# 两个服务不能原子更新;同时检查两侧版本,让失败重试可以补齐而旧版不能回退
tag(image, digest, 'latest')
run('gh', 'api', '--method', 'PATCH', endpoint, '-f', 'make_latest=true')
puts "Published #{version} and promoted latest"
end
end
if $PROGRAM_NAME == __FILE__
begin
ReleasePublication.publish(
version: ENV.fetch('VERSION'), image: "#{ENV.fetch('REGISTRY')}/#{ENV.fetch('IMAGE_NAME')}",
digest: ENV.fetch('DIGEST'), repository: ENV.fetch('GITHUB_REPOSITORY'),
release_id: ENV.fetch('RELEASE_ID')
)
rescue StandardError => error
warn "error: #{error.message}"
exit 1
end
end
+129
View File
@@ -0,0 +1,129 @@
require 'fileutils'
require 'json'
require 'open3'
require 'rubygems'
require 'time'
require 'yaml'
# 本地入口与发行工作流共用版本、说明和平台合同
module ReleaseMetadata
APP_NAME = 'codex-proxy-rs'
SEMVER = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:alpha|beta|rc|exp)\.[1-9]\d*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z/
def self.version(raw)
value = raw.delete_prefix('v')
raise "Invalid release version: #{raw}" unless value.match?(SEMVER)
value
end
def self.prerelease?(value)
version(value).split('+', 2).first.include?('-')
end
def self.sort_key(value)
Gem::Version.new(version(value).split('+', 2).first)
end
def self.validate_notes(value)
tag = "v#{version(value)}"
lines = File.readlines('release/notes.md')
unless lines.first&.strip == "# #{tag}" && !lines.drop(1).join.strip.empty?
raise "release/notes.md: expected non-empty notes starting with '# #{tag}'"
end
end
def self.git(*arguments)
output, status = Open3.capture2('git', *arguments)
raise "git #{arguments.join(' ')} failed" unless status.success?
output.strip
end
def self.platforms
entries = YAML.load_file('release/platforms.yaml').fetch('platforms')
raise 'release/platforms.yaml: platforms must be a non-empty array' unless entries.is_a?(Array) && !entries.empty?
seen = {}
entries.each do |entry|
raise 'Invalid release platform' unless entry.is_a?(Hash)
%w[os arch runner target binary archive].each do |key|
raise "Invalid platform #{key}" unless entry[key].is_a?(String) && !entry[key].strip.empty?
end
key = "#{entry['os']}-#{entry['arch']}"
raise "Duplicate release platform: #{key}" if seen[key]
seen[key] = true
raise "Unsupported archive: #{entry['archive']}" unless entry['archive'] == 'tar.gz'
if entry['docker'] == true
platform = entry['dockerPlatform']
raise "Missing Docker platform: #{key}" unless platform.is_a?(String) && !platform.strip.empty?
entry['dockerPlatform'] = platform.strip
end
entry['artifact'] = "#{APP_NAME}-#{key}"
end
raise 'At least one Docker platform is required' unless entries.any? { |entry| entry['docker'] == true }
entries
end
def self.outputs(tag)
value = version(YAML.load_file('release/version.yaml').fetch('version'))
raise "Release tag #{tag} does not match v#{value}" unless tag == "v#{value}"
validate_notes(value)
entries = platforms
docker, native = entries.partition { |entry| entry['docker'] == true }
previous = git('tag', '--list', 'v*', '--merged', 'HEAD').lines.map(&:strip).filter_map do |candidate|
next unless candidate.delete_prefix('v').match?(SEMVER)
key = sort_key(candidate)
[key, candidate] if key < sort_key(value)
end.max_by(&:first)&.last
{
'tag' => tag,
'version' => value,
'docker_version' => value.tr('+', '-'),
'build_time' => Time.now.utc.iso8601,
'git_sha' => git('rev-parse', 'HEAD'),
'platform_matrix' => JSON.generate('include' => entries),
'docker_matrix' => JSON.generate('include' => docker),
'non_docker_matrix' => JSON.generate('include' => native),
'docker_platforms' => docker.map { |entry| entry['dockerPlatform'] }.join(','),
'previous_tag' => previous,
'prerelease' => prerelease?(value),
'build_type' => value.split('+', 2).first.include?('-exp.') ? 'experimental' : 'release'
}
end
def self.seal_manifest(metadata)
path = 'backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json'
host = JSON.parse(File.read(path))
raise "Invalid plugin host compatibility declaration: #{path}" unless host['schema_version'] == 2
manifest = {
'schema_version' => 1,
'sealed' => true,
'gateway_version' => metadata.fetch('version'),
'gateway_git_sha' => metadata.fetch('git_sha'),
'plugin_host' => host,
'plugins' => []
}
FileUtils.mkdir_p('dist/plugin-release-assets')
File.write('dist/plugin-release-assets/plugin-release-manifest.json', JSON.pretty_generate(manifest) + "\n")
end
end
if $PROGRAM_NAME == __FILE__
begin
command, value = ARGV
raise 'usage: release-metadata.rb validate VERSION | outputs TAG' unless ARGV.length == 2
case command
when 'validate'
value = ReleaseMetadata.version(value)
ReleaseMetadata.validate_notes(value)
puts value
when 'outputs'
metadata = ReleaseMetadata.outputs(value)
ReleaseMetadata.seal_manifest(metadata)
metadata.each { |key, entry| puts "#{key}=#{entry}" }
else
raise "Unknown command: #{command}"
end
rescue StandardError => error
warn "error: #{error.message}"
exit 1
end
end
+27
View File
@@ -0,0 +1,27 @@
# 从构建声明读取 CI 工具链,阻止 Docker 与本地 Rust 版本漂移
module BuildToolchains
def self.read(root = '.')
declaration = File.read(File.join(root, 'backend/rust-toolchain.toml'))
rust = declaration[/^\s*channel\s*=\s*["'](\d+\.\d+(?:\.\d+)?)["']\s*(?:#.*)?$/, 1]
raise 'Expected a pinned Rust channel in backend/rust-toolchain.toml' unless rust
dockerfile = File.read(File.join(root, 'deploy/Dockerfile'))
image = lambda do |stage, name|
match = dockerfile.match(/^FROM (#{name}:([^\s@]+)@sha256:[a-f0-9]{64}) AS #{stage}$/)
raise "Missing pinned #{stage} image" unless match
[match[1], match[2].split('-', 2).first]
end
rust_image, image_rust = image.call('backend-builder', 'rust')
_, node = image.call('frontend-builder', 'node')
raise "Rust toolchain #{rust} differs from Docker builder #{image_rust}" unless rust == image_rust
{ 'rust' => rust, 'rust_image' => rust_image, 'node' => node }
end
end
if $PROGRAM_NAME == __FILE__
begin
BuildToolchains.read.each { |key, value| puts "#{key}=#{value}" }
rescue StandardError => error
warn "error: #{error.message}"
exit 1
end
end
+13 -2
View File
@@ -28,7 +28,7 @@ jobs:
timeout-minutes: 30
env:
CARGO_TERM_COLOR: always
# Rust 1.97 编译 AWS SDK 调试信息需要更大的 codegen 线程栈。
# 编译 AWS SDK 调试信息需要更大的 codegen 线程栈
RUST_MIN_STACK: 16777216
CPR_TEST_DATABASE_URL: postgres://codex_proxy:codex_proxy@127.0.0.1:5432/codex_proxy
CPR_TEST_REDIS_URL: redis://127.0.0.1:6379
@@ -97,9 +97,14 @@ jobs:
fi
fi
- name: Read build toolchains
id: toolchains
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- name: Setup Rust
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: ${{ steps.toolchains.outputs.rust }}
components: rustfmt,clippy
- name: Cache Rust
@@ -202,6 +207,8 @@ jobs:
with:
ref: ${{ inputs.ref }}
# GitHub 已支持 queue: max,actionlint 尚未支持该字段
# https://github.com/rhysd/actionlint/issues/657
- name: Lint GitHub Actions workflows
run: >-
docker run --rm
@@ -209,6 +216,7 @@ jobs:
--workdir /repo
rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9
-color
-ignore '^unexpected key "queue" for "concurrency" section\. expected one of "cancel-in-progress", "group"$'
.github/workflows/*.yml
- name: Check workflow script syntax
@@ -216,3 +224,6 @@ jobs:
bash -n release/publish
shopt -s globstar nullglob
for script in .github/**/*.sh; do bash -n "$script"; done
for script in .github/scripts/*.rb; do ruby -c "$script"; done
ruby .github/scripts/toolchains.rb
ruby -c backend/crates/gateway-plugin/runtime/plugin-api-lifecycle.rb
+16 -2
View File
@@ -42,18 +42,26 @@ jobs:
predicate-quantifier: some-with-excludes
filters: |
backend:
- '.github/scripts/toolchains.rb'
- 'deploy/Dockerfile'
- 'backend/**'
- 'release/version.yaml'
- '.github/workflows/ci.yml'
- '.github/workflows/_quality.yml'
- '!**/*.md'
frontend:
- '.github/scripts/toolchains.rb'
- 'backend/rust-toolchain.toml'
- 'deploy/Dockerfile'
- 'frontend/**'
- '.github/actions/build-frontend/**'
- '.github/workflows/ci.yml'
- '.github/workflows/_quality.yml'
- '!**/*.md'
source:
- '.github/scripts/toolchains.rb'
- 'backend/rust-toolchain.toml'
- 'deploy/Dockerfile'
- '.gitmodules'
- 'modules/ui'
- 'modules/plugins'
@@ -106,12 +114,18 @@ jobs:
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
package_json_file: frontend/package.json
- name: Read build toolchains
id: toolchains
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
node-version: ${{ steps.toolchains.outputs.node }}
- name: Setup Rust
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: ${{ steps.toolchains.outputs.rust }}
- name: Check dependency pins
run: |
ui_revision="$(git -C modules/ui rev-parse HEAD)"
+45 -337
View File
@@ -36,6 +36,8 @@ jobs:
previous_tag: ${{ steps.version.outputs.previous_tag }}
prerelease: ${{ steps.version.outputs.prerelease }}
build_type: ${{ steps.version.outputs.build_type }}
rust: ${{ steps.toolchains.outputs.rust }}
rust_image: ${{ steps.toolchains.outputs.rust_image }}
steps:
- name: Checkout
@@ -44,151 +46,15 @@ jobs:
ref: ${{ inputs.tag }}
fetch-depth: 0
- name: Read release metadata
- name: Read release metadata and seal plugin manifest
id: version
shell: bash
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
ruby <<'RUBY' >> "$GITHUB_OUTPUT"
require "json"
require "open3"
require "rubygems"
require "time"
require "yaml"
run: ruby .github/scripts/release-metadata.rb outputs "$RELEASE_TAG" >> "$GITHUB_OUTPUT"
APP_NAME = "codex-proxy-rs"
SEMVER = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:alpha|beta|rc|exp)\.[1-9]\d*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z/
def fail(message)
warn(message)
exit(1)
end
def require_string(data, key, path)
value = data[key]
fail("#{path}: #{key} must be a non-empty string") unless value.is_a?(String) && !value.strip.empty?
value.strip
end
def version_sort_key(version)
Gem::Version.new(version.split("+", 2).first)
rescue ArgumentError
nil
end
version_path = "release/version.yaml"
platforms_path = "release/platforms.yaml"
version_data = YAML.load_file(version_path)
platforms_data = YAML.load_file(platforms_path)
version = require_string(version_data, "version", version_path).delete_prefix("v")
tag_prefix = "v"
expected_tag = "#{tag_prefix}#{version}"
release_tag = ENV.fetch("RELEASE_TAG")
fail("Release tag #{release_tag} does not match #{version_path} tag #{expected_tag}") unless release_tag == expected_tag
fail("#{version_path}: version #{version} is not a valid semver") unless version.match?(SEMVER)
notes_path = "release/notes.md"
fail("#{notes_path}: release notes are required") unless File.file?(notes_path)
notes = File.readlines(notes_path)
unless notes.first&.strip == "# #{release_tag}" && !notes.drop(1).join.strip.empty?
fail("#{notes_path}: expected non-empty notes starting with '# #{release_tag}'")
end
platforms = platforms_data["platforms"]
fail("#{platforms_path}: platforms must be a non-empty array") unless platforms.is_a?(Array) && !platforms.empty?
required_platform_keys = %w[os arch runner target binary archive]
seen_platforms = {}
docker_platform_jobs = []
non_docker_platform_jobs = []
docker_platforms = []
platforms.each_with_index do |platform, index|
fail("#{platforms_path}: platforms[#{index}] must be a table") unless platform.is_a?(Hash)
required_platform_keys.each do |key|
require_string(platform, key, "#{platforms_path}: platforms[#{index}]")
end
archive = platform["archive"]
fail("#{platforms_path}: platforms[#{index}].archive must be tar.gz") unless archive == "tar.gz"
platform_key = "#{platform["os"]}-#{platform["arch"]}"
fail("#{platforms_path}: duplicate platform #{platform_key}") if seen_platforms[platform_key]
seen_platforms[platform_key] = true
platform["artifact"] = "#{APP_NAME}-#{platform_key}"
if platform["docker"] == true
docker_platform = require_string(platform, "dockerPlatform", "#{platforms_path}: platforms[#{index}]")
platform["dockerPlatform"] = docker_platform
docker_platform_jobs << platform
docker_platforms << docker_platform
else
non_docker_platform_jobs << platform
end
end
fail("#{platforms_path}: at least one platform must set docker = true") if docker_platforms.empty?
git_sha, status = Open3.capture2("git", "rev-parse", "HEAD")
fail("failed to resolve git sha") unless status.success?
matrix = JSON.generate({ "include" => platforms })
docker_matrix = JSON.generate({ "include" => docker_platform_jobs })
non_docker_matrix = JSON.generate({ "include" => non_docker_platform_jobs })
current_sort_key = version_sort_key(version)
tags, status = Open3.capture2("git", "tag", "--list", "#{tag_prefix}*", "--merged", "HEAD")
fail("failed to list git tags") unless status.success?
previous_tag = tags
.lines
.map(&:strip)
.reject(&:empty?)
.reject { |tag| tag == release_tag }
.filter_map do |tag|
tag_version = tag.delete_prefix(tag_prefix)
next unless tag_version.match?(SEMVER)
sort_key = version_sort_key(tag_version)
next unless sort_key && current_sort_key && sort_key < current_sort_key
[sort_key, tag]
end
.max_by(&:first)
&.last
puts "tag=#{release_tag}"
puts "version=#{version}"
# Docker tags forbid "+", so semver build metadata maps to "-" in image tags.
puts "docker_version=#{version.tr("+", "-")}"
puts "build_time=#{Time.now.utc.iso8601}"
puts "git_sha=#{git_sha.strip}"
puts "platform_matrix=#{matrix}"
puts "docker_matrix=#{docker_matrix}"
puts "non_docker_matrix=#{non_docker_matrix}"
puts "docker_platforms=#{docker_platforms.join(",")}"
puts "previous_tag=#{previous_tag}"
puts "prerelease=#{version.split("+", 2).first.include?("-")}"
puts "build_type=#{version.split("+", 2).first.include?("-exp.") ? "experimental" : "release"}"
RUBY
- name: Seal plugin release manifest
env:
VERSION: ${{ steps.version.outputs.version }}
GIT_SHA: ${{ steps.version.outputs.git_sha }}
run: |
ruby <<'RUBY'
require "fileutils"
require "json"
host_path = "backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json"
host = JSON.parse(File.read(host_path))
abort "Invalid plugin host compatibility declaration: #{host_path}" unless host["schema_version"] == 2
# 清单只依赖版本与兼容声明,平台归档和镜像消费同一份封口结果。
manifest = {
"schema_version" => 1,
"sealed" => true,
"gateway_version" => ENV.fetch("VERSION"),
"gateway_git_sha" => ENV.fetch("GIT_SHA"),
"plugin_host" => host,
"plugins" => [],
}
FileUtils.mkdir_p("dist/plugin-release-assets")
File.write("dist/plugin-release-assets/plugin-release-manifest.json", JSON.pretty_generate(manifest) + "\n")
RUBY
- name: Read build toolchains
id: toolchains
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- name: Upload sealed plugin assets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -245,7 +111,7 @@ jobs:
~/.cargo/git
backend/target
# 按目标、工具链和依赖保存,不为每个发行提交复制整个 target
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml', '.github/workflows/release.yml') }}
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ needs.release-info.outputs.rust_image }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
restore-keys: |
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
@@ -265,11 +131,12 @@ jobs:
-v "$HOME/.cargo:/cargo" \
-v "$PWD/..:/workspace" \
-w /workspace/backend \
rust:1.97-bookworm@sha256:77fac8b98f9f46062bb680b6d25d5bcaabfc400143952ebc572e924bcbedc3fa \
"${RUST_IMAGE}" \
cargo build --release --locked --target "${{ matrix.target }}" --bin codex-proxy-rs
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/${{ matrix.binary }}"
working-directory: backend
env:
RUST_IMAGE: ${{ needs.release-info.outputs.rust_image }}
CPR_VERSION: ${{ needs.release-info.outputs.version }}
CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
@@ -299,8 +166,9 @@ jobs:
ref: ${{ needs.release-info.outputs.git_sha }}
- name: Setup Rust
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: ${{ needs.release-info.outputs.rust }}
targets: ${{ matrix.target }}
- name: Cache Cargo
@@ -310,7 +178,7 @@ jobs:
~/.cargo/registry
~/.cargo/git
backend/target
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml', '.github/workflows/release.yml') }}
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
restore-keys: |
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
@@ -373,101 +241,8 @@ jobs:
env:
VERSION: ${{ needs.release-info.outputs.version }}
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
run: |
set -euo pipefail
ruby <<'RUBY' > release-platforms.json
require "json"
require "yaml"
print JSON.generate(YAML.load_file("release/platforms.yaml"))
RUBY
python3 - <<'PY'
import hashlib
import json
import os
import pathlib
import shutil
import tarfile
APP_NAME = "codex-proxy-rs"
def sha256(path: pathlib.Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as file:
for chunk in iter(lambda: file.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
version = os.environ["VERSION"]
platforms = json.loads(pathlib.Path("release-platforms.json").read_text())["platforms"]
frontend_dir = pathlib.Path("dist/frontend")
binary_artifacts_dir = pathlib.Path("dist/binaries")
release_dir = pathlib.Path("dist/release")
package_dir = pathlib.Path("package")
shutil.rmtree(release_dir, ignore_errors=True)
release_dir.mkdir(parents=True)
config_template = pathlib.Path("deploy/config.example.yaml")
shutil.copy2(config_template, release_dir / config_template.name)
compose = pathlib.Path("deploy/compose.yaml").read_text()
image = f"{os.environ['REGISTRY']}/{os.environ['IMAGE_NAME']}"
default_image = "${CPR_IMAGE:-" + image + ":latest}"
if compose.count(default_image) != 1:
raise SystemExit("Expected exactly one default application image in deploy/compose.yaml")
# 发布附件固定镜像版本,防止后续 pull 与随包配置模板脱节。
versioned_image = "${CPR_IMAGE:-" + f"{image}:{os.environ['DOCKER_VERSION']}" + "}"
compose = compose.replace(default_image, versioned_image)
(release_dir / "compose.yaml").write_text(compose)
assets = [release_dir / config_template.name, release_dir / "compose.yaml"]
shutil.rmtree(package_dir, ignore_errors=True)
release_packages = []
for platform in platforms:
platform_name = f"{platform['os']}-{platform['arch']}"
artifact_name = f"{APP_NAME}-{platform_name}"
binary_src = binary_artifacts_dir / artifact_name / platform["binary"]
if not binary_src.is_file():
raise SystemExit(f"Missing binary artifact: {binary_src}")
package_root = package_dir / platform_name / APP_NAME
web_dist = package_root / "web" / "dist"
web_dist.mkdir(parents=True)
binary_dest = package_root / platform["binary"]
shutil.copy2(binary_src, binary_dest)
binary_dest.chmod(0o755)
shutil.copytree(frontend_dir, web_dist, dirs_exist_ok=True)
deploy_dir = package_root / "deploy"
deploy_dir.mkdir()
shutil.copy2(config_template, deploy_dir / config_template.name)
archive_name = f"{APP_NAME}_{version}_{platform['os']}_{platform['arch']}.{platform['archive']}"
archive_path = release_dir / archive_name
if platform["archive"] != "tar.gz":
raise SystemExit(f"Unsupported archive format: {platform['archive']}")
release_packages.append((platform, package_root, archive_path))
plugin_manifest_path = release_dir / "plugin-release-manifest.json"
shutil.copy2("dist/plugin-release-assets/plugin-release-manifest.json", plugin_manifest_path)
assets.append(plugin_manifest_path)
# 保留运行时清单目录合同,宿主包不再携带教学示例。
for platform, package_root, archive_path in release_packages:
official_dir = package_root / "plugins" / "official"
official_dir.mkdir(parents=True)
bundled_manifest = official_dir / plugin_manifest_path.name
shutil.copy2(plugin_manifest_path, bundled_manifest)
bundled_manifest.chmod(0o444)
official_dir.chmod(0o555)
with tarfile.open(archive_path, "w:gz") as archive:
archive.add(package_root, arcname=APP_NAME)
assets.append(archive_path)
checksum_path = release_dir / "checksums.txt"
with checksum_path.open("w", encoding="utf-8") as file:
for asset in assets:
file.write(f"{sha256(asset)} {asset.name}\n")
PY
PLATFORM_MATRIX: ${{ needs.release-info.outputs.platform_matrix }}
run: ruby .github/scripts/package-release.rb archives
- name: Generate release asset provenance
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
@@ -531,56 +306,7 @@ jobs:
MATRIX_OS: ${{ matrix.os }}
MATRIX_ARCH: ${{ matrix.arch }}
MATRIX_BINARY: ${{ matrix.binary }}
run: |
set -euo pipefail
python3 - <<'PY'
import json
import shutil
import os
import pathlib
app_name = "codex-proxy-rs"
platform_name = f"{os.environ['MATRIX_OS']}-{os.environ['MATRIX_ARCH']}"
binary_src = pathlib.Path("dist/docker-artifacts") / f"{app_name}-{platform_name}" / os.environ["MATRIX_BINARY"]
if not binary_src.is_file():
raise SystemExit(f"Missing Docker binary artifact: {binary_src}")
target_dir = pathlib.Path("dist/docker") / platform_name
target_dir.mkdir(parents=True, exist_ok=True)
binary_dest = target_dir / app_name
shutil.copy2(binary_src, binary_dest)
binary_dest.chmod(0o755)
release_dir = pathlib.Path("dist/plugin-release-assets")
manifest_path = release_dir / "plugin-release-manifest.json"
manifest = json.loads(manifest_path.read_text())
if manifest.get("schema_version") != 1 or manifest.get("sealed") is not True:
raise SystemExit("Invalid sealed plugin release manifest")
official_dir = target_dir / "plugins" / "official"
official_dir.mkdir(parents=True)
bundled_manifest = official_dir / manifest_path.name
shutil.copy2(manifest_path, bundled_manifest)
bundled_manifest.chmod(0o444)
selected = 0
for plugin in manifest.get("plugins", []):
artifacts = [
artifact for artifact in plugin.get("artifacts", [])
if artifact.get("release_os") == os.environ["MATRIX_OS"]
and artifact.get("release_architecture") == os.environ["MATRIX_ARCH"]
]
if len(artifacts) != 1:
raise SystemExit(f"Official plugin platform selection is not unique: {plugin.get('id')}")
archive = release_dir / artifacts[0]["archive"]
if not archive.is_file():
raise SystemExit(f"Missing official plugin archive: {archive.name}")
bundled_archive = official_dir / archive.name
shutil.copy2(archive, bundled_archive)
bundled_archive.chmod(0o444)
selected += 1
if selected != len(manifest.get("plugins", [])):
raise SystemExit("Official plugin release bundle is incomplete")
official_dir.chmod(0o555)
PY
run: ruby .github/scripts/package-release.rb image
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
@@ -644,6 +370,9 @@ jobs:
retention-days: 1
publish:
concurrency:
group: release-publication
queue: max
# 产物准备可与测试重叠,版本标签和 GitHub Release 必须等全部质量与镜像验证通过
needs: [release-info, quality, package-assets, build-docker-image]
runs-on: ubuntu-latest
@@ -686,14 +415,12 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create app image manifest
- name: Assemble candidate image manifest
id: manifest
shell: bash
env:
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
DOCKER_PLATFORMS: ${{ needs.release-info.outputs.docker_platforms }}
PRERELEASE: ${{ needs.release-info.outputs.prerelease }}
run: |
set -euo pipefail
@@ -717,16 +444,10 @@ jobs:
exit 1
fi
target_args=(
-t "${image}:${DOCKER_VERSION}"
-t "${image}:sha-${GIT_SHA}"
)
if [ "$PRERELEASE" != true ]; then
target_args+=(-t "${image}:latest")
fi
docker buildx imagetools create "${target_args[@]}" "${sources[@]}"
candidate="${image}:sha-${GIT_SHA}"
docker buildx imagetools create -t "$candidate" "${sources[@]}"
inspect_output="$(docker buildx imagetools inspect "${image}:${DOCKER_VERSION}")"
inspect_output="$(docker buildx imagetools inspect "$candidate")"
platform_list="$(awk '$1 == "Platform:" { print $2 }' <<< "${inspect_output}")"
for platform in "${platforms[@]}"; do
if ! grep -Eq "^${platform}(/|$)" <<< "${platform_list}"; then
@@ -736,7 +457,7 @@ jobs:
fi
done
digest="$(docker buildx imagetools inspect "${image}:${DOCKER_VERSION}" --format '{{ .Manifest.Digest }}')"
digest="$(docker buildx imagetools inspect "$candidate" --format '{{ .Manifest.Digest }}')"
if [ -z "${digest}" ]; then
echo "missing image digest"
exit 1
@@ -750,31 +471,10 @@ jobs:
subject-digest: ${{ steps.manifest.outputs.digest }}
push-to-registry: true
- name: Sign app image
shell: bash
- name: Sign app image digest
env:
DIGEST: ${{ steps.manifest.outputs.digest }}
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
PRERELEASE: ${{ needs.release-info.outputs.prerelease }}
run: |
set -euo pipefail
if [ -z "${DIGEST}" ]; then
echo "missing image digest"
exit 1
fi
image="${REGISTRY}/${IMAGE_NAME}"
tags=("${image}:${DOCKER_VERSION}" "${image}:sha-${GIT_SHA}")
if [ "$PRERELEASE" != true ]; then
tags+=("${image}:latest")
fi
images=()
for tag in "${tags[@]}"; do
images+=("${tag}@${DIGEST}")
done
cosign sign --yes "${images[@]}"
run: cosign sign --yes "${REGISTRY}/${IMAGE_NAME}@${DIGEST}"
- name: Prepare release notes
shell: bash
@@ -803,25 +503,33 @@ jobs:
- [GitHub 仓库](https://github.com/${{ github.repository }})
- [部署指南](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md)
run: |
python3 <<'PY'
import os
from pathlib import Path
ruby <<'RUBY'
notes = File.readlines('release/notes.md').drop(1).join.strip
installation = ENV.fetch('INSTALLATION_NOTES').strip
output = File.join(ENV.fetch('RUNNER_TEMP'), 'release-notes.md')
File.write(output, "#{notes}\n\n#{installation}\n")
RUBY
notes = "\n".join(Path("release/notes.md").read_text().splitlines()[1:]).strip()
installation = os.environ["INSTALLATION_NOTES"].strip()
output = Path(os.environ["RUNNER_TEMP"]) / "release-notes.md"
output.write_text(f"{notes}\n\n{installation}\n")
PY
- name: Publish GitHub Release
- name: Upload GitHub Release assets
id: release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
tag_name: ${{ needs.release-info.outputs.tag }}
generate_release_notes: true
previous_tag: ${{ needs.release-info.outputs.previous_tag }}
prerelease: ${{ needs.release-info.outputs.prerelease == 'true' }}
make_latest: ${{ needs.release-info.outputs.prerelease == 'true' && 'false' || 'true' }}
# action 保留已公开 Release 的状态,新 Release 上传完成后仍保持草稿
draft: true
make_latest: 'false'
body_path: ${{ runner.temp }}/release-notes.md
fail_on_unmatched_files: true
files: |
dist/release/*
- name: Publish version and advance latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release-info.outputs.version }}
DIGEST: ${{ steps.manifest.outputs.digest }}
RELEASE_ID: ${{ steps.release.outputs.id }}
run: ruby .github/scripts/publish-release.rb
+5 -1
View File
@@ -51,10 +51,14 @@ jobs:
with:
package_json_file: frontend/package.json
- name: Read build toolchains
id: toolchains
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
node-version: ${{ steps.toolchains.outputs.node }}
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
+4 -4
View File
@@ -4,7 +4,7 @@
| 任务意图与常见说法 | 必用技能 |
| --- | --- |
| 开发、实现、修复、排查报错、审查问题或代码、修改文档 | [cpr-dev-guide](.agents/skills/cpr-dev-guide/SKILL.md) |
| 开发、修复、排障、代码审查、改文档;包括“改弹窗/样式/文案”“修接口/调度/数据库”等省略请求 | [cpr-dev-guide](.agents/skills/cpr-dev-guide/SKILL.md),按实际操作和文件匹配规范,跨前后端同时执行 |
| 提交/提个 PR、准备或更新 PR、看下 PR、回复 PR 讨论、处理 PR 审查意见 | [cpr-github-pr](.agents/skills/cpr-github-pr/SKILL.md);需要修改时同时执行 cpr-dev-guide |
| 提 Issue、反馈到仓库、把问题或建议整理成 Issue、补充或回复 Issue | [cpr-github-issue](.agents/skills/cpr-github-issue/SKILL.md) |
| 写网关插件、修改插件能力、排查插件接入、打包插件 | [cpr-plugin-dev](.agents/skills/cpr-plugin-dev/SKILL.md),同时执行 cpr-dev-guide |
@@ -16,7 +16,7 @@
执行所选流程中的问题核实、自审、验证与交付要求,协作流程和审查标准见 [CONTRIBUTING.md](CONTRIBUTING.md)
Issue 和 PR 回复分别按对应技能的[Issue 回复口吻](.agents/skills/cpr-github-issue/SKILL.md#回复口吻)与[PR 回复口吻](.agents/skills/cpr-github-pr/SKILL.md#回复口吻)执行
Issue 和 PR 回复统一按 [GitHub 回复口吻](CONTRIBUTING.md#github-回复口吻)执行
模块职责以 [系统架构](docs/architecture.md) 为准,界面约定以 [管理端主题](docs/theme.md) 为准;
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则
@@ -34,10 +34,10 @@ If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is
## 工作方式
- 开发和审查按 [问题与方案依据](CONTRIBUTING.md#问题与方案依据) 先核实问题;涉及 Codex 行为、协议或兼容性时,按[参考仓库优先级](.agents/skills/cpr-dev-guide/SKILL.md#参考仓库的优先级)以官方 Codex 仓库对应版本源码为第一手资料,三方仓库仅作参考
- 开发和审查按 [问题与方案依据](CONTRIBUTING.md#问题与方案依据) 先核实问题及方案必要性;涉及 Codex 行为、协议或兼容性时,以官方对应版本源码为第一手资料,三方仓库仅作参考
- 先定位变更所属模块、同类实现和上下游调用关系,再选择最小合理改动。复用已有能力,也避免为尚不存在的需求增加抽象。
- 提交前自审最终差异,先处理本次引入的职责混淆、重复规则、冗余状态和无依据的兼容分支;后端按 [后端自审](docs/architecture.md#后端自审) 追踪状态与资源生命周期,不能把自审留给 CI 或 PR 审查者。
- 页面变更先复用已有页面设计与组件,不因个人偏好重排页面或另建交互。文案取舍遵循 [界面文案与信息层级](docs/theme.md#界面文案与信息层级),页面 PR 必须提供 [实际截图](CONTRIBUTING.md#界面验证)。
- 前端开发与审查执行[前端流程](.agents/skills/cpr-dev-guide/references/frontend.md),先复用已有页面设计与组件,不因个人偏好重排页面或另建交互;页面 PR 必须提供 [实际截图](CONTRIBUTING.md#界面验证)。
- 代码注释使用中文,解释原因与边界;提交信息使用英文,沿用历史中的 Conventional Commits 格式。
- 常规文档只描述当前状态,不记录变更历史;按[文档职责与更新条件](CONTRIBUTING.md#文档职责与更新条件)在所属章节就地修订,分清层次与详略,短段和列表少用句号,交付前逐段检查必要性、归属、重复与当前状态表达
- 按变更范围执行验证,记录命令、结果和缺口。跳过的测试不算通过;界面与集成行为需要对应运行证据,构建通过不能替代实际验收。
+26 -5
View File
@@ -19,15 +19,30 @@
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议说明使用场景、当前不便和期望行为;协作规则调整可直接提交普通 Issue,说明原因和建议方案
### GitHub 回复口吻
AI 草拟或发布 Issue、PR 讨论及审查意见的回复时:
- 用第一人称“我”,自然称呼对方为“佬友”,不逐句重复,也不用“我们”“本助手”代称自己
- 客气、委婉地回应具体问题,补充信息或提出不同意见时说明缘由,避免命令式、责备式表达
- 使用自然短句,简单回复用短段,并列信息较多时再分点;少用句号,避免客服套话、机械模板和空泛保证
- 只把实际完成的排查、复现、修改和验证写成“我已……”;未采纳的意见说明依据,未确认、待处理或未验证的内容如实交代,说清结论、限制和下一步
## 问题与方案依据
开发、创建 PR 和审查都先核实待解决的问题、触发条件与预期行为,再确定或评估实现。区分问题发生版本、目标分支现状和本次变更后的行为,不把需求描述、PR 描述或第三方项目的做法直接当成事实。目标分支已修复的问题,应说明当前仍需补齐的差异,不能据此否定旧版本中的问题
涉及上游协议、客户端行为或兼容性时,以对应版本的官方源码为首要资料。优先读取用户提供或本机已有的官方源码,核对仓库来源、版本或提交;本地资料不足时查官方仓库的对应版本源码,再结合官方测试、文档、发布说明和运行证据。第三方资料只作线索或补充。引用具体文件、符号或行号,对外引用优先使用固定提交的源码链接;源码不可得或版本不匹配时说明证据缺口,不把最新实现当成当前部署行为。无上游依赖的内部变更,以本项目代码、合同和运行证据为依据
涉及上游协议、客户端行为或兼容性时,遵守以下依据与行为约束;无上游依赖的内部变更以本项目代码、合同和运行证据为依据
官方已有同一场景的处理方式时,优先沿用其协议语义、职责归属和解决路径,检查是否只需复用已有能力或正确传递官方合同。新增覆盖配置、补偿逻辑或采用第三方兼容方案,应说明官方路径为何不足;偏离官方方案需有本项目的实际约束和验证依据,不机械照搬源码,也不默认接受已有提案
- **资料来源**:以对应版本的官方源码与调用链为首要资料,Codex 对照 [openai/codex](https://github.com/openai/codex)。先查用户提供或本机已有的仓库,核对来源、版本或提交;不足时再查官方对应版本源码,结合测试、文档、发布说明和运行证据。三方资料只作线索或实现参考,不能替代官方依据
- **证据表达**:引用具体文件、符号或行号,对外优先使用固定提交的源码链接;注明适用版本,区分官方事实、三方参考与本项目推断。源码不可得或版本不匹配时报告缺口,不把最新实现当作部署行为,也不把自定义策略写成官方要求
- **默认方案**:Codex 兼容性优先保持官方行为,以透明转发和最小必要适配为默认方案。沿用官方协议语义、职责归属和解决路径,优先复用已有能力或正确传递合同,不额外解释请求意图、扩展工具权限或改变续接语义
- **问题核实**:对照官方与 CPR 的实际请求、响应及状态生命周期,检查本项目是否误解、改写、遗漏或破坏合同;分别说明源码推断、代理链路复现与官方直连结果。真实上游报错不自动等于 CPR 缺陷
- **审查官方对接**:审查 OpenAI 官方上游与 Codex 的对接时,默认二者兼容、直接对接正常。不得仅凭自行构造的异常上游数据揣测官方不兼容,据此报告缺陷、提出改进建议或要求防御分支与测试;特定版本或场景的例外须有真实响应或实际复现证据
- **偏离依据**:新增覆盖配置、补偿逻辑或三方兼容方案前,说明官方路径为何不足、本项目实际约束、最小改动、行为代价及验证。补偿后成功、测试通过或三方采用同一做法不能单独证明必要性;依据不足时保持现有合同并报告缺口,不先加分支再倒推理由。用户明确要求的行为变更限定在授权范围内
- **网关职责**:调度、隔离和资源管理按网关职责评估,不机械照搬单客户端实现,不因官方客户端没有同类池策略就判错,也不任意改变客户端可观察行为。新增默认限制或回收阈值须有容量依据,并验证续接、重试与延迟影响
反馈 Issue 时区分观察到的症状、根因假设和方案建议,关联已有 Issue、PR 及包含修复的发布版本。能说明问题的反馈可以先提交,不要求报告者先找到官方源码或证明根因;引用官方行为或提出实现方案时,按上述原则注明依据与缺口
反馈 Issue 时区分症状、根因假设和方案建议,关联已有 Issue、PR 及包含修复的发布版本。症状反馈不以找到官方源码或证明根因为前提;引用上游行为或提出方案时注明上述依据与缺口
## PR 流程
@@ -147,7 +162,7 @@ PR 不接受以下协作规则文件的新增、修改、删除或重命名:
## 界面验证
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有页面为依据,优先延续原页面的布局、信息层级与交互;设计技能不构成重做页面的理由
AI 进行前端开发或审查时,执行[前端流程](.agents/skills/cpr-dev-guide/references/frontend.md),覆盖页面、组件、弹窗、表单及局部文案和样式。用户指定设计技能时使用该技能;未指定时,只有任务明确需要建立或重塑视觉方向才使用 `$frontend-design`。新增页面也优先沿用 [管理端主题](docs/theme.md) 和已有设计,设计技能不构成重做页面的理由
开发前查找原页面、同类页面及 `modules/ui/src/components` 中的基础组件,明确参考实现和可复用部分,再确定最小改动。只有原设计不能满足本次操作或存在具体问题时才调整结构,并在 PR 中说明原因;不顺带换风格、重排无关区域或增加重复的模式入口。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题
@@ -163,11 +178,17 @@ AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方
截图用于视觉审阅,保存、切换、错误恢复和键盘操作等行为仍需实际验证并记录覆盖范围,不能仅凭静态图片推断交互正确
当前用户明确接管或确认本轮视觉验收时,沿用约定分工和结果;明确免除本次截图时,该项不再阻塞交付或要求 Draft。报告区分用户确认、AI 实测和未验证,其他验证仍执行;后续修改只复核受影响部分
涉及浏览器能力或访问环境差异时,按项目支持的部署方式核对相关 HTTP/HTTPS、localhost/实际部署地址与浏览器版本,记录实际覆盖的条件;本地预览正常不能替代部署环境中的交互验收
## 验证
选择与变更相关的检查,纯文档修改无需重建前后端。行为修复补充能覆盖触发条件和结果的测试,不增加仅复述实现的断言
按变更风险明确要验证的行为与通过条件,优先复用现有检查;缺少有效回归覆盖时补充行为测试,不为表名、字段清单或 SQL 文本形状单独保留测试,也不增加仅复述实现的断言。纯文档修改无需重建前后端
必要验证通过且没有新改动、失败或未解疑点后停止扩围与重复运行。真实账号验证满足约定场景后即收尾,不无目的重复请求;用户限定验证范围或次数时按其要求执行,未覆盖项如实列出
临时预览、模拟数据、诊断脚本与压测产物放在 `.runtime/` 或系统临时目录,使用后按需清理;只有持续维护的工具和回归测试进入源码目录,不把一次性验收产物混进 `src/`、`examples/` 或正式提交
| 范围 | 现有检查 | 仍需提供的证据 |
| --- | --- | --- |
+16 -2
View File
@@ -1878,6 +1878,7 @@ dependencies = [
name = "gateway-plugin-sdk"
version = "0.1.0"
dependencies = [
"base64 0.23.1",
"semver",
"serde",
"serde_json",
@@ -2779,6 +2780,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -5631,14 +5644,15 @@ dependencies = [
[[package]]
name = "zip"
version = "8.6.0"
version = "9.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"nix",
"typed-path",
]
+1 -1
View File
@@ -20,7 +20,7 @@ version = "0.0.0"
edition = "2024"
license = "Apache-2.0"
publish = false
rust-version = "1.97"
rust-version = "1.99.1"
[workspace.lints.rust]
warnings = "deny"
@@ -67,6 +67,7 @@ async fn execute(
let Environment {
host,
mut store,
timezone,
plugin_cache,
version,
plugin_http,
@@ -115,6 +116,16 @@ async fn execute(
&plugin_runtime,
));
or_shutdown!(plugin_runtime, services.register_settings(&settings));
let accounts = gateway_admin::initialize_accounts(
store.admin_ports().accounts(),
store.admin_ports().account_runtime(),
store.admin_ports().settings(),
timezone,
providers.admin.clone(),
core.snapshot_control(),
core.account_probe(),
);
or_shutdown!(plugin_runtime, services.register_accounts(&accounts));
let usage_data = gateway_admin::initialize_usage_data(
store.admin_ports().usage_data(),
store.admin_ports().settings(),
@@ -78,7 +78,7 @@ async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
});
let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.with_client_builder(provider_openai::build_reqwest_client_with_custom_ca);
let result = probe.test(&proxy, false).await;
let result = probe.test(Some(&proxy), false).await;
assert!(result.success, "{}", result.message);
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
tokio::time::timeout(Duration::from_secs(5), server)
+3 -3
View File
@@ -8,7 +8,7 @@
从仓库根目录安装到本地工具目录:
```bash
cargo +1.97.0 install --locked --path backend/apps/plugin-cli --root /path/to/local-tools
cargo +1.99.1 install --locked --path backend/apps/plugin-cli --root /path/to/local-tools
```
将 `/path/to/local-tools/bin` 加入 `PATH`,或使用可执行文件的完整路径;工具链与宿主 [验证命令](../../../docs/architecture.md#验证命令)一致
@@ -45,6 +45,6 @@ CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共
Rust 包名为 `codex-proxy-plugin-cli`,位于宿主的 `backend` workspace,共享工具链、锁文件和检查规则;内部仅依赖 `gateway-plugin-sdk`
```bash
cargo +1.97.0 run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked
cargo +1.99.1 run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help
RUST_MIN_STACK=16777216 cargo +1.99.1 test --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked
```
+2 -2
View File
@@ -250,8 +250,8 @@ fn source_manifest() -> &'static [u8] {
"main":"bin/plugin",
"runtime":"trustedProcess",
"contributes":{
"middleware":{"version":4,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
"management":{}
"middleware":{"version":5,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
"management":{"version":2}
},
"icon":"assets/icon.png",
+1 -1
View File
@@ -31,7 +31,7 @@ sha2 = "0.11"
subtle = "2.6.1"
thiserror = "2.0.18"
tracing = "0.1.44"
uuid = { version = "1.23.3", features = ["v7"] }
uuid = { version = "1.23.3", features = ["v4", "v7"] }
cron = "0.17"
chrono-tz = "0.10"
tokio = { version = "1.52", features = ["rt", "time", "macros", "sync"] }
+55 -2
View File
@@ -21,6 +21,7 @@ pub mod freeze_recovery;
pub mod model;
pub mod ports;
pub mod public_service;
mod reset_credit_automation;
mod use_case;
pub use use_case::plugins::{PluginDistributionPorts, PluginManagementService, PluginsService};
@@ -420,13 +421,15 @@ async fn initialize_inner(
auth.ensure_default_admin(config.default_password.expose())
.await?;
let accounts = Arc::new(DefaultAccountsService::new(
let accounts = initialize_accounts(
store.accounts(),
store.account_runtime(),
store.settings(),
timezone,
registry.clone(),
snapshot.clone(),
probe.clone(),
));
);
let backup_ports = store.backup();
let backups = Arc::new(DefaultBackupService::new(
backup_ports.repository(),
@@ -477,6 +480,7 @@ async fn initialize_inner(
let usage_data = initialize_usage_data(store.usage_data(), store.settings(), registry.clone());
let mut public_services = public_service::Registry::new(service_middleware);
public_services.register_settings(&settings)?;
public_services.register_accounts(&accounts)?;
public_services.register_usage_data(&usage_data)?;
let services = AdminServices {
timezone,
@@ -553,6 +557,33 @@ async fn initialize_inner(
.map_err(|_| AdminError::internal("导入 Worker 注册信息不合法"))?;
worker_contributions.push(WorkerContribution::Registration(registration));
worker_contributions.extend(freeze_recovery_worker_contribution(freeze_recovery)?);
let id = WorkerId::try_new(WorkerKind::ResetCreditAutomation, "admin")
.map_err(|_| AdminError::internal("重置卡 Worker ID 不合法"))?;
let schedule = WorkerSchedule::try_new(
Duration::from_secs(5),
Duration::from_secs(5),
Duration::from_secs(60),
Duration::from_secs(90),
Duration::from_secs(30),
)
.map_err(|_| AdminError::internal("重置卡 Worker 调度配置不合法"))?;
let lease = WorkerLeaseRequest::try_new(id.clone(), Duration::from_secs(90))
.map_err(|_| AdminError::internal("重置卡 Worker 租约配置不合法"))?;
worker_contributions.push(WorkerContribution::Registration(
WorkerRegistration::try_new(
id,
WorkerRunnable::Scheduled {
schedule,
lease: Some(lease),
task: Box::new(reset_credit_automation::ResetCreditAutomationTask {
accounts,
store: store.accounts(),
settings: store.settings(),
}),
},
)
.map_err(|_| AdminError::internal("重置卡 Worker 注册失败"))?,
));
Ok(AdminBundle {
services,
worker_contributions,
@@ -641,6 +672,28 @@ fn freeze_recovery_worker_contribution(
Ok(vec![WorkerContribution::Registration(registration)])
}
/// 服务端与 CLI 复用账号用例,不随命令启动后台调度
#[must_use]
pub fn initialize_accounts(
accounts: Arc<dyn ports::store::AccountStore>,
account_runtime: Arc<dyn ports::store::AccountRuntimeStore>,
settings: Arc<dyn ports::store::SettingsStore>,
timezone: gateway_core::time::DeploymentTimeZone,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
) -> Arc<dyn AccountsService> {
Arc::new(DefaultAccountsService::new(
accounts,
account_runtime,
settings,
timezone,
providers,
snapshot,
probe,
))
}
/// 设置服务不依赖 Web 管理会话,CLI 与服务器通过同一用例执行事务和发布
pub fn initialize_settings(
store: Arc<dyn ports::store::SettingsStore>,
@@ -294,3 +294,24 @@ pub enum AccountConnectionTestEvent {
/// 每次连接测试独占的有限事件流
pub type AccountConnectionTestEventStream =
Pin<Box<dyn Stream<Item = AccountConnectionTestEvent> + Send + 'static>>;
/// 单个账号普通并发池的实时租约明细,不关联请求历史
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountConcurrencyEntry {
pub lease_id: String,
pub request_id: Option<String>,
pub client_key_id: Option<String>,
pub client_key_name: Option<String>,
pub model: Option<String>,
pub client_ip: Option<String>,
pub started_at: Option<DateTime<Utc>>,
pub acquired_at: Option<DateTime<Utc>>,
}
/// 同一次 Redis 读取中的当前占用数和分页明细
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountConcurrencyPage {
pub entries: Vec<AccountConcurrencyEntry>,
pub total: u64,
pub observed_at: DateTime<Utc>,
}
@@ -3,8 +3,8 @@
use super::{
client_keys::ClientKeyRecord,
observability::{
HealthTimeline, ObservabilityPageSize, OpsErrorPage, RequestMetricPoint, TimeRange,
UsageOverview, UsagePage,
HealthTimeline, ObservabilityPageSize, RequestMetricPoint, TimeRange, UsageOverview,
UsagePage,
},
};
@@ -18,6 +18,7 @@ pub struct KeyUsageQuery {
pub enum KeyUsageRecordKind {
Success,
Error,
All,
}
#[derive(Debug, Clone)]
@@ -35,7 +36,4 @@ pub struct KeyUsageOverview {
pub health_timeline: HealthTimeline,
}
pub enum KeyUsageRecords {
Success(UsagePage),
Error(OpsErrorPage),
}
pub type KeyUsageRecords = UsagePage;
@@ -22,6 +22,7 @@ pub mod provider_credentials;
pub mod proxies;
pub mod quota_forecast;
pub mod quota_forecast_sampling;
pub mod reset_credit_automation;
pub mod retention;
pub mod settings;
pub mod system;
@@ -195,6 +195,7 @@ pub struct UsageQuery {
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DashboardQuery {
pub range: TimeRange,
pub yesterday_range: TimeRange,
pub granularity: Granularity,
pub account_limit: u16,
pub recent_request_limit: u16,
@@ -202,10 +203,21 @@ pub struct DashboardQuery {
}
impl DashboardQuery {
#[must_use]
pub fn new(range: TimeRange) -> Self {
Self {
pub fn new(
range: TimeRange,
timezone: gateway_core::time::DeploymentTimeZone,
) -> Result<Self, AdminModelError> {
let yesterday_range = TimeRange::new(
timezone
.days_before(range.end, 1)
.ok_or(AdminModelError::InvalidTimeRange)?,
timezone
.day_start(range.end)
.ok_or(AdminModelError::InvalidTimeRange)?,
)?;
Ok(Self {
range,
yesterday_range,
granularity: Granularity::for_range(range),
account_limit: 4,
recent_request_limit: 10,
@@ -213,7 +225,7 @@ impl DashboardQuery {
outcome: Some(RequestOutcome::Succeeded),
..UsageFilter::default()
},
}
})
}
}
@@ -653,6 +665,7 @@ pub struct DashboardObservation {
pub analytics: AnalyticsMetadata,
pub range: TimeRange,
pub totals: DashboardTotals,
pub yesterday: RequestMetrics,
pub provider_accounts: AccountPoolMetrics,
pub runtime_slots: Option<DashboardRuntimeSlots>,
pub trend: Vec<RequestMetricPoint>,
@@ -664,6 +677,9 @@ pub struct DashboardObservation {
#[derive(Debug, Clone, PartialEq)]
pub struct UsageListRecord {
pub outcome: RequestOutcome,
pub error_code: Option<String>,
pub error_message: Option<String>,
pub client_status_code: Option<u16>,
pub client_api_key_name: Option<String>,
pub id: String,
pub endpoint: String,
@@ -76,6 +76,7 @@ pub struct PluginManagementResource {
/// 原始正文可能含业务数据,不能派生内容型 Debug 或写入诊断日志
pub struct PluginManagementRequest {
pub request_context: Option<std::sync::Arc<gateway_core::engine::extensions::RequestContext>>,
pub method: String,
pub path: String,
pub query: String,
@@ -1,6 +1,6 @@
//! 插件私有状态的归属、代次隔离与迁移数据合同
use std::{collections::BTreeMap, fmt};
use std::{collections::BTreeMap, fmt, sync::Arc};
use serde::{Deserialize, Serialize};
@@ -173,6 +173,8 @@ pub struct PluginStateTransition {
pub instance_id: String,
pub artifact_sha256: String,
pub namespaces: Vec<PluginStateMigrationNamespace>,
/// Store 持有的迁移所有权;最后一个调用持有者释放后允许回收遗留 staging
pub guard: Arc<dyn Send + Sync>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -104,6 +104,8 @@ pub struct NewProxy {
#[derive(Debug, Clone)]
pub struct UpdateProxy {
/// 省略保留配置,零值取消限制
pub max_websocket_connections: Option<u32>,
pub enabled: Option<bool>,
pub auto_location: Option<bool>,
pub test: Option<ProxyTestResult>,
@@ -121,6 +123,21 @@ pub struct ProxyMutation {
pub record: ProxyRecord,
}
pub const MAX_PROXY_BATCH_SIZE: usize = 100;
#[derive(Debug)]
pub struct TestProxy {
pub id: String,
pub revision: Revision,
pub detect_location: bool,
}
#[derive(Debug)]
pub enum ProxyBatchResult<T> {
Completed(Result<T, super::AdminError>),
Skipped,
}
/// 检测失败与连通性分开,双栈冲突不得继续应用旧位置
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
@@ -0,0 +1,45 @@
//! 自动重置卡的持久计划与查询投影,不保存上游凭据或完整卡片
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlannedResetCredit {
pub id: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PendingResetCredit {
pub credit: PlannedResetCredit,
pub redeem_request_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResetCreditAutomationState {
pub next_discovery_at: DateTime<Utc>,
pub credits: Vec<PlannedResetCredit>,
pub pending: Option<PendingResetCredit>,
pub retry_at: Option<DateTime<Utc>>,
pub failures: u32,
pub last_result: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResetCreditAutomation {
pub account_id: String,
pub enabled: bool,
/// 零表示尚未创建;每次持久状态写入都递增版本
pub revision: u64,
pub next_run_at: DateTime<Utc>,
pub state: ResetCreditAutomationState,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResetCreditAutomationStatus {
pub enabled: bool,
pub lead_minutes: u32,
pub next_discovery_at: Option<DateTime<Utc>>,
pub next_consume_at: Option<DateTime<Utc>>,
pub last_result: Option<String>,
}
@@ -75,6 +75,8 @@ pub struct RuntimeSettingsValues {
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
pub reset_credit_lead_minutes: u32,
pub reset_credit_max_weekly_remaining_percent: Option<u32>,
}
/// 完整运行设置事实
@@ -189,6 +191,15 @@ impl From<RuntimeSettings> for ReplaceRuntimeSettings {
impl RuntimeSettingsValues {
/// 校验共同事实,返回字段名供边界转换错误;SQL 类型转换仍由 Store 处理
pub fn validate(&self) -> Result<(), &'static str> {
if !(1..=1440).contains(&self.reset_credit_lead_minutes) {
return Err("reset_credit_lead_minutes");
}
if self
.reset_credit_max_weekly_remaining_percent
.is_some_and(|percent| percent > 100)
{
return Err("reset_credit_max_weekly_remaining_percent");
}
self.request_location
.validate()
.map_err(|_| "request_location")?;
@@ -11,6 +11,7 @@ pub mod plugins;
pub mod pricing;
pub mod provider;
pub mod proxy;
pub mod reset_credit_automation;
pub mod retention;
pub mod store;
pub mod system;
@@ -64,5 +64,6 @@ pub struct ProxyImportReservation {
#[async_trait]
pub trait ProxyProbe: Send + Sync {
async fn test(&self, proxy: &OutboundProxy, detect_location: bool) -> ProxyTestResult;
/// 未指定代理时探测服务器直连出口,不继承进程代理环境变量
async fn test(&self, proxy: Option<&OutboundProxy>, detect_location: bool) -> ProxyTestResult;
}
@@ -0,0 +1,29 @@
//! 自动重置卡计划的持久化端口,配置变更沿用管理事务与审计
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use super::store::AdminStoreResult;
use crate::model::{MutationContext, reset_credit_automation::ResetCreditAutomation};
#[async_trait]
pub trait ResetCreditAutomationStore: Send + Sync {
async fn reset_credit_automation(
&self,
account_id: &str,
) -> AdminStoreResult<Option<ResetCreditAutomation>>;
/// 只读取到期的本地计划,按到期时间稳定排序
async fn due_reset_credit_automations(
&self,
now: DateTime<Utc>,
limit: u32,
) -> AdminStoreResult<Vec<String>>;
/// 按 record.revision 比较写入,成功返回新版本;配置变更传入审计上下文
async fn save_reset_credit_automation(
&self,
record: &ResetCreditAutomation,
context: Option<&MutationContext>,
) -> AdminStoreResult<u64>;
}
@@ -109,7 +109,9 @@ pub type AdminStoreResult<T> = Result<T, AdminStoreError>;
/// 账号目录与公共账号写操作
#[async_trait]
pub trait AccountStore: Send + Sync {
pub trait AccountStore:
super::reset_credit_automation::ResetCreditAutomationStore + Send + Sync
{
/// 插件回调按授权 Provider/账号在数据库过滤后做稳定 cursor 分页
async fn list_plugin_accounts(
&self,
@@ -128,6 +130,8 @@ pub trait AccountStore: Send + Sync {
runtime: AccountRuntimeSnapshot,
) -> AdminStoreResult<Option<AccountPageItem>>;
async fn account_exists(&self, account_id: &str) -> AdminStoreResult<bool>;
async fn load_account_usage(
&self,
range: TimeRange,
@@ -241,6 +245,13 @@ pub trait AccountStore: Send + Sync {
/// 可丢失账号运行态的管理读端口;跨存储编排由 Admin application service 拥有
#[async_trait]
pub trait AccountRuntimeStore: Send + Sync {
async fn account_concurrency(
&self,
account_id: &str,
offset: u64,
limit: u32,
) -> AdminStoreResult<crate::model::accounts::AccountConcurrencyPage>;
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot>;
async fn account_runtime(
@@ -0,0 +1,37 @@
//! 账号自动重置的公开操作声明,复用账号用例及宿主签发的审计上下文
use crate::{AccountsService, model::AdminError};
use gateway_core::account::ProviderAccountId;
use std::sync::Arc;
pub(super) fn register(
registry: &mut super::Registry,
service: &Arc<dyn AccountsService>,
) -> Result<(), AdminError> {
macro_rules! register {
($sdk:ident, $method:ident, $input:ty, $pattern:pat $(, $argument:expr)* $(,)?) => {{
let service = service.clone();
registry.register(concat!("accounts.", stringify!($method)), move |$pattern: $input| {
let service = service.clone();
Box::pin(async move { service.$method($($argument),*).await })
})?;
}};
}
register!(
GetResetCreditAutomation,
reset_credit_automation_status,
String,
account_id,
ProviderAccountId::new(account_id).map_err(|_| AdminError::invalid("账号 ID 无效"))?
);
register!(
SetResetCreditAutomation,
set_reset_credit_automation,
(String, bool),
(account_id, enabled),
&super::actor::mutation_context()?,
ProviderAccountId::new(account_id).map_err(|_| AdminError::invalid("账号 ID 无效"))?,
enabled
);
Ok(())
}
@@ -1,21 +1,55 @@
//! 公开服务写入主体由受信宿主边界设置,插件输入不能覆盖
use crate::model::usage_data::UsageActor;
use crate::model::{AdminError, MutationActor, MutationContext};
use std::future::Future;
tokio::task_local! {
static ACTOR: UsageActor;
static ACTOR: Option<UsageActor>;
}
/// 与 middleware Origin 独立传递,嵌套服务不会丢失真实调用主体
pub async fn scope_actor<F: Future>(actor: UsageActor, future: F) -> F::Output {
scope_captured(Some(actor), future).await
}
pub(super) async fn scope_captured<F: Future>(actor: Option<UsageActor>, future: F) -> F::Output {
ACTOR.scope(actor, future).await
}
pub(super) fn current_actor() -> Result<UsageActor, crate::model::usage_data::UsageDataError> {
ACTOR.try_with(Clone::clone).map_err(|_| {
ACTOR.try_with(Clone::clone).ok().flatten().ok_or_else(|| {
crate::model::usage_data::UsageDataError::invalid(
"usage mutation requires a trusted caller identity",
)
})
}
pub(super) fn mutation_context() -> Result<MutationContext, AdminError> {
let actor = ACTOR
.try_with(Clone::clone)
.ok()
.flatten()
.ok_or_else(|| AdminError::invalid("服务写入缺少可信调用主体"))?;
super::CURRENT
.try_with(|origin| {
let (actor, request_id) = match actor {
UsageActor::Plugin { instance_id, .. } => (
MutationActor::System,
format!(
"plugin:{instance_id}:request:{}:call:{}",
origin.request_id, origin.call_id
),
),
UsageActor::Admin { subject } => (
MutationActor::AdminSession {
admin_user_id: subject,
},
origin.request_id.clone(),
),
UsageActor::System => (MutationActor::System, origin.request_id.clone()),
};
MutationContext { actor, request_id }
})
.map_err(|_| AdminError::invalid("服务写入缺少调用上下文"))
}
@@ -1,5 +1,6 @@
//! 主动服务调用的统一组合入口;普通业务方法由所属 HTTP 或执行边界包裹
mod accounts;
mod actor;
mod registry;
mod settings;
@@ -101,8 +102,10 @@ pub(crate) async fn invoke(
plan: plan.clone(),
};
let inner = context.clone();
// next 可在另一回调任务中执行,终端必须保留发起主体及其缺席状态
let actor = actor::current_actor().ok();
let next = compose(Vec::new(), move |input| {
Box::pin(scope(inner, terminal(input)))
Box::pin(actor::scope_captured(actor, scope(inner, terminal(input))))
});
tokio::select! {
biased;
@@ -29,6 +29,13 @@ impl Registry {
super::settings::register(self, service)
}
pub fn register_accounts(
&mut self,
service: &Arc<dyn crate::AccountsService>,
) -> Result<(), AdminError> {
super::accounts::register(self, service)
}
pub(crate) fn register<I, O>(
&mut self,
name: &'static str,
@@ -0,0 +1,125 @@
//! 每日错峰发现与已知卡片的临期执行,周期只读取本地到期计划
use crate::{
model::reset_credit_automation::{PlannedResetCredit, ResetCreditAutomation},
ports::store::{AccountStore, SettingsStore},
use_case::accounts::AccountsService,
};
use chrono::{DateTime, Utc};
use futures::StreamExt as _;
use gateway_core::{
account::ProviderAccountId,
task::{ScheduledTask, WorkerCycleContext, WorkerTaskError},
time::DeploymentTimeZone,
};
use sha2::{Digest as _, Sha256};
use std::{sync::Arc, time::Duration};
/// 当地次日随机发现时间;随机数由调用者提供,结果持久化后不会因重启重抽
pub(crate) fn next_discovery(
now: DateTime<Utc>,
timezone: DeploymentTimeZone,
random: u64,
) -> Option<DateTime<Utc>> {
let start = timezone.days_after(now, 1)?;
let end = timezone.days_after(now, 2)?;
let seconds = u64::try_from((end - start).num_seconds()).ok()?;
if seconds == 0 {
return None;
}
start.checked_add_signed(chrono::Duration::seconds(
i64::try_from(random % seconds).ok()?,
))
}
/// 提前窗口的前 10% 用于账号错峰,最多一分钟,其余时间保留给网络失败恢复
pub(crate) fn consume_at(
account_id: &str,
credit: &PlannedResetCredit,
lead_minutes: u32,
) -> DateTime<Utc> {
let lead_seconds = i64::from(lead_minutes) * 60;
let mut hash = Sha256::new();
hash.update(account_id.as_bytes());
hash.update([0]);
hash.update(credit.id.as_bytes());
let digest = hash.finalize();
let offset =
i64::from(u16::from_be_bytes([digest[0], digest[1]])) % (lead_seconds / 10).clamp(1, 60);
credit.expires_at - chrono::Duration::seconds(lead_seconds - offset)
}
pub(crate) fn next_consumption(
record: &ResetCreditAutomation,
lead_minutes: u32,
) -> Option<DateTime<Utc>> {
record
.state
.credits
.iter()
.map(|credit| consume_at(&record.account_id, credit, lead_minutes))
.min()
}
pub(crate) fn reschedule(
record: &mut ResetCreditAutomation,
lead_minutes: u32,
now: DateTime<Utc>,
) {
record.next_run_at = if let Some(retry) = record.state.retry_at {
retry
} else if record.state.pending.is_some() {
now
} else {
next_consumption(record, lead_minutes).map_or(record.state.next_discovery_at, |due| {
due.min(record.state.next_discovery_at)
})
};
}
pub(crate) struct ResetCreditAutomationTask {
pub(crate) accounts: Arc<dyn AccountsService>,
pub(crate) store: Arc<dyn AccountStore>,
pub(crate) settings: Arc<dyn SettingsStore>,
}
impl ScheduledTask for ResetCreditAutomationTask {
fn run_cycle(
&self,
context: WorkerCycleContext,
) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
let settings = self
.settings
.load_runtime_settings()
.await
.map_err(|_| WorkerTaskError::safe("重置卡设置读取失败"))?;
// 单轮有界,积压沿到期顺序继续;并发复用已有账号刷新配置
let due = self
.store
.due_reset_credit_automations(Utc::now(), 100)
.await
.map_err(|_| WorkerTaskError::safe("重置卡计划读取失败"))?;
let work = futures::stream::iter(due)
.map(|account_id| async move {
let id = ProviderAccountId::new(account_id.clone())
.map_err(|_| WorkerTaskError::safe("重置卡账号 ID 不合法"))?;
if let Err(error) = self.accounts.reconcile_reset_credit_automation(id).await {
tracing::warn!(
account_id,
error_kind = error.kind().as_str(),
"自动重置卡任务失败"
);
}
Ok::<_, WorkerTaskError>(())
})
.buffer_unordered(settings.values.refresh_concurrency.max(1) as usize)
.collect::<Vec<_>>();
tokio::select! {
() = context.cancellation().cancelled() => {},
_ = tokio::time::timeout(Duration::from_secs(30), work) => {},
}
Ok(())
})
}
}
@@ -1,5 +1,7 @@
//! 统一账号目录与原生 Provider 分派
mod reset_credit_automation;
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
@@ -52,6 +54,13 @@ const CONNECTION_TEST_INPUT: &str = "Reply with exactly OK.";
/// 统一账号页消费的服务
#[async_trait]
pub trait AccountsService: Send + Sync {
async fn concurrency(
&self,
account_id: ProviderAccountId,
page: u32,
page_size: u32,
) -> Result<crate::model::accounts::AccountConcurrencyPage, AdminError>;
async fn list(&self, query: AccountListQuery) -> Result<AccountDirectoryPage, AdminError>;
async fn export(
@@ -123,6 +132,29 @@ pub trait AccountsService: Send + Sync {
Err(AdminError::invalid("当前 Provider 不支持账号头像"))
}
async fn reset_credit_automation_status(
&self,
_account_id: ProviderAccountId,
) -> Result<crate::model::reset_credit_automation::ResetCreditAutomationStatus, AdminError>
{
Err(AdminError::invalid("账号不支持自动重置卡"))
}
async fn set_reset_credit_automation(
&self,
_context: &MutationContext,
_account_id: ProviderAccountId,
_enabled: bool,
) -> Result<crate::model::reset_credit_automation::ResetCreditAutomationStatus, AdminError>
{
Err(AdminError::invalid("账号不支持自动重置卡"))
}
async fn reconcile_reset_credit_automation(
&self,
_account_id: ProviderAccountId,
) -> Result<(), AdminError> {
Err(AdminError::invalid("账号不支持自动重置卡"))
}
async fn reset_credits(
&self,
_context: &MutationContext,
@@ -160,6 +192,8 @@ pub trait AccountsService: Send + Sync {
pub(crate) struct DefaultAccountsService {
accounts: Arc<dyn AccountStore>,
account_runtime: Arc<dyn AccountRuntimeStore>,
settings: Arc<dyn crate::ports::store::SettingsStore>,
timezone: gateway_core::time::DeploymentTimeZone,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
@@ -172,6 +206,8 @@ impl DefaultAccountsService {
pub(crate) fn new(
accounts: Arc<dyn AccountStore>,
account_runtime: Arc<dyn AccountRuntimeStore>,
settings: Arc<dyn crate::ports::store::SettingsStore>,
timezone: gateway_core::time::DeploymentTimeZone,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
@@ -179,6 +215,8 @@ impl DefaultAccountsService {
Self {
accounts,
account_runtime,
settings,
timezone,
providers,
snapshot,
probe,
@@ -381,6 +419,31 @@ impl DefaultAccountsService {
#[async_trait]
impl AccountsService for DefaultAccountsService {
async fn concurrency(
&self,
account_id: ProviderAccountId,
page: u32,
page_size: u32,
) -> Result<crate::model::accounts::AccountConcurrencyPage, AdminError> {
let offset = page
.checked_sub(1)
.and_then(|page| page.checked_mul(page_size))
.filter(|_| (1..=100).contains(&page_size))
.ok_or_else(|| AdminError::invalid("并发明细分页参数无效"))?;
if !self
.accounts
.account_exists(account_id.as_str())
.await
.map_err(|error| map_store_error(error, "account concurrency"))?
{
return Err(AdminError::not_found("账号不存在"));
}
self.account_runtime
.account_concurrency(account_id.as_str(), u64::from(offset), page_size)
.await
.map_err(|error| map_store_error(error, "account concurrency"))
}
async fn list(&self, query: AccountListQuery) -> Result<AccountDirectoryPage, AdminError> {
let providers = &self.providers;
let runtime = self
@@ -891,27 +954,48 @@ impl AccountsService for DefaultAccountsService {
.map_err(|error| map_provider_error(error, "provider profile avatar"))
}
async fn reset_credit_automation_status(
&self,
account_id: ProviderAccountId,
) -> Result<crate::model::reset_credit_automation::ResetCreditAutomationStatus, AdminError>
{
self.automation_status(&account_id).await
}
async fn set_reset_credit_automation(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
enabled: bool,
) -> Result<crate::model::reset_credit_automation::ResetCreditAutomationStatus, AdminError>
{
self.configure_automation(context, &account_id, enabled)
.await
}
async fn reconcile_reset_credit_automation(
&self,
account_id: ProviderAccountId,
) -> Result<(), AdminError> {
self.reconcile_automation(&account_id).await
}
async fn reset_credits(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
) -> Result<ProviderResetCredits, AdminError> {
let (_, provider) = self.provider_for_account(&account_id).await?;
match provider.reset_credits(&account_id).await {
Ok(credits) => Ok(credits),
Err(error)
if error.kind()
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
{
self.refresh(context, account_id.clone()).await?;
let (_, provider) = self.provider_for_account(&account_id).await?;
provider
.reset_credits(&account_id)
.await
.map_err(map_reset_credits_error_after_refresh)
}
Err(error) => Err(map_provider_error(error, "provider reset credits")),
let lock = self.reset_credit_lock(&account_id).await;
let _guard = lock.lock().await;
let credits = self
.reset_credits_upstream(context, account_id.clone())
.await?;
if let Err(error) = self.remember_reset_credits(&account_id, &credits).await {
tracing::warn!(
account_id = account_id.as_str(),
error_kind = error.kind().as_str(),
"保存重置卡计划失败"
);
}
Ok(credits)
}
async fn consume_reset_credit(
@@ -919,27 +1003,26 @@ impl AccountsService for DefaultAccountsService {
context: &MutationContext,
command: ConsumeProviderResetCredit,
) -> Result<ProviderResetCreditResult, AdminError> {
let account_id = command.account_id.clone();
// 覆盖 credential refresh + 同键重试的完整账号级临界区,避免 401 两次调用
// 之间插入另一笔不可逆消费
let lock = self.reset_credit_lock(&account_id).await;
let lock = self.reset_credit_lock(&command.account_id).await;
let _guard = lock.lock().await;
let (_, provider) = self.provider_for_account(&account_id).await?;
match provider.consume_reset_credit(command.clone()).await {
Ok(result) => Ok(result),
Err(error)
if error.kind()
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
{
self.refresh(context, account_id.clone()).await?;
let (_, provider) = self.provider_for_account(&account_id).await?;
provider
.consume_reset_credit(command)
.await
.map_err(map_reset_credits_error_after_refresh)
}
Err(error) => Err(map_provider_error(error, "provider reset-credit consume")),
if self
.accounts
.reset_credit_automation(command.account_id.as_str())
.await
.map_err(|e| map_store_error(e, "reset-credit automation"))?
.is_some_and(|record| {
record
.state
.pending
.is_some_and(|pending| pending.credit.expires_at > Utc::now())
})
{
return Err(AdminError::new(
crate::model::AdminErrorKind::Conflict,
"自动重置结果待确认,请稍后刷新",
));
}
self.consume_reset_credit_upstream(context, command).await
}
async fn models(
@@ -0,0 +1,513 @@
//! 重置卡自动使用的账号锁、持久幂等键、查询缓存与重试编排
use super::*;
impl DefaultAccountsService {
pub(super) async fn reset_credits_upstream(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
) -> Result<ProviderResetCredits, AdminError> {
let (_, provider) = self.provider_for_account(&account_id).await?;
match provider.reset_credits(&account_id).await {
Ok(credits) => Ok(credits),
Err(error)
if error.kind()
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
{
self.refresh(context, account_id.clone()).await?;
let (_, provider) = self.provider_for_account(&account_id).await?;
provider
.reset_credits(&account_id)
.await
.map_err(map_reset_credits_error_after_refresh)
}
Err(error) => Err(map_provider_error(error, "provider reset credits")),
}
}
pub(super) async fn consume_reset_credit_upstream(
&self,
context: &MutationContext,
command: ConsumeProviderResetCredit,
) -> Result<ProviderResetCreditResult, AdminError> {
let account_id = command.account_id.clone();
let (_, provider) = self.provider_for_account(&account_id).await?;
match provider.consume_reset_credit(command.clone()).await {
Ok(result) => Ok(result),
Err(error)
if error.kind()
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
{
self.refresh(context, account_id.clone()).await?;
let (_, provider) = self.provider_for_account(&account_id).await?;
provider
.consume_reset_credit(command)
.await
.map_err(map_reset_credits_error_after_refresh)
}
Err(error) => Err(map_provider_error(error, "provider reset-credit consume")),
}
}
}
use crate::{
model::{MutationActor, reset_credit_automation::*},
reset_credit_automation::{consume_at, next_consumption, next_discovery, reschedule},
};
use chrono::{DateTime, Utc};
use rand_core::{OsRng, RngCore as _};
impl DefaultAccountsService {
async fn automation_settings(
&self,
) -> Result<crate::model::settings::RuntimeSettingsValues, AdminError> {
Ok(self
.settings
.load_runtime_settings()
.await
.map_err(|e| map_store_error(e, "runtime settings"))?
.values)
}
pub(super) async fn automation_status(
&self,
account_id: &ProviderAccountId,
) -> Result<ResetCreditAutomationStatus, AdminError> {
self.require_reset_credit_automation(account_id).await?;
let lead_minutes = self.automation_settings().await?.reset_credit_lead_minutes;
let record = self
.accounts
.reset_credit_automation(account_id.as_str())
.await
.map_err(|e| map_store_error(e, "reset-credit automation"))?;
Ok(ResetCreditAutomationStatus {
enabled: record.as_ref().is_some_and(|r| r.enabled),
lead_minutes,
next_discovery_at: record
.as_ref()
.filter(|r| r.enabled)
.map(|r| r.state.next_discovery_at),
next_consume_at: record.as_ref().filter(|r| r.enabled).and_then(|r| {
next_consumption(r, lead_minutes)
.or_else(|| {
r.state
.pending
.as_ref()
.map(|pending| consume_at(&r.account_id, &pending.credit, lead_minutes))
})
.map(|due| r.state.retry_at.map_or(due, |retry| due.max(retry)))
}),
last_result: record.and_then(|r| {
if r.state
.pending
.as_ref()
.is_some_and(|pending| pending.credit.expires_at <= Utc::now())
{
Some("expired_unknown".to_owned())
} else {
r.state.last_result
}
}),
})
}
async fn require_reset_credit_automation(
&self,
account_id: &ProviderAccountId,
) -> Result<bool, AdminError> {
let (stored, provider) = self.provider_for_account(account_id).await?;
let capabilities =
provider.account_capabilities(account_id, &stored.account.authentication_kind);
if !capabilities.reset_credits || !capabilities.consume_reset_credit {
return Err(AdminError::invalid("当前账号不支持自动重置卡"));
}
Ok(stored.account.enabled)
}
pub(super) async fn configure_automation(
&self,
context: &MutationContext,
account_id: &ProviderAccountId,
enabled: bool,
) -> Result<ResetCreditAutomationStatus, AdminError> {
let lock = self.reset_credit_lock(account_id).await;
let _guard = lock.lock().await;
self.require_reset_credit_automation(account_id).await?;
let now = Utc::now();
let mut record = self
.accounts
.reset_credit_automation(account_id.as_str())
.await
.map_err(|e| map_store_error(e, "reset-credit automation"))?
.unwrap_or_else(|| ResetCreditAutomation {
account_id: account_id.as_str().to_owned(),
enabled: false,
revision: 0,
next_run_at: now,
state: ResetCreditAutomationState {
next_discovery_at: now,
credits: vec![],
pending: None,
retry_at: None,
failures: 0,
last_result: None,
},
});
if record.enabled != enabled {
record.enabled = enabled;
// 首次开启错开一分钟;重新开启保留已知到期时间及未决幂等键
if enabled && record.revision == 0 {
record.state.next_discovery_at =
now + chrono::Duration::seconds(i64::from(OsRng.next_u32() % 60));
}
let lead = self.automation_settings().await?.reset_credit_lead_minutes;
reschedule(&mut record, lead, now);
self.save_automation(&mut record, Some(context)).await?;
let settings = self
.settings
.load_runtime_settings()
.await
.map_err(|e| map_store_error(e, "runtime settings"))?;
publish_committed(self.snapshot.as_ref(), settings.config_revision).await?;
}
self.automation_status(account_id).await
}
async fn save_automation(
&self,
record: &mut ResetCreditAutomation,
context: Option<&MutationContext>,
) -> Result<(), AdminError> {
record.revision = self
.accounts
.save_reset_credit_automation(record, context)
.await
.map_err(|e| map_store_error(e, "reset-credit automation"))?;
Ok(())
}
fn observe_reset_credits(
&self,
record: &mut ResetCreditAutomation,
credits: &ProviderResetCredits,
now: DateTime<Utc>,
) -> Result<(), AdminError> {
record.state.credits = credits
.credits
.iter()
.filter(|credit| credit.status.as_deref() == Some("available"))
.filter_map(|credit| {
credit
.expires_at
.filter(|expiry| *expiry > now)
.map(|expires_at| PlannedResetCredit {
id: credit.id.clone(),
expires_at,
})
})
.collect();
record.state.credits.sort_by_key(|credit| credit.expires_at);
record.state.credits.dedup_by(|a, b| a.id == b.id);
if record.state.next_discovery_at <= now
|| self
.timezone
.local(record.state.next_discovery_at)
.date_naive()
== self.timezone.local(now).date_naive()
{
record.state.next_discovery_at =
next_discovery(now, self.timezone, OsRng.next_u64())
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
}
if record.state.pending.is_none() {
record.state.retry_at = None;
record.state.failures = 0;
}
Ok(())
}
pub(super) async fn remember_reset_credits(
&self,
account_id: &ProviderAccountId,
credits: &ProviderResetCredits,
) -> Result<(), AdminError> {
let Some(mut record) = self
.accounts
.reset_credit_automation(account_id.as_str())
.await
.map_err(|e| map_store_error(e, "reset-credit automation"))?
else {
return Ok(());
};
if !record.enabled {
return Ok(());
}
let now = Utc::now();
self.observe_reset_credits(&mut record, credits, now)?;
reschedule(
&mut record,
self.automation_settings().await?.reset_credit_lead_minutes,
now,
);
self.save_automation(&mut record, None).await
}
async fn automation_failed(
&self,
record: &mut ResetCreditAutomation,
lead: u32,
now: DateTime<Utc>,
) -> Result<(), AdminError> {
if record.state.pending.is_none()
&& !next_consumption(record, lead).is_some_and(|due| due <= now)
{
record.state.next_discovery_at =
next_discovery(now, self.timezone, OsRng.next_u64())
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
record.state.retry_at = None;
record.state.last_result = Some("discovery_failed".to_owned());
reschedule(record, lead, now);
return self.save_automation(record, None).await;
}
record.state.failures = record.state.failures.saturating_add(1);
let delay = (30_i64 * 2_i64.pow(record.state.failures.min(4) - 1)).min(300)
+ i64::from(OsRng.next_u32() % 30);
record.state.retry_at = Some(now + chrono::Duration::seconds(delay));
record.state.last_result = Some(
if record.state.pending.is_some() {
"pending"
} else {
"discovery_failed"
}
.to_owned(),
);
reschedule(record, lead, now);
self.save_automation(record, None).await
}
/// 暂不满足条件不丢弃卡片,也不创建未决消费,允许到期前再次判断
async fn defer_automation(
&self,
record: &mut ResetCreditAutomation,
lead: u32,
reason: &str,
) -> Result<(), AdminError> {
let now = Utc::now();
let retry = now + chrono::Duration::seconds(30 + i64::from(OsRng.next_u32() % 30));
record.state.retry_at = Some(
record
.state
.credits
.iter()
.map(|credit| credit.expires_at)
.min()
.map_or(retry, |expiry| retry.min(expiry)),
);
record.state.last_result = Some(reason.to_owned());
record.state.failures = 0;
reschedule(record, lead, now);
self.save_automation(record, None).await
}
async fn weekly_reset_credit_gate(
&self,
account_id: &ProviderAccountId,
max_remaining: u32,
) -> Result<Option<&'static str>, AdminError> {
let (_, provider) = self.provider_for_account(account_id).await?;
let quota = provider
.quota(ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: true,
rolling_usage: None,
})
.await
.map_err(|error| map_provider_error(error, "reset-credit weekly quota"))?;
// 复用账号周窗口归属,不把短期、月度或模型专属桶当作剩余周额度
let remaining = quota
.usage_windows()
.find(|(_, period)| *period == AccountUsagePeriod::Weekly)
.and_then(|(window, _)| window.used_percent)
.filter(|used| used.is_finite())
.map(|used| 100.0 - used.clamp(0.0, 100.0));
Ok(match remaining {
Some(remaining) if remaining > f64::from(max_remaining) => {
Some("weekly_quota_above_threshold")
}
Some(_) => None,
None => Some("weekly_quota_unavailable"),
})
}
pub(super) async fn reconcile_automation(
&self,
account_id: &ProviderAccountId,
) -> Result<(), AdminError> {
let lock = self.reset_credit_lock(account_id).await;
let _guard = lock.lock().await;
let Some(mut record) = self
.accounts
.reset_credit_automation(account_id.as_str())
.await
.map_err(|e| map_store_error(e, "reset-credit automation"))?
else {
return Ok(());
};
if !record.enabled {
return Ok(());
}
match self.require_reset_credit_automation(account_id).await {
Ok(true) => {}
Ok(false) => return Ok(()),
Err(error) => {
record.next_run_at = next_discovery(Utc::now(), self.timezone, OsRng.next_u64())
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
self.save_automation(&mut record, None).await?;
return Err(error);
}
}
let now = Utc::now();
let settings = self.automation_settings().await?;
let lead = settings.reset_credit_lead_minutes;
record
.state
.credits
.retain(|credit| credit.expires_at > now);
if record
.state
.pending
.as_ref()
.is_some_and(|pending| pending.credit.expires_at <= now)
{
record.state.pending = None;
record.state.retry_at = None;
record.state.last_result = Some("expired_unknown".to_owned());
}
if record.state.retry_at.is_some_and(|retry| retry > now) {
reschedule(&mut record, lead, now);
return self.save_automation(&mut record, None).await;
}
let context = MutationContext {
actor: MutationActor::System,
request_id: "reset-credit-automation".to_owned(),
};
let due_credit = next_consumption(&record, lead).is_some_and(|due| due <= now);
if record.state.pending.is_none()
&& (due_credit
|| record.state.next_discovery_at <= now
|| record.state.retry_at.is_some())
{
// 先保存下一次发现,查询被取消或进程重启也不会重复刷普通发现接口
if record.state.next_discovery_at <= now {
record.state.next_discovery_at =
next_discovery(now, self.timezone, OsRng.next_u64())
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
}
if due_credit || record.state.retry_at.is_some() {
record.state.retry_at = Some(now + chrono::Duration::seconds(30));
}
reschedule(&mut record, lead, now);
self.save_automation(&mut record, None).await?;
// 真正消费前重新核对卡片;每日发现结果也可直接承担本次核对
match self
.reset_credits_upstream(&context, account_id.clone())
.await
{
Ok(credits) => self.observe_reset_credits(&mut record, &credits, Utc::now())?,
Err(error) => {
self.automation_failed(&mut record, lead, Utc::now())
.await?;
return Err(error);
}
}
if let Some(credit) = record
.state
.credits
.iter()
.find(|credit| consume_at(account_id.as_str(), credit, lead) <= Utc::now())
.cloned()
{
if let Some(max_remaining) = settings.reset_credit_max_weekly_remaining_percent {
// 已发出的未决请求跳过此检查,继续沿原幂等键确认结果
match self
.weekly_reset_credit_gate(account_id, max_remaining)
.await
{
Ok(Some(reason)) => {
return self.defer_automation(&mut record, lead, reason).await;
}
Ok(None) => {}
Err(error) => {
self.defer_automation(&mut record, lead, "weekly_quota_unavailable")
.await?;
return Err(error);
}
}
}
// 刷新额度期间卡片可能到期,不能把已过期卡片持久化为待消费
if credit.expires_at <= Utc::now() {
record
.state
.credits
.retain(|credit| credit.expires_at > Utc::now());
reschedule(&mut record, lead, Utc::now());
return self.save_automation(&mut record, None).await;
}
record.state.pending = Some(PendingResetCredit {
credit,
redeem_request_id: uuid::Uuid::new_v4().to_string(),
});
record.state.last_result = Some("pending".to_owned());
record.state.retry_at = Some(Utc::now() + chrono::Duration::seconds(30));
reschedule(&mut record, lead, Utc::now());
// 必须在发送消费请求前落盘,进程退出或响应丢失继续使用相同幂等键
self.save_automation(&mut record, None).await?;
}
}
let Some(pending) = record.state.pending.clone() else {
reschedule(&mut record, lead, Utc::now());
return self.save_automation(&mut record, None).await;
};
let command = ConsumeProviderResetCredit {
account_id: account_id.clone(),
credit_id: Some(pending.credit.id.clone()),
redeem_request_id: uuid::Uuid::parse_str(&pending.redeem_request_id)
.map_err(|_| AdminError::internal("重置卡幂等键不合法"))?,
};
match self.consume_reset_credit_upstream(&context, command).await {
Ok(result) => {
let confirmed = matches!(result.code.as_str(), "reset" | "already_redeemed");
record.state.pending = None;
if result.code == "nothing_to_reset" {
return self
.defer_automation(&mut record, lead, "nothing_to_reset")
.await;
}
record
.state
.credits
.retain(|credit| credit.id != pending.credit.id);
record.state.retry_at = None;
record.state.failures = 0;
record.state.last_result = Some(if confirmed {
result.code
} else {
"not_redeemed".to_owned()
});
reschedule(&mut record, lead, Utc::now());
// 已确认的结果先落盘,再刷新额度;观测失败不能重放已完成的消费
self.save_automation(&mut record, None).await?;
if confirmed {
let _ = self.quota(account_id, true).await;
}
}
Err(error) => {
self.automation_failed(&mut record, lead, Utc::now())
.await?;
return Err(error);
}
}
Ok(())
}
}
@@ -14,9 +14,7 @@ use crate::{
KeyUsageOverview, KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecords,
KeyUsageRecordsQuery,
},
observability::{
Granularity, OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery,
},
observability::{Granularity, TimeRange, UsageFilter, UsageQuery, UsageRecordStatus},
system::SystemVersion,
},
ports::store::{ClientKeyStore, ObservabilityStore},
@@ -221,35 +219,21 @@ impl KeyUsageService for DefaultKeyUsageService {
let Some(id) = self.key_id(session_id).await? else {
return Ok(None);
};
let result = match query.kind {
KeyUsageRecordKind::Success => KeyUsageRecords::Success(
self.observations
.list_usage_records(UsageQuery {
status: Default::default(),
range: query.usage.range,
filter: usage_filter(&id, query.usage.model),
current_page: query.current_page,
page_size: query.page_size,
})
.await
.map_err(|error| map_store_error(error, "key usage records"))?,
),
KeyUsageRecordKind::Error => KeyUsageRecords::Error(
self.observations
.list_ops_errors(OpsErrorQuery {
range: query.usage.range,
filter: OpsErrorFilter {
client_api_key_ref: Some(id.as_str().to_owned()),
model: query.usage.model,
..OpsErrorFilter::default()
},
current_page: query.current_page,
page_size: query.page_size,
})
.await
.map_err(|error| map_store_error(error, "key usage errors"))?,
),
};
let result = self
.observations
.list_usage_records(UsageQuery {
status: match query.kind {
KeyUsageRecordKind::Success => UsageRecordStatus::Success,
KeyUsageRecordKind::Error => UsageRecordStatus::Error,
KeyUsageRecordKind::All => UsageRecordStatus::All,
},
range: query.usage.range,
filter: usage_filter(&id, query.usage.model),
current_page: query.current_page,
page_size: query.page_size,
})
.await
.map_err(|error| map_store_error(error, "key usage records"))?;
Ok(Some(result))
}
}
@@ -111,9 +111,11 @@ impl DefaultObservabilityService {
range: TimeRange,
) -> Result<DashboardResult, AdminError> {
let observed_at = Utc::now();
let query = DashboardQuery::new(range, self.timezone)
.map_err(|_| AdminError::internal("时间超出支持范围"))?;
let today_start = query.yesterday_range.end;
let (mut observation, settings) = futures::try_join!(
self.store
.dashboard_summary(DashboardQuery::new(range), observed_at),
self.store.dashboard_summary(query, observed_at),
self.settings.load_runtime_settings(),
)
.map_err(|error| map_store_error(error, "dashboard"))?;
@@ -126,17 +128,16 @@ impl DefaultObservabilityService {
.providers
.plan_type_display(&account.provider_kind, account.plan_type.as_deref());
}
let today_start = self
.timezone
.day_start(observation.range.end)
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
let yesterday_start = self
.timezone
.days_before(observation.range.end, 1)
.ok_or_else(|| AdminError::internal("时间超出支持范围"))?;
let today =
dashboard_period_metrics(&observation.trend, today_start, observation.range.end);
let yesterday = dashboard_period_metrics(&observation.trend, yesterday_start, today_start);
let today = dashboard_period_metrics(
observation
.trend
.iter()
.filter(|point| {
point.bucket_start >= today_start && point.bucket_start < observation.range.end
})
.map(|point| &point.metrics),
);
let yesterday = dashboard_period_metrics(std::iter::once(&observation.yesterday));
let total_billing_usd = observation.totals.billing_usd.clone();
let total_cached_token_rate = rate_or_zero(
observation.totals.cached_input_tokens,
@@ -1051,17 +1052,11 @@ fn trend_point(point: RequestMetricPoint) -> TrendPoint {
}
}
fn dashboard_period_metrics(
points: &[RequestMetricPoint],
start: DateTime<Utc>,
end: DateTime<Utc>,
fn dashboard_period_metrics<'a>(
metrics: impl IntoIterator<Item = &'a RequestMetrics>,
) -> DashboardPeriodMetrics {
let mut result = DashboardPeriodMetrics::default();
for metrics in points
.iter()
.filter(|point| point.bucket_start >= start && point.bucket_start < end)
.map(|point| &point.metrics)
{
for metrics in metrics {
result.request_count = result.request_count.saturating_add(metrics.request_count);
result.input_tokens = result.input_tokens.saturating_add(metrics.input_tokens);
result.output_tokens = result.output_tokens.saturating_add(metrics.output_tokens);
@@ -1,6 +1,9 @@
//! 账号出站代理的管理、账号解绑与连通性测试用例
use std::sync::Arc;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use async_trait::async_trait;
use futures::{StreamExt, stream};
@@ -27,6 +30,16 @@ use crate::{
#[async_trait]
pub trait ProxiesService: Send + Sync {
async fn create_batch(
&self,
commands: Vec<Result<NewProxy, AdminError>>,
context: &MutationContext,
) -> Result<Vec<ProxyBatchResult<ProxyMutation>>, AdminError>;
async fn test_batch(
&self,
commands: Vec<Result<TestProxy, AdminError>>,
context: &MutationContext,
) -> Result<Vec<ProxyBatchResult<ProxyRecord>>, AdminError>;
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError>;
async fn list_accounts(
&self,
@@ -61,10 +74,10 @@ pub trait ProxiesService: Send + Sync {
detect_location: bool,
context: &MutationContext,
) -> Result<ProxyRecord, AdminError>;
/// 探测未保存的连接地址,不写入代理记录或修改账号绑定
/// 探测未保存的连接地址或服务器直连出口,不写入配置或修改账号绑定
async fn probe(
&self,
proxy: &OutboundProxy,
proxy: Option<&OutboundProxy>,
detect_location: bool,
) -> Result<ProxyTestResult, AdminError>;
}
@@ -96,6 +109,75 @@ impl DefaultProxiesService {
#[async_trait]
impl ProxiesService for DefaultProxiesService {
async fn create_batch(
&self,
commands: Vec<Result<NewProxy, AdminError>>,
context: &MutationContext,
) -> Result<Vec<ProxyBatchResult<ProxyMutation>>, AdminError> {
validate_batch_size(commands.len())?;
let mut stopped = false;
let mut results = Vec::with_capacity(commands.len());
for command in commands {
if stopped {
results.push(ProxyBatchResult::Skipped);
continue;
}
let result = match command {
Ok(command) => self.create(command, context).await,
Err(error) => Err(error),
};
stopped = result.as_ref().is_err_and(batch_should_stop);
results.push(ProxyBatchResult::Completed(result));
}
Ok(results)
}
async fn test_batch(
&self,
commands: Vec<Result<TestProxy, AdminError>>,
context: &MutationContext,
) -> Result<Vec<ProxyBatchResult<ProxyRecord>>, AdminError> {
validate_batch_size(commands.len())?;
let mut ids = std::collections::HashSet::with_capacity(commands.len());
if commands
.iter()
.filter_map(|command| command.as_ref().ok())
.any(|command| !ids.insert(&command.id))
{
return Err(AdminError::invalid("批量测试不能包含重复代理"));
}
let stopped = AtomicBool::new(false);
// 与单条测试共用全局容量,批次最多占用两槽,保留交互操作的容量
Ok(stream::iter(commands)
.map(|command| {
let stopped = &stopped;
async move {
if stopped.load(Ordering::Relaxed) {
return ProxyBatchResult::Skipped;
}
let result = match command {
Ok(command) => {
self.test(
&command.id,
command.revision,
command.detect_location,
context,
)
.await
}
Err(error) => Err(error),
};
if result.as_ref().is_err_and(batch_should_stop) {
stopped.store(true, Ordering::Relaxed);
}
ProxyBatchResult::Completed(result)
}
})
.buffered(2)
.collect()
.await)
}
async fn remove_account(
&self,
proxy_id: &str,
@@ -194,7 +276,7 @@ impl ProxiesService for DefaultProxiesService {
.transpose()
.map_err(|_| AdminError::invalid("代理位置不合法"))?;
command.test = if command.auto_location {
Some(self.probe(&command.proxy, true).await?)
Some(self.probe(Some(&command.proxy), true).await?)
} else {
None
};
@@ -233,7 +315,7 @@ impl ProxiesService for DefaultProxiesService {
if command.auto_location.unwrap_or(current.auto_location)
&& (!current.auto_location || proxy != &current.proxy)
{
command.test = Some(self.probe(proxy, true).await?);
command.test = Some(self.probe(Some(proxy), true).await?);
}
}
let result = self
@@ -262,7 +344,7 @@ impl ProxiesService for DefaultProxiesService {
async fn probe(
&self,
proxy: &OutboundProxy,
proxy: Option<&OutboundProxy>,
detect_location: bool,
) -> Result<ProxyTestResult, AdminError> {
let _permit = self.test_slots.try_acquire().map_err(|_| {
@@ -292,7 +374,7 @@ impl ProxiesService for DefaultProxiesService {
// 手动解析只为本次测试请求位置,不改变自动跟随的持久配置
let result = self
.probe
.test(&record.proxy, record.auto_location || detect_location)
.test(Some(&record.proxy), record.auto_location || detect_location)
.await;
let mutation = self
.store
@@ -313,3 +395,22 @@ fn validate_name(value: &str) -> Result<String, AdminError> {
}
Ok(value.to_owned())
}
fn validate_batch_size(size: usize) -> Result<(), AdminError> {
if size == 0 || size > MAX_PROXY_BATCH_SIZE {
return Err(AdminError::invalid("一次需要提交 1 至 100 条代理"));
}
Ok(())
}
fn batch_should_stop(error: &AdminError) -> bool {
matches!(
error.kind(),
AdminErrorKind::Unauthorized
| AdminErrorKind::Forbidden
| AdminErrorKind::BadGateway
| AdminErrorKind::UpstreamResultUnknown
| AdminErrorKind::Unavailable
| AdminErrorKind::Internal
)
}
@@ -273,6 +273,19 @@ struct FakeRuntimeStore {
#[async_trait]
impl AccountRuntimeStore for FakeRuntimeStore {
async fn account_concurrency(
&self,
_: &str,
_: u64,
_: u32,
) -> AdminStoreResult<gateway_admin::model::accounts::AccountConcurrencyPage> {
Err(gateway_admin::ports::store::AdminStoreError::new(
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
"account concurrency",
"not configured in this fixture",
))
}
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot::default())
}
@@ -1,5 +1,7 @@
//! 账号管理用例测试及共享 Provider、存储替身
mod reset_credit_automation;
use std::{
collections::BTreeMap,
sync::{Arc, Mutex},
@@ -87,6 +89,11 @@ pub(super) struct FakeProviderAdmin {
quota_refresh_account: Mutex<Option<(Arc<FakeAccountStore>, AccountRecord)>>,
current_credential_revision: Mutex<Revision>,
reset_credit_commands: Mutex<Vec<ConsumeProviderResetCredit>>,
reset_credits: Mutex<Vec<gateway_admin::model::provider_credentials::ProviderResetCredit>>,
reset_credit_failure: Mutex<Option<ProviderAdminErrorKind>>,
reset_credit_result: Mutex<String>,
reset_credit_query_started: tokio::sync::Notify,
reset_credit_query_gate: Mutex<Option<tokio::sync::oneshot::Receiver<()>>>,
profile_result: Mutex<Result<ProviderProfileStatistics, ProviderAdminErrorKind>>,
subscription_result: Mutex<Result<Option<ProviderSubscription>, ProviderAdminErrorKind>>,
personal_info_barrier: Mutex<Option<Arc<tokio::sync::Barrier>>>,
@@ -112,6 +119,11 @@ impl FakeProviderAdmin {
quota_refresh_account: Mutex::new(None),
current_credential_revision: Mutex::new(revision(1)),
reset_credit_commands: Mutex::new(Vec::new()),
reset_credits: Mutex::default(),
reset_credit_failure: Mutex::default(),
reset_credit_result: Mutex::new("reset".into()),
reset_credit_query_started: tokio::sync::Notify::new(),
reset_credit_query_gate: Mutex::new(None),
profile_result: Mutex::new(Ok(empty_profile_statistics())),
subscription_result: Mutex::new(Ok(None)),
personal_info_barrier: Mutex::new(None),
@@ -498,6 +510,28 @@ impl ProviderAdmin for FakeProviderAdmin {
Ok(self.quota.lock().expect("provider quota").clone())
}
async fn reset_credits(
&self,
_: &ProviderAccountId,
) -> Result<gateway_admin::model::provider_credentials::ProviderResetCredits, ProviderAdminError>
{
self.record("provider.reset_credits");
self.require_available()?;
self.reset_credit_query_started.notify_one();
let gate = self.reset_credit_query_gate.lock().unwrap().take();
if let Some(gate) = gate {
let _ = gate.await;
}
let credits = self.reset_credits.lock().unwrap().clone();
Ok(
gateway_admin::model::provider_credentials::ProviderResetCredits {
available_count: credits.len() as u64,
credits,
},
)
}
async fn consume_reset_credit(
&self,
command: ConsumeProviderResetCredit,
@@ -508,8 +542,11 @@ impl ProviderAdmin for FakeProviderAdmin {
.expect("provider reset-credit commands")
.push(command);
self.require_available()?;
if let Some(kind) = self.reset_credit_failure.lock().unwrap().take() {
return Err(ProviderAdminError::new(kind));
}
Ok(ProviderResetCreditResult {
code: "reset".to_owned(),
code: self.reset_credit_result.lock().unwrap().clone(),
credit: None,
})
}
@@ -577,6 +614,9 @@ pub(super) struct FakeAccountStore {
quota_window_queries: Mutex<Vec<AccountUsageWindowQuery>>,
quota_forecast_history: Mutex<QuotaForecastHistory>,
update_commands: Mutex<Vec<UpdateAccount>>,
automations: Mutex<
BTreeMap<String, gateway_admin::model::reset_credit_automation::ResetCreditAutomation>,
>,
pub(super) lowered_limits: Mutex<Vec<(String, u32)>>,
}
@@ -599,6 +639,7 @@ impl FakeAccountStore {
quota_forecast_history: Mutex::new(QuotaForecastHistory::default()),
update_commands: Mutex::new(Vec::new()),
lowered_limits: Mutex::new(Vec::new()),
automations: Mutex::default(),
})
}
@@ -693,6 +734,16 @@ impl FakeAccountStore {
#[async_trait]
impl AccountStore for FakeAccountStore {
async fn account_exists(&self, account_id: &str) -> AdminStoreResult<bool> {
self.record("store.account_exists");
Ok(self
.accounts
.lock()
.expect("accounts")
.iter()
.any(|account| account.id == account_id))
}
async fn list_plugin_accounts(
&self,
_: gateway_admin::model::provider_credentials::PluginAccountListQuery,
@@ -1086,7 +1137,10 @@ impl AccountStore for FakeAccountStore {
}
}
struct StaticSettingsStore;
#[derive(Default)]
struct StaticSettingsStore {
reset_credit_max_weekly_remaining_percent: Option<u32>,
}
#[async_trait]
impl SettingsStore for StaticSettingsStore {
@@ -1145,6 +1199,9 @@ impl SettingsStore for StaticSettingsStore {
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
reset_credit_max_weekly_remaining_percent: self
.reset_credit_max_weekly_remaining_percent,
},
})
}
@@ -1840,7 +1897,7 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
store.set_accounts(vec![openai_account, xai_account]);
let services = super::AdminHarness::new()
.accounts(store.clone())
.settings(Arc::new(StaticSettingsStore))
.settings(Arc::new(StaticSettingsStore::default()))
.provider(openai)
.provider(xai)
.probe(Arc::new(SuccessfulAccountProbe))
@@ -2048,7 +2105,7 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
store.set_accounts(vec![account_record("openai"), xai_account]);
let services = super::AdminHarness::new()
.accounts(store.clone())
.settings(Arc::new(StaticSettingsStore))
.settings(Arc::new(StaticSettingsStore::default()))
.provider(openai)
.provider(failing)
.probe(Arc::new(SuccessfulAccountProbe))
@@ -3405,7 +3462,7 @@ async fn accounts_service_with_probe(
) -> AdminServices {
super::AdminHarness::new()
.accounts(store)
.settings(Arc::new(StaticSettingsStore))
.settings(Arc::new(StaticSettingsStore::default()))
.provider(provider)
.probe(probe)
.build()
@@ -3574,6 +3631,19 @@ struct CapacityRuntime {
#[async_trait]
impl AccountRuntimeStore for CapacityRuntime {
async fn account_concurrency(
&self,
_: &str,
_: u64,
_: u32,
) -> AdminStoreResult<gateway_admin::model::accounts::AccountConcurrencyPage> {
Err(gateway_admin::ports::store::AdminStoreError::new(
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
"account concurrency",
"not configured in this fixture",
))
}
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot::default())
}
@@ -3636,7 +3706,7 @@ async fn account_capacity_should_batch_page_ids_and_distinguish_idle_from_unavai
let services = super::AdminHarness::new()
.accounts(store)
.account_runtime(runtime.clone())
.settings(Arc::new(StaticSettingsStore))
.settings(Arc::new(StaticSettingsStore::default()))
.provider(FakeProviderAdmin::new("openai", events()))
.build()
.await;
@@ -3667,3 +3737,56 @@ async fn account_capacity_should_batch_page_ids_and_distinguish_idle_from_unavai
}
}
}
#[async_trait]
impl gateway_admin::ports::reset_credit_automation::ResetCreditAutomationStore
for FakeAccountStore
{
async fn reset_credit_automation(
&self,
account_id: &str,
) -> AdminStoreResult<
Option<gateway_admin::model::reset_credit_automation::ResetCreditAutomation>,
> {
Ok(self.automations.lock().unwrap().get(account_id).cloned())
}
async fn due_reset_credit_automations(
&self,
now: chrono::DateTime<Utc>,
limit: u32,
) -> AdminStoreResult<Vec<String>> {
let records = self.automations.lock().unwrap();
let mut due = records
.values()
.filter(|r| r.enabled && r.next_run_at <= now)
.collect::<Vec<_>>();
due.sort_by_key(|r| r.next_run_at);
Ok(due
.into_iter()
.take(limit as usize)
.map(|r| r.account_id.clone())
.collect())
}
async fn save_reset_credit_automation(
&self,
record: &gateway_admin::model::reset_credit_automation::ResetCreditAutomation,
context: Option<&MutationContext>,
) -> AdminStoreResult<u64> {
let mut records = self.automations.lock().unwrap();
if records.get(&record.account_id).map_or(0, |r| r.revision) != record.revision {
return Err(AdminStoreError::new(
AdminStoreErrorKind::Conflict,
"automation",
"stale revision",
));
}
let mut next = record.clone();
next.revision += 1;
let revision = next.revision;
records.insert(next.account_id.clone(), next);
if let Some(context) = context {
self.record_context(context);
}
Ok(revision)
}
}
@@ -0,0 +1,969 @@
//! 自动重置的持久幂等恢复、低频发现与账号独立错峰
use super::*;
use gateway_admin::{
model::reset_credit_automation::*,
ports::reset_credit_automation::ResetCreditAutomationStore as _,
};
use gateway_core::{
engine::middleware::{self, FrozenMiddlewarePlan, MiddlewarePlan},
routing::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference},
};
#[derive(Debug)]
struct ServiceForwarder;
impl ExtensionSetLease for ServiceForwarder {
fn is_ready(&self) -> bool {
true
}
}
impl MiddlewarePlan for ServiceForwarder {
fn has_service(&self) -> bool {
true
}
fn handle_service(
&self,
_: middleware::service::Context,
input: middleware::service::Value,
next: middleware::service::Next,
) -> BoxFuture<'static, Result<middleware::service::Value, middleware::service::Error>> {
Box::pin(async move {
// 模拟 RPC 回调跨任务执行 next,且该任务带有另一插件的身份
tokio::spawn(gateway_admin::public_service::scope_actor(
gateway_admin::model::usage_data::UsageActor::Plugin {
publisher: "test".into(),
name: "forwarder".into(),
instance_id: "forwarding-plugin".into(),
},
next.run(input),
))
.await
.unwrap()
})
}
fn handle(
&self,
_: middleware::MiddlewareContext,
request: middleware::MiddlewareRequest,
next: middleware::MiddlewareNext,
) -> BoxFuture<'static, Result<middleware::MiddlewareResponse, middleware::MiddlewareError>>
{
next.run(request)
}
}
#[tokio::test]
async fn public_service_reads_and_updates_reset_credit_automation_with_host_identity() {
use gateway_admin::{
model::usage_data::UsageActor,
public_service::{Origin, Plan, scope, scope_actor},
};
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events.clone());
let services = accounts_service(provider, store.clone()).await;
let registry = services.public_services();
let read = "accounts.reset_credit_automation_status";
let write = "accounts.set_reset_credit_automation";
let initial = registry.call(read, json!("acct_test")).await.unwrap();
assert_eq!(initial["enabled"], false);
assert_eq!(initial["lead_minutes"], 10);
let actor = UsageActor::Plugin {
publisher: "test".into(),
name: "automation".into(),
instance_id: "installed-plugin".into(),
};
let middleware = Arc::new(ServiceForwarder);
let plan = FrozenMiddlewarePlan::new(
middleware.clone(),
ExtensionSetReference::new(
ExtensionSetId::new("service-forwarder".into()).unwrap(),
middleware,
),
);
let origin = Origin {
request_id: "plugin-request".into(),
call_id: "plugin-parent".into(),
cancellation: Default::default(),
extensions: Default::default(),
plan: Plan::Frozen(Some(plan)),
};
let error = scope(
origin.clone(),
registry.call(write, json!(["acct_test", true])),
)
.await
.unwrap_err();
assert_eq!(error.kind, "invalid");
let enabled = scope_actor(
actor.clone(),
scope(
origin.clone(),
registry.call(write, json!(["acct_test", true])),
),
)
.await
.unwrap();
let status: ResetCreditAutomationStatus = serde_json::from_value(enabled.clone()).unwrap();
assert!(status.enabled);
assert!(status.next_discovery_at.is_some());
assert_eq!(
registry.call(read, json!("acct_test")).await.unwrap(),
enabled
);
assert!(store.automations.lock().unwrap()["acct_test"].enabled);
let audit = store.audit_requests();
assert_eq!(audit.len(), 1);
assert!(audit[0].starts_with("plugin:installed-plugin:request:plugin-request:call:"));
let repeated = scope_actor(
actor.clone(),
scope(
origin.clone(),
registry.call(write, json!(["acct_test", true])),
),
)
.await
.unwrap();
assert_eq!(repeated, enabled);
assert_eq!(store.audit_requests(), audit);
let disabled = scope_actor(
actor,
scope(origin, registry.call(write, json!(["acct_test", false]))),
)
.await
.unwrap();
assert_eq!(disabled["enabled"], false);
assert!(disabled["next_discovery_at"].is_null());
assert!(disabled["next_consume_at"].is_null());
assert!(!store.automations.lock().unwrap()["acct_test"].enabled);
assert_eq!(store.audit_requests().len(), 2);
assert!(!recorded(&events).contains(&"provider.reset_credits"));
}
#[tokio::test]
async fn public_service_automation_rejects_invalid_and_cancelled_writes_without_side_effects() {
use gateway_admin::{
model::usage_data::UsageActor,
public_service::{Origin, Plan, scope, scope_actor},
};
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let store = FakeAccountStore::new("openai", events);
let services = accounts_service(provider.clone(), store.clone()).await;
let registry = services.public_services();
let write = "accounts.set_reset_credit_automation";
// 直接服务调用也必须有可信主体,输入不能自报管理员身份
let error = registry
.call(write, json!(["acct_test", true]))
.await
.unwrap_err();
assert_eq!(error.kind, "invalid");
let error = scope_actor(
UsageActor::System,
registry.call(
write,
json!([{"actor": "admin_api_key"}, "acct_test", true]),
),
)
.await
.unwrap_err();
assert_eq!(error.kind, "invalid");
let error = scope_actor(
UsageActor::System,
registry.call(write, json!(["acct_test", true])),
)
.await
.unwrap_err();
assert_eq!(error.kind, "invalid");
assert_eq!(error.message, "当前账号不支持自动重置卡");
enable_capability(&provider);
store.set_accounts(Vec::new());
let error = scope_actor(
UsageActor::System,
registry.call(write, json!(["acct_test", true])),
)
.await
.unwrap_err();
assert_eq!(error.kind, "not_found");
let cancellation = gateway_core::lifecycle::CancellationToken::new();
cancellation.cancel();
let error = scope_actor(
UsageActor::System,
scope(
Origin {
request_id: "cancelled".into(),
call_id: "parent".into(),
cancellation,
extensions: Default::default(),
plan: Plan::Frozen(None),
},
registry.call(write, json!(["acct_test", true])),
),
)
.await
.unwrap_err();
assert_eq!(error.kind, "unavailable");
assert!(store.automations.lock().unwrap().is_empty());
assert!(store.audit_requests().is_empty());
}
fn enable_capability(provider: &FakeProviderAdmin) {
let mut capabilities = provider.capabilities.lock().unwrap();
capabilities.reset_credits = true;
capabilities.consume_reset_credit = true;
}
fn plan(expires_at: chrono::DateTime<Utc>) -> ResetCreditAutomation {
ResetCreditAutomation {
account_id: "acct_test".into(),
enabled: true,
revision: 1,
next_run_at: Utc::now(),
state: ResetCreditAutomationState {
next_discovery_at: Utc::now() + TimeDelta::days(1),
credits: vec![PlannedResetCredit {
id: "card_1".into(),
expires_at,
}],
pending: None,
retry_at: None,
failures: 0,
last_result: None,
},
}
}
fn card(
expires_at: chrono::DateTime<Utc>,
) -> gateway_admin::model::provider_credentials::ProviderResetCredit {
gateway_admin::model::provider_credentials::ProviderResetCredit {
id: "card_1".into(),
status: Some("available".into()),
expires_at: Some(expires_at),
title: None,
reset_type: None,
}
}
#[tokio::test]
async fn automation_restores_pending_idempotency_after_service_restart() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events.clone());
let expiry = Utc::now() + TimeDelta::minutes(5);
provider.reset_credits.lock().unwrap().push(card(expiry));
*provider.reset_credit_failure.lock().unwrap() = Some(ProviderAdminErrorKind::Ambiguous);
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), plan(expiry));
let account_id = ProviderAccountId::new("acct_test").unwrap();
let services = accounts_service(provider.clone(), store.clone()).await;
services
.accounts()
.reconcile_reset_credit_automation(account_id.clone())
.await
.expect_err("unknown consume result");
let persisted = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
let pending = persisted.state.pending.as_ref().unwrap();
let command = provider.reset_credit_commands()[0].clone();
assert_eq!(
pending.redeem_request_id,
command.redeem_request_id.to_string()
);
assert_eq!(command.redeem_request_id.get_version_num(), 4);
assert_eq!(provider.quota_requests().len(), 0);
services
.accounts()
.reconcile_reset_credit_automation(account_id.clone())
.await
.unwrap();
assert_eq!(
provider.reset_credit_commands().len(),
1,
"wait until persisted retry time"
);
store
.automations
.lock()
.unwrap()
.get_mut("acct_test")
.unwrap()
.state
.retry_at = Some(Utc::now() - TimeDelta::seconds(1));
drop(services);
let restarted = threshold_services(provider.clone(), store.clone(), Some(0)).await;
restarted
.accounts()
.reconcile_reset_credit_automation(account_id)
.await
.unwrap();
assert_eq!(provider.reset_credit_commands(), [command.clone(), command]);
assert_eq!(
recorded(&events)
.iter()
.filter(|e| **e == "provider.reset_credits")
.count(),
1
);
let record = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert!(record.state.pending.is_none());
assert!(record.state.credits.is_empty());
assert_eq!(record.state.last_result.as_deref(), Some("reset"));
assert!(provider.quota_requests().iter().any(|q| q.refresh));
}
#[tokio::test]
async fn automation_only_queries_due_accounts_and_rechecks_credit_before_consume() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events.clone());
let mut record = plan(Utc::now() + TimeDelta::hours(6));
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), record.clone());
let services = accounts_service(provider.clone(), store.clone()).await;
let account_id = ProviderAccountId::new("acct_test").unwrap();
services
.accounts()
.reconcile_reset_credit_automation(account_id.clone())
.await
.unwrap();
assert!(!recorded(&events).contains(&"provider.reset_credits"));
record.state.credits[0].expires_at = Utc::now() + TimeDelta::minutes(5);
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), record);
services
.accounts()
.reconcile_reset_credit_automation(account_id)
.await
.unwrap();
assert!(recorded(&events).contains(&"provider.reset_credits"));
assert!(
provider.reset_credit_commands().is_empty(),
"disappeared credit must never be consumed"
);
}
#[tokio::test]
async fn automation_defaults_off_and_disabled_pending_survives_reenable() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events.clone());
let services = accounts_service(provider.clone(), store.clone()).await;
let account_id = ProviderAccountId::new("acct_test").unwrap();
assert!(
!services
.accounts()
.reset_credit_automation_status(account_id.clone())
.await
.unwrap()
.enabled
);
services
.accounts()
.set_reset_credit_automation(&context("enable"), account_id.clone(), true)
.await
.unwrap();
let first = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert!(first.state.next_discovery_at >= Utc::now() - TimeDelta::seconds(1));
let pending = PendingResetCredit {
credit: PlannedResetCredit {
id: "pending".into(),
expires_at: Utc::now() + TimeDelta::hours(1),
},
redeem_request_id: uuid::Uuid::new_v4().to_string(),
};
store
.automations
.lock()
.unwrap()
.get_mut("acct_test")
.unwrap()
.state
.pending = Some(pending.clone());
services
.accounts()
.set_reset_credit_automation(&context("disable"), account_id.clone(), false)
.await
.unwrap();
services
.accounts()
.reconcile_reset_credit_automation(account_id.clone())
.await
.unwrap();
assert!(provider.reset_credit_commands().is_empty());
services
.accounts()
.set_reset_credit_automation(&context("reenable"), account_id, true)
.await
.unwrap();
let next = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert_eq!(next.state.pending, Some(pending));
assert_eq!(next.state.next_discovery_at, first.state.next_discovery_at);
}
#[tokio::test]
async fn automation_schedules_each_account_inside_the_lead_window_and_next_local_day() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events);
let now = Utc::now();
let expiry = now + TimeDelta::days(2);
provider.reset_credits.lock().unwrap().push(card(expiry));
let accounts = (0..100)
.map(|n| {
let mut account = account_record("openai");
account.id = format!("acct_{n}");
let mut record = plan(expiry);
record.account_id = account.id.clone();
record.state.next_discovery_at = now - TimeDelta::seconds(1);
store
.automations
.lock()
.unwrap()
.insert(account.id.clone(), record);
account
})
.collect::<Vec<_>>();
store.set_accounts(accounts.clone());
let services = accounts_service(provider, store.clone()).await;
let timezone = gateway_core::time::DeploymentTimeZone::default();
let start = timezone.days_after(now, 1).unwrap();
let end = timezone.days_after(now, 2).unwrap();
let mut times = std::collections::BTreeSet::new();
for account in accounts {
let id = ProviderAccountId::new(account.id.clone()).unwrap();
services
.accounts()
.reconcile_reset_credit_automation(id.clone())
.await
.unwrap();
let status = services
.accounts()
.reset_credit_automation_status(id)
.await
.unwrap();
let discovery = status.next_discovery_at.unwrap();
assert!(discovery >= start && discovery < end);
times.insert(status.next_consume_at.unwrap());
}
assert!(
times.len() > 30,
"accounts must not all fire at the same instant"
);
assert!(times.iter().all(
|due| *due >= expiry - TimeDelta::minutes(10) && *due < expiry - TimeDelta::minutes(9)
));
}
#[tokio::test]
async fn automation_discards_externally_used_credit_and_stops_on_already_redeemed() {
for already_redeemed_after_query in [false, true] {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events.clone());
let expiry = Utc::now() + TimeDelta::minutes(5);
let mut remote_credit = card(expiry);
if !already_redeemed_after_query {
remote_credit.status = Some("redeemed".into());
}
provider.reset_credits.lock().unwrap().push(remote_credit);
*provider.reset_credit_result.lock().unwrap() = "already_redeemed".into();
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), plan(expiry));
let services = accounts_service(provider.clone(), store.clone()).await;
let account_id = ProviderAccountId::new("acct_test").unwrap();
services
.accounts()
.reconcile_reset_credit_automation(account_id.clone())
.await
.unwrap();
services
.accounts()
.reconcile_reset_credit_automation(account_id)
.await
.unwrap();
assert_eq!(
provider.reset_credit_commands().len(),
usize::from(already_redeemed_after_query)
);
let stored = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert!(stored.state.credits.is_empty());
assert!(stored.state.pending.is_none());
if already_redeemed_after_query {
assert_eq!(
stored.state.last_result.as_deref(),
Some("already_redeemed")
);
}
}
}
#[tokio::test]
async fn automation_daily_discovery_is_not_polled_again_after_success_or_failure() {
for failed in [false, true] {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
if failed {
provider.fail_next(ProviderAdminErrorKind::Unavailable);
}
let store = FakeAccountStore::new("openai", events.clone());
let mut record = plan(Utc::now() + TimeDelta::days(3));
record.state.credits.clear();
record.state.next_discovery_at = Utc::now() - TimeDelta::seconds(1);
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), record);
let services = accounts_service(provider.clone(), store.clone()).await;
let id = ProviderAccountId::new("acct_test").unwrap();
let first = services
.accounts()
.reconcile_reset_credit_automation(id.clone())
.await;
assert_eq!(first.is_err(), failed);
let scheduled = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap()
.state
.next_discovery_at;
services
.accounts()
.reconcile_reset_credit_automation(id)
.await
.unwrap();
assert_eq!(
recorded(&events)
.iter()
.filter(|e| **e == "provider.reset_credits")
.count(),
1
);
assert_eq!(
store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap()
.state
.next_discovery_at,
scheduled
);
assert!(scheduled > Utc::now());
}
}
#[tokio::test]
async fn automation_cancelled_discovery_preserves_next_random_time_across_restart() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events.clone());
let mut record = plan(Utc::now() + TimeDelta::days(3));
record.state.credits.clear();
record.state.next_discovery_at = Utc::now() - TimeDelta::seconds(1);
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), record);
let (release, gate) = tokio::sync::oneshot::channel();
*provider.reset_credit_query_gate.lock().unwrap() = Some(gate);
let services = accounts_service(provider.clone(), store.clone()).await;
let task = tokio::spawn(async move {
services
.accounts()
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
.await
});
tokio::time::timeout(
std::time::Duration::from_secs(2),
provider.reset_credit_query_started.notified(),
)
.await
.unwrap();
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
drop(release);
let before = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert!(before.state.next_discovery_at > Utc::now());
let restarted = accounts_service(provider.clone(), store.clone()).await;
restarted
.accounts()
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
.await
.unwrap();
let after = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert_eq!(
before.state.next_discovery_at,
after.state.next_discovery_at
);
assert_eq!(
recorded(&events)
.iter()
.filter(|e| **e == "provider.reset_credits")
.count(),
1
);
}
#[tokio::test]
async fn disabled_automation_only_blocks_manual_consume_until_pending_credit_expires() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events);
let mut record = plan(Utc::now() + TimeDelta::minutes(5));
record.enabled = false;
record.state.pending = Some(PendingResetCredit {
credit: record.state.credits[0].clone(),
redeem_request_id: uuid::Uuid::new_v4().to_string(),
});
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), record.clone());
let services = accounts_service(provider.clone(), store.clone()).await;
let command = ConsumeProviderResetCredit {
account_id: ProviderAccountId::new("acct_test").unwrap(),
credit_id: Some("another_card".into()),
redeem_request_id: uuid::Uuid::new_v4(),
};
services
.accounts()
.consume_reset_credit(&context("manual-with-pending"), command.clone())
.await
.expect_err("unresolved automation is still active");
assert!(provider.reset_credit_commands().is_empty());
record.state.pending.as_mut().unwrap().credit.expires_at = Utc::now() - TimeDelta::seconds(1);
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), record);
services
.accounts()
.consume_reset_credit(&context("manual-with-pending"), command)
.await
.unwrap();
assert_eq!(provider.reset_credit_commands().len(), 1);
}
async fn threshold_services(
provider: Arc<FakeProviderAdmin>,
store: Arc<FakeAccountStore>,
max_remaining: Option<u32>,
) -> AdminServices {
super::super::AdminHarness::new()
.accounts(store)
.settings(Arc::new(StaticSettingsStore {
reset_credit_max_weekly_remaining_percent: max_remaining,
}))
.provider(provider)
.probe(Arc::new(SuccessfulAccountProbe))
.build()
.await
}
fn weekly_quota(used_percent: f64) -> ProviderQuota {
ProviderQuota {
windows: vec![ProviderQuotaWindow {
key: "week".into(),
group: "shortTerm".into(),
label: "周额度".into(),
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(7 * 86_400),
used_percent: Some(used_percent),
reset_at: Some(Utc::now() + TimeDelta::days(1)),
limit_reached: false,
local_usage: None,
provider_data: None,
}],
..empty_quota()
}
}
fn schedule_due_credit(provider: &FakeProviderAdmin, store: &FakeAccountStore) {
enable_capability(provider);
let expiry = Utc::now() + TimeDelta::minutes(5);
provider.reset_credits.lock().unwrap().push(card(expiry));
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), plan(expiry));
}
fn advance_retry(store: &FakeAccountStore) {
store
.automations
.lock()
.unwrap()
.get_mut("acct_test")
.unwrap()
.state
.retry_at = Some(Utc::now() - TimeDelta::seconds(1));
}
#[tokio::test]
async fn automation_weekly_threshold_is_optional_inclusive_and_uses_remaining_quota() {
for (threshold, used, consume) in [
(None, 0.0, true),
(Some(70), 29.9, false),
(Some(70), 30.0, true),
(Some(0), 99.9, false),
(Some(0), 100.0, true),
(Some(100), 0.0, true),
] {
let provider = FakeProviderAdmin::new("openai", events());
let store = FakeAccountStore::new("openai", events());
schedule_due_credit(&provider, &store);
provider.set_quota(weekly_quota(used));
let services = threshold_services(provider.clone(), store.clone(), threshold).await;
services
.accounts()
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
.await
.unwrap();
assert_eq!(
!provider.reset_credit_commands().is_empty(),
consume,
"threshold={threshold:?}, used={used}"
);
let quota_requests = provider.quota_requests();
// 消费成功已有一次回读;未设置上限时不增加消费前查询
assert_eq!(
quota_requests.len(),
usize::from(threshold.is_some()) + usize::from(consume)
);
assert!(quota_requests.iter().all(|request| request.refresh));
let record = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert!(record.state.pending.is_none());
assert_eq!(record.state.credits.is_empty(), consume);
if !consume {
assert_eq!(
record.state.last_result.as_deref(),
Some("weekly_quota_above_threshold")
);
assert!(record.state.retry_at.unwrap() > Utc::now());
assert!(record.state.retry_at.unwrap() <= record.state.credits[0].expires_at);
}
}
}
#[tokio::test]
async fn automation_retains_skipped_credit_and_refreshes_quota_on_next_attempt() {
let provider = FakeProviderAdmin::new("openai", events());
let store = FakeAccountStore::new("openai", events());
schedule_due_credit(&provider, &store);
provider.set_quota(weekly_quota(10.0));
let services = threshold_services(provider.clone(), store.clone(), Some(70)).await;
let id = ProviderAccountId::new("acct_test").unwrap();
services
.accounts()
.reconcile_reset_credit_automation(id.clone())
.await
.unwrap();
services
.accounts()
.reconcile_reset_credit_automation(id.clone())
.await
.unwrap();
assert!(provider.reset_credit_commands().is_empty());
assert_eq!(provider.quota_requests().len(), 1);
provider.set_quota(weekly_quota(40.0));
advance_retry(&store);
services
.accounts()
.reconcile_reset_credit_automation(id)
.await
.unwrap();
assert_eq!(provider.reset_credit_commands().len(), 1);
let record = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert!(record.state.credits.is_empty());
assert!(record.state.retry_at.is_none());
}
#[tokio::test]
async fn automation_defers_missing_invalid_or_failed_weekly_quota() {
let weekly = weekly_quota(90.0).windows.remove(0);
let mut short = weekly.clone();
short.window_seconds = Some(18_000);
let mut model = weekly.clone();
model.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
let mut month = weekly.clone();
month.group = "monthly".into();
month.window_seconds = Some(30 * 86_400);
let mut missing_percent = weekly.clone();
missing_percent.used_percent = None;
for (quota, failure) in [
(empty_quota(), None),
(
ProviderQuota {
windows: vec![short, model, month],
..empty_quota()
},
None,
),
(
ProviderQuota {
windows: vec![missing_percent],
..empty_quota()
},
None,
),
(weekly_quota(f64::NAN), None),
(
weekly_quota(90.0),
Some(ProviderAdminErrorKind::Unavailable),
),
] {
let provider = FakeProviderAdmin::new("openai", events());
let store = FakeAccountStore::new("openai", events());
schedule_due_credit(&provider, &store);
provider.set_quota(quota);
*provider.quota_failure.lock().unwrap() = failure;
let services = threshold_services(provider.clone(), store.clone(), Some(70)).await;
let result = services
.accounts()
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
.await;
assert_eq!(result.is_err(), failure.is_some());
assert!(provider.reset_credit_commands().is_empty());
let record = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert_eq!(
record.state.last_result.as_deref(),
Some("weekly_quota_unavailable")
);
assert!(record.state.pending.is_none());
assert_eq!(record.state.credits.len(), 1);
assert!(record.state.retry_at.is_some());
}
}
#[tokio::test]
async fn automation_nothing_to_reset_retains_credit_and_retries_with_a_new_key() {
let provider = FakeProviderAdmin::new("openai", events());
let store = FakeAccountStore::new("openai", events());
schedule_due_credit(&provider, &store);
*provider.reset_credit_result.lock().unwrap() = "nothing_to_reset".into();
let services = accounts_service(provider.clone(), store.clone()).await;
let id = ProviderAccountId::new("acct_test").unwrap();
services
.accounts()
.reconcile_reset_credit_automation(id.clone())
.await
.unwrap();
let record = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert_eq!(
record.state.last_result.as_deref(),
Some("nothing_to_reset")
);
assert!(record.state.pending.is_none());
assert_eq!(record.state.credits.len(), 1);
services
.accounts()
.reconcile_reset_credit_automation(id.clone())
.await
.unwrap();
assert_eq!(provider.reset_credit_commands().len(), 1);
advance_retry(&store);
*provider.reset_credit_result.lock().unwrap() = "reset".into();
services
.accounts()
.reconcile_reset_credit_automation(id)
.await
.unwrap();
let commands = provider.reset_credit_commands();
assert_eq!(commands.len(), 2);
assert_eq!(commands[0].credit_id, commands[1].credit_id);
assert_ne!(commands[0].redeem_request_id, commands[1].redeem_request_id);
assert!(
store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap()
.state
.credits
.is_empty()
);
}
@@ -62,6 +62,8 @@ fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> Runti
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
reset_credit_max_weekly_remaining_percent: None,
},
}
}
@@ -158,6 +160,19 @@ impl FreezeRuntimeStore {
#[async_trait]
impl AccountRuntimeStore for FreezeRuntimeStore {
async fn account_concurrency(
&self,
_: &str,
_: u64,
_: u32,
) -> AdminStoreResult<gateway_admin::model::accounts::AccountConcurrencyPage> {
Err(gateway_admin::ports::store::AdminStoreError::new(
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
"account concurrency",
"not configured in this fixture",
))
}
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot::default())
}
@@ -96,6 +96,7 @@ pub(super) struct AdminHarness {
client_session_ttl_minutes: u64,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn gateway_admin::ports::proxy::ProxyStore>,
proxy_probe: Arc<dyn gateway_admin::ports::proxy::ProxyProbe>,
account_runtime: Arc<dyn AccountRuntimeStore>,
account_groups: Arc<dyn AccountGroupStore>,
auth: Arc<dyn AuthStore>,
@@ -121,6 +122,7 @@ impl AdminHarness {
client_session_ttl_minutes: 1_440,
accounts: unavailable.clone(),
proxies: Arc::new(proxies::TestProxies::default()),
proxy_probe: Arc::new(proxies::TestProxies::default()),
account_runtime: unavailable.clone(),
account_groups: Arc::new(UnavailableAccountGroupStore),
auth: Arc::new(BootstrapAuthStore::default()),
@@ -291,7 +293,7 @@ impl AdminHarness {
.unwrap(),
snapshot: Arc::new(NoopSnapshot),
account_probe: self.probe,
proxy_probe: Arc::new(proxies::TestProxies::default()),
proxy_probe: self.proxy_probe,
client_distribution: Arc::new(NoopClientDistribution),
system: self.system,
client_key_verifier: self.client_key_verifier,
@@ -476,6 +478,10 @@ impl AccountGroupStore for UnavailableAccountGroupStore {
#[async_trait]
impl AccountStore for UnavailableStore {
async fn account_exists(&self, _: &str) -> AdminStoreResult<bool> {
Err(unavailable("account"))
}
async fn list_plugin_accounts(
&self,
_: gateway_admin::model::provider_credentials::PluginAccountListQuery,
@@ -645,6 +651,19 @@ impl AccountStore for UnavailableStore {
#[async_trait]
impl AccountRuntimeStore for UnavailableStore {
async fn account_concurrency(
&self,
_: &str,
_: u64,
_: u32,
) -> AdminStoreResult<gateway_admin::model::accounts::AccountConcurrencyPage> {
Err(gateway_admin::ports::store::AdminStoreError::new(
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
"account concurrency",
"not configured in this fixture",
))
}
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot::default())
}
@@ -1160,3 +1179,31 @@ fn unavailable_system() -> SystemOperationError {
)
}
mod service_contract;
#[async_trait]
impl gateway_admin::ports::reset_credit_automation::ResetCreditAutomationStore
for UnavailableStore
{
async fn reset_credit_automation(
&self,
_: &str,
) -> AdminStoreResult<
Option<gateway_admin::model::reset_credit_automation::ResetCreditAutomation>,
> {
Ok(None)
}
async fn due_reset_credit_automations(
&self,
_: chrono::DateTime<chrono::Utc>,
_: u32,
) -> AdminStoreResult<Vec<String>> {
Ok(Vec::new())
}
async fn save_reset_credit_automation(
&self,
_: &gateway_admin::model::reset_credit_automation::ResetCreditAutomation,
_: Option<&gateway_admin::model::MutationContext>,
) -> AdminStoreResult<u64> {
Err(unavailable("reset-credit automation"))
}
}
@@ -147,6 +147,123 @@ async fn dashboard_summary_should_derive_first_token_average_from_trend() {
assert_eq!(summary.average_first_token_latency_ms, Some(100));
}
#[test]
fn dashboard_comparison_should_follow_deployment_calendar_across_dst() {
let timezone = "America/New_York".parse().unwrap();
for (end, previous_start, previous_end, hours) in [
(
"2026-03-09T16:00:00Z",
"2026-03-08T05:00:00Z",
"2026-03-09T04:00:00Z",
23,
),
(
"2026-11-02T17:00:00Z",
"2026-11-01T04:00:00Z",
"2026-11-02T05:00:00Z",
25,
),
(
"2026-11-02T05:00:00Z",
"2026-11-01T04:00:00Z",
"2026-11-02T05:00:00Z",
25,
),
] {
let end = end.parse().unwrap();
let range = TimeRange::calendar_at(
gateway_admin::model::observability::CalendarPeriod::Today,
end,
timezone,
)
.unwrap();
let query = DashboardQuery::new(range, timezone).unwrap();
assert_eq!(query.range, range);
assert_eq!(
query.yesterday_range.start,
previous_start.parse::<DateTime<Utc>>().unwrap()
);
assert_eq!(
query.yesterday_range.end,
previous_end.parse::<DateTime<Utc>>().unwrap()
);
assert_eq!(
(query.yesterday_range.end - query.yesterday_range.start).num_hours(),
hours
);
}
}
#[tokio::test]
async fn dashboard_summary_should_compare_separate_yesterday_metrics_with_today_only_trend() {
let end: DateTime<Utc> = "2026-10-18T04:00:00Z".parse().unwrap();
let range = TimeRange::calendar_at(
gateway_admin::model::observability::CalendarPeriod::Today,
end,
Default::default(),
)
.unwrap();
let store = Arc::new(FixtureObservabilityStore::new(range));
store.replace_trend(vec![health_metric_point(
range.start,
RequestMetrics {
request_count: 2,
input_tokens: 100,
cached_input_tokens: 20,
total_tokens: 120,
..Default::default()
},
)]);
*store.yesterday.lock().unwrap() = RequestMetrics {
request_count: 4,
input_tokens: 400,
cached_input_tokens: 200,
total_tokens: 500,
..Default::default()
};
let result = observability_services(store.clone())
.await
.observability()
.dashboard_summary(range, TrendKind::Usage)
.await
.unwrap();
assert_eq!(result.today.request_count, 2);
assert_eq!(result.today.total_tokens, 120);
assert_eq!(result.today.observed_cached_token_rate, Some(0.2));
assert_eq!(result.yesterday.request_count, 4);
assert_eq!(result.yesterday.total_tokens, 500);
assert_eq!(result.yesterday.observed_cached_token_rate, Some(0.5));
assert_eq!(result.observation.range, range);
assert_eq!(result.observation.trend.len(), 1);
assert_eq!(result.observation.trend[0].bucket_start, range.start);
assert_eq!(
store
.summary_query
.lock()
.unwrap()
.as_ref()
.unwrap()
.yesterday_range
.end,
range.start
);
}
#[tokio::test]
async fn dashboard_summary_should_keep_missing_yesterday_cache_rate_unknown() {
let range = observation_range(Utc::now());
let store = Arc::new(FixtureObservabilityStore::new(range));
let result = observability_services(store)
.await
.observability()
.dashboard_summary(range, TrendKind::Usage)
.await
.unwrap();
assert_eq!(result.yesterday.request_count, 0);
assert_eq!(result.yesterday.total_tokens, 0);
assert_eq!(result.yesterday.observed_cached_token_rate, None);
}
#[tokio::test]
async fn health_timeline_should_apply_status_precedence_and_thresholds() {
let now = Utc::now();
@@ -866,6 +983,7 @@ async fn dashboard_should_preserve_provider_plan_names() {
}
struct FixtureObservabilityStore {
yesterday: Mutex<RequestMetrics>,
trend: Mutex<Vec<RequestMetricPoint>>,
overview: Mutex<UsageOverview>,
projected_costs: Mutex<Vec<UsageProjectedCostPoint>>,
@@ -886,6 +1004,7 @@ struct FixtureObservabilityStore {
impl FixtureObservabilityStore {
fn new(range: TimeRange) -> Self {
Self {
yesterday: Mutex::new(RequestMetrics::default()),
trend: Mutex::new(Vec::new()),
overview: Mutex::new(UsageOverview {
analytics: Default::default(),
@@ -973,6 +1092,7 @@ impl ObservabilityStore for FixtureObservabilityStore {
tokio::time::sleep(dashboard_delay).await;
}
Ok(DashboardObservation {
yesterday: self.yesterday.lock().unwrap().clone(),
analytics: self.overview.lock().expect("overview").analytics.clone(),
range: query.range,
totals: Default::default(),
@@ -1140,6 +1260,8 @@ impl SettingsStore for FixtureSettingsStore {
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
reset_credit_max_weekly_remaining_percent: None,
},
})
}
@@ -1217,6 +1339,9 @@ fn total_record(
now: DateTime<Utc>,
) -> UsageListRecord {
UsageListRecord {
error_code: None,
error_message: None,
client_status_code: None,
outcome: gateway_admin::model::observability::RequestOutcome::Succeeded,
client_api_key_name: Some("Production".to_owned()),
id: id.to_owned(),
@@ -434,6 +434,7 @@ impl PluginStateStore for LifecycleFixture {
from_schema_version: 1,
to_schema_version: 2,
}],
guard: Arc::new(()),
})
}
@@ -15,6 +15,7 @@ pub(super) struct TestProxies {
pub events: Option<super::accounts::EventLog>,
pub accounts: Option<Vec<ProxyAccountRef>>,
pub record: Option<ProxyRecord>,
pub records: Vec<ProxyRecord>,
}
struct ImportGuard(super::accounts::EventLog);
@@ -77,9 +78,13 @@ impl ProxyStore for TestProxies {
self.record
.clone()
.filter(|record| record.id == id)
.or_else(|| self.records.iter().find(|record| record.id == id).cloned())
.ok_or_else(|| super::unavailable("proxy"))
}
async fn create(&self, _: NewProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
if let Some(events) = &self.events {
events.lock().unwrap().push("proxy.create");
}
Err(super::unavailable("proxy"))
}
async fn update(&self, _: UpdateProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
@@ -95,18 +100,23 @@ impl ProxyStore for TestProxies {
}
async fn record_test(
&self,
_: &str,
id: &str,
_: Revision,
_: ProxyTestResult,
result: ProxyTestResult,
_: &MutationContext,
) -> AdminStoreResult<ProxyMutation> {
Err(super::unavailable("proxy"))
let mut record = self.get(id).await?;
record.last_test = Some(result);
Ok(ProxyMutation {
config_revision: record.revision,
record,
})
}
}
#[async_trait]
impl ProxyProbe for TestProxies {
async fn test(&self, _: &OutboundProxy, _: bool) -> ProxyTestResult {
async fn test(&self, _: Option<&OutboundProxy>, _: bool) -> ProxyTestResult {
panic!("unexpected proxy probe")
}
}
@@ -310,3 +320,163 @@ async fn credential_import_keeps_proxy_reserved_until_commit_and_releases_on_err
}
}
}
#[tokio::test]
async fn batch_create_stops_after_uncertain_store_failure_without_replaying() {
use super::accounts::{context, events, recorded};
use gateway_admin::model::AdminErrorKind;
let events = events();
let services = super::AdminHarness::new()
.proxies(std::sync::Arc::new(TestProxies {
events: Some(events.clone()),
..Default::default()
}))
.build()
.await;
let commands = [" ", "First", "Skipped"]
.into_iter()
.map(|name| {
Ok(NewProxy {
name: name.to_owned(),
proxy: OutboundProxy::parse("http://proxy.example:8080").unwrap(),
auto_location: false,
location: None,
test: None,
})
})
.collect();
let results = services
.proxies()
.create_batch(commands, &context("batch-create"))
.await
.unwrap();
assert!(
matches!(&results[0], ProxyBatchResult::Completed(Err(error)) if error.kind() == AdminErrorKind::Invalid)
);
assert!(
matches!(&results[1], ProxyBatchResult::Completed(Err(error)) if error.kind() == AdminErrorKind::Unavailable)
);
assert!(matches!(&results[2], ProxyBatchResult::Skipped));
assert_eq!(recorded(&events), vec!["proxy.create"]);
}
struct GatedProxyProbe {
started: tokio::sync::mpsc::UnboundedSender<()>,
release: tokio::sync::Semaphore,
active: std::sync::atomic::AtomicUsize,
}
struct ActiveProbe<'a>(&'a std::sync::atomic::AtomicUsize);
impl Drop for ActiveProbe<'_> {
fn drop(&mut self) {
self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
}
}
#[async_trait]
impl ProxyProbe for GatedProxyProbe {
async fn test(&self, _: Option<&OutboundProxy>, _: bool) -> ProxyTestResult {
self.active
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let _active = ActiveProbe(&self.active);
self.started.send(()).unwrap();
self.release.acquire().await.unwrap().forget();
ProxyTestResult {
location: Default::default(),
success: true,
latency_ms: 1,
exit_ip: None,
exit_ipv4: None,
exit_ipv6: None,
message: "ok".to_owned(),
}
}
}
#[tokio::test]
async fn batch_tests_share_capacity_preserve_order_and_release_slots_on_cancellation() {
use super::accounts::context;
use std::sync::{Arc, atomic::Ordering};
let (started, mut receiver) = tokio::sync::mpsc::unbounded_channel();
let probe = Arc::new(GatedProxyProbe {
started,
release: tokio::sync::Semaphore::new(0),
active: Default::default(),
});
let now = chrono::Utc::now();
let records = (0..4)
.map(|i| ProxyRecord {
id: format!("proxy_{i}"),
name: format!("Proxy {i}"),
proxy: OutboundProxy::parse("http://proxy.example:8080").unwrap(),
revision: Revision::new(1).unwrap(),
enabled: true,
auto_location: false,
location: None,
detected_location: None,
account_count: 0,
last_test_at: None,
last_test: None,
created_at: now,
updated_at: now,
})
.collect();
let mut harness = super::AdminHarness::new().proxies(Arc::new(TestProxies {
records,
..Default::default()
}));
harness.proxy_probe = probe.clone();
let services = harness.build().await;
let commands = || {
(0..4)
.map(|i| {
Ok(TestProxy {
id: format!("proxy_{i}"),
revision: Revision::new(1).unwrap(),
detect_location: true,
})
})
.collect()
};
let context = context("batch-test");
let mut batch = Box::pin(services.proxies().test_batch(commands(), &context));
tokio::select! {
_ = &mut batch => panic!("batch finished before probes were released"),
_ = async { receiver.recv().await.unwrap(); receiver.recv().await.unwrap(); } => {}
}
assert_eq!(probe.active.load(Ordering::Relaxed), 2);
let mut single_a = Box::pin(services.proxies().probe(None, false));
let mut single_b = Box::pin(services.proxies().probe(None, false));
tokio::select! {
_ = &mut batch => panic!("batch unexpectedly finished"),
_ = &mut single_a => panic!("single probe unexpectedly finished"),
_ = &mut single_b => panic!("single probe unexpectedly finished"),
_ = async { receiver.recv().await.unwrap(); receiver.recv().await.unwrap(); } => {}
}
assert_eq!(probe.active.load(Ordering::Relaxed), 4);
assert_eq!(
services
.proxies()
.probe(None, false)
.await
.unwrap_err()
.kind(),
gateway_admin::model::AdminErrorKind::RateLimited
);
drop((batch, single_a, single_b));
assert_eq!(probe.active.load(Ordering::Relaxed), 0);
probe.release.add_permits(4);
let results = services
.proxies()
.test_batch(commands(), &context)
.await
.unwrap();
for (index, result) in results.into_iter().enumerate() {
let ProxyBatchResult::Completed(Ok(record)) = result else {
panic!("probe failed after cancellation")
};
assert_eq!(record.id, format!("proxy_{index}"));
assert!(record.last_test.unwrap().success);
}
assert_eq!(probe.active.load(Ordering::Relaxed), 0);
}
@@ -124,6 +124,89 @@ fn contract_type(mut item: Item, name: &str) -> Item {
item
}
fn service_operations(
root: &Path,
namespace: &str,
service_path: &str,
service_name: &str,
) -> (Vec<syn::File>, usize) {
let service = source(root, service_path)
.items
.into_iter()
.find_map(|item| match item {
Item::Trait(item) if item.ident == service_name => Some(item),
_ => None,
})
.unwrap();
let outputs: BTreeMap<_, _> = service
.items
.into_iter()
.filter_map(|item| match item {
syn::TraitItem::Fn(method) => {
let syn::ReturnType::Type(_, output) = method.sig.output else {
panic!("服务必须返回 Result")
};
let Type::Path(result) = *output else {
panic!("服务必须返回 Result")
};
let segment = result.path.segments.last().unwrap();
assert_eq!(segment.ident, "Result");
let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
panic!("Result 缺少类型参数")
};
let syn::GenericArgument::Type(mut output) = args.args.first().unwrap().clone()
else {
panic!("Result 缺少输出类型")
};
WireTypes.visit_type_mut(&mut output);
Some((method.sig.ident.to_string(), output))
}
_ => None,
})
.collect();
let register = source(root, &format!("src/public_service/{namespace}.rs"))
.items
.into_iter()
.find_map(|item| match item {
Item::Fn(item) if item.sig.ident == "register" => Some(item),
_ => None,
})
.unwrap();
let operations: Vec<syn::File> = register
.block
.stmts
.into_iter()
.filter_map(|stmt| {
let syn::Stmt::Macro(stmt) = stmt else {
return None;
};
if !stmt.mac.path.is_ident("register") {
return None;
}
let Operation {
name,
method,
mut input,
} = syn::parse2(stmt.mac.tokens).unwrap();
WireTypes.visit_type_mut(&mut input);
let output = outputs.get(&method.to_string()).unwrap();
let operation = format!("{namespace}.{method}");
Some(
syn::parse2(quote! {
pub struct #name;
impl Operation for #name {
const NAME: &'static str = #operation;
type Input = #input;
type Output = #output;
}
})
.unwrap(),
)
})
.collect();
(operations, outputs.len())
}
#[test]
fn sdk_settings_contract_matches_host_declarations() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
@@ -240,78 +323,13 @@ fn sdk_settings_contract_matches_host_declarations() {
}
}
let service = source(root, "src/use_case/settings.rs")
.items
.into_iter()
.find_map(|item| match item {
Item::Trait(item) if item.ident == "SettingsService" => Some(item),
_ => None,
})
.unwrap();
let outputs: BTreeMap<_, _> = service
.items
.into_iter()
.filter_map(|item| match item {
syn::TraitItem::Fn(method) => {
let syn::ReturnType::Type(_, output) = method.sig.output else {
panic!("服务必须返回 Result")
};
let Type::Path(result) = *output else {
panic!("服务必须返回 Result")
};
let segment = result.path.segments.last().unwrap();
assert_eq!(segment.ident, "Result");
let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
panic!("Result 缺少类型参数")
};
let syn::GenericArgument::Type(mut output) = args.args.first().unwrap().clone()
else {
panic!("Result 缺少输出类型")
};
WireTypes.visit_type_mut(&mut output);
Some((method.sig.ident.to_string(), output))
}
_ => None,
})
.collect();
let register = source(root, "src/public_service/settings.rs")
.items
.into_iter()
.find_map(|item| match item {
Item::Fn(item) if item.sig.ident == "register" => Some(item),
_ => None,
})
.unwrap();
let operations: Vec<_> = register
.block
.stmts
.into_iter()
.filter_map(|stmt| {
let syn::Stmt::Macro(stmt) = stmt else {
return None;
};
if !stmt.mac.path.is_ident("register") {
return None;
}
let Operation {
name,
method,
mut input,
} = syn::parse2(stmt.mac.tokens).unwrap();
WireTypes.visit_type_mut(&mut input);
let output = outputs.get(&method.to_string()).unwrap();
let operation = format!("settings.{method}");
Some(quote! {
pub struct #name;
impl Operation for #name {
const NAME: &'static str = #operation;
type Input = #input;
type Output = #output;
}
})
})
.collect();
assert_eq!(operations.len(), outputs.len(), "公开服务方法必须完整声明");
let (operations, method_count) = service_operations(
root,
"settings",
"src/use_case/settings.rs",
"SettingsService",
);
assert_eq!(operations.len(), method_count, "公开服务方法必须完整声明");
let assignments = replacement_fields.iter().map(|field| match field.to_string().as_str() {
"expected_revision" => quote!(expected_revision: settings.config_revision),
"request_profile_updates" => quote!(request_profile_updates: settings.request_profiles.into_iter().map(|(provider, value)| (provider, Some(value))).collect()),
@@ -340,14 +358,48 @@ fn sdk_settings_contract_matches_host_declarations() {
}
}
};
let output = root.join("../gateway-plugin/sdk/src/call/services/settings.rs");
let expected = prettyplease::unparse(&syn::parse2::<syn::File>(generated).unwrap());
check_contract(root, "settings", syn::parse2(generated).unwrap());
}
#[test]
fn sdk_accounts_contract_matches_host_declarations() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let status = source(root, "src/model/reset_credit_automation.rs")
.items
.into_iter()
.find(|item| matches!(item, Item::Struct(item) if item.ident == "ResetCreditAutomationStatus"))
.unwrap();
let status = contract_type(status, "ResetCreditAutomationStatus");
let (operations, _) = service_operations(
root,
"accounts",
"src/use_case/accounts/mod.rs",
"AccountsService",
);
let generated = quote! {
//! 账号自动重置的类型化操作与状态合同
//!
//! 从宿主状态类型与 public_service/accounts.rs 生成;更新命令见 SDK 维护说明
use super::Operation;
use serde::{Deserialize, Serialize};
#status
#(#operations)*
};
check_contract(root, "accounts", syn::parse2(generated).unwrap());
}
fn check_contract(root: &Path, namespace: &str, generated: syn::File) {
let output = root.join(format!(
"../gateway-plugin/sdk/src/call/services/{namespace}.rs"
));
let expected = prettyplease::unparse(&generated);
if std::env::var_os("CPR_UPDATE_SERVICE_CONTRACT").is_some() {
std::fs::write(&output, &expected).unwrap();
}
let actual = syn::parse_file(&std::fs::read_to_string(output).unwrap()).unwrap();
assert!(
prettyplease::unparse(&actual) == expected,
"SDK 服务合同已过期:CPR_UPDATE_SERVICE_CONTRACT=1 cargo test -p gateway-admin --test main sdk_settings_contract_matches_host_declarations"
"SDK 服务合同已过期:CPR_UPDATE_SERVICE_CONTRACT=1 cargo test -p gateway-admin --test main sdk_{namespace}_contract_matches_host_declarations"
);
}
@@ -355,6 +355,8 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
reset_credit_max_weekly_remaining_percent: None,
},
},
)
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
license = "Apache-2.0"
publish = false
rust-version = "1.97"
rust-version = "1.99.1"
autotests = false
[[test]]
@@ -0,0 +1,100 @@
//! 账号普通池并发明细的有界实时查询
use super::*;
use crate::auth::SessionState;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct Query {
account_id: String,
#[serde(default = "first_page")]
page: u32,
#[serde(default = "page_size")]
page_size: u32,
}
const fn first_page() -> u32 {
1
}
const fn page_size() -> u32 {
20
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Entry {
lease_id: String,
request_id: Option<String>,
client_key_id: Option<String>,
client_key_name: Option<String>,
model: Option<String>,
client_ip: Option<String>,
started_at: Option<DateTime<Utc>>,
started_at_display: Option<String>,
occupied_ms: Option<u64>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Data {
account_id: String,
entries: Vec<Entry>,
total: u64,
page: u32,
page_size: u32,
observed_at: DateTime<Utc>,
}
pub(super) async fn list<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<Query>,
) -> Result<Response, AdminError>
where
S: SessionState + Send + Sync,
{
require_account_id(&query.account_id, "accountId").map_err(map_wire_error)?;
let account_id = ProviderAccountId::new(query.account_id)
.map_err(|_| map_wire_error(WireValidationError::new("accountId")))?;
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let result = state
.admin_services()
.accounts()
.concurrency(account_id.clone(), query.page, query.page_size)
.await
.map_err(map_service_error)?;
let observed_at = result.observed_at;
let entries = result
.entries
.into_iter()
.map(|entry| Entry {
lease_id: entry.lease_id,
request_id: entry.request_id,
client_key_id: entry.client_key_id,
client_key_name: entry.client_key_name,
model: entry.model,
client_ip: entry.client_ip,
started_at: entry.started_at,
started_at_display: entry.started_at.map(|at| time.datetime(&at)),
occupied_ms: entry
.acquired_at
.map(|at| u64::try_from((observed_at - at).num_milliseconds()).unwrap_or(0)),
})
.collect();
let mut response = AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(Data {
account_id: account_id.as_str().to_owned(),
entries,
total: result.total,
page: query.page,
page_size: query.page_size,
observed_at,
}),
)
.into_response();
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
Ok(response)
}
@@ -12,6 +12,10 @@ where
Router::new()
.merge(super::import_tasks::router::<S>())
.route("/api/admin/accounts", get(list_accounts::<S>))
.route(
"/api/admin/accounts/concurrency",
get(super::concurrency::list::<S>),
)
.route("/api/admin/accounts/detail", get(account_detail::<S>))
.route("/api/admin/accounts/export", get(export_accounts::<S>))
.route("/api/admin/accounts/import", post(import_accounts::<S>))
@@ -36,6 +40,11 @@ where
"/api/admin/accounts/profile-avatar",
get(account_profile_avatar::<S>),
)
.route(
"/api/admin/accounts/reset-credits/automation",
get(account_reset_credit_automation::<S>)
.post(set_account_reset_credit_automation::<S>),
)
.route(
"/api/admin/accounts/reset-credits",
get(account_reset_credits::<S>).post(consume_account_reset_credit::<S>),
@@ -625,3 +634,54 @@ where
});
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
}
async fn account_reset_credit_automation<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let result = state
.admin_services()
.accounts()
.reset_credit_automation_status(query.into_id().map_err(map_wire_error)?)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountResetCreditAutomationData::from((result, time))),
))
}
async fn set_account_reset_credit_automation<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<AccountResetCreditAutomationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let account_id = AccountIdQuery {
account_id: request.account_id,
}
.into_id()
.map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.set_reset_credit_automation(
&auth.context().mutation_context(),
account_id,
request.enabled,
)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountResetCreditAutomationData::from((result, time))),
))
}
@@ -61,6 +61,7 @@ const MAX_ACCOUNT_DELETE_BATCH: usize = 200;
const MAX_ACCOUNT_GROUP_BATCH: usize = 1000;
const MAX_AVATAR_VERSION_BYTES: usize = 32;
mod concurrency;
mod credentials;
mod handlers;
mod import_tasks;
@@ -1192,3 +1192,49 @@ fn profile_activity_calendar(
weeks,
})
}
/// 账号自动使用临期重置卡开关
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountResetCreditAutomationRequest {
pub account_id: String,
pub enabled: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountResetCreditAutomationData {
pub enabled: bool,
pub lead_minutes: u32,
pub next_discovery_at_display: Option<String>,
pub next_consume_at_display: Option<String>,
pub last_result: Option<String>,
}
impl
From<(
gateway_admin::model::reset_credit_automation::ResetCreditAutomationStatus,
crate::time::TimePresenter,
)> for AccountResetCreditAutomationData
{
fn from(
(status, time): (
gateway_admin::model::reset_credit_automation::ResetCreditAutomationStatus,
crate::time::TimePresenter,
),
) -> Self {
Self {
enabled: status.enabled,
lead_minutes: status.lead_minutes,
next_discovery_at_display: status
.next_discovery_at
.as_ref()
.map(|value| time.datetime(value)),
next_consume_at_display: status
.next_consume_at
.as_ref()
.map(|value| time.datetime(value)),
last_result: status.last_result,
}
}
}
@@ -262,6 +262,8 @@ pub(crate) fn usage_list_record_view(
.clone()
.or_else(|| record.requested_model_id.clone());
UsageListRecordView {
error_code: record.error_code,
error_message: record.error_message,
logical_outcome: record.outcome.as_str().to_owned(),
client_api_key_name: record.client_api_key_name,
id: record.id,
@@ -86,6 +86,8 @@ pub struct ImageBillingView {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageListRecordView {
pub error_code: Option<String>,
pub error_message: Option<String>,
pub logical_outcome: String,
pub client_api_key_name: Option<String>,
pub id: String,
@@ -251,6 +251,8 @@ async fn callback<S: SessionState + Send + Sync>(
&path.target(),
&nonce,
PluginManagementRequest {
request_context: crate::middleware::current()
.and_then(|context| context.extensions.shared_request()),
method: "GET".into(),
path: path.path,
query,
@@ -323,6 +325,8 @@ async fn handle<S: SessionState + Send + Sync>(
.handle(
&target,
PluginManagementRequest {
request_context: crate::middleware::current()
.and_then(|context| context.extensions.shared_request()),
method,
path: path.path,
query,
+97 -34
View File
@@ -10,10 +10,10 @@ use axum::{
routing::{get, post},
};
use gateway_admin::model::{
PageSize, Revision,
AdminError as ServiceError, PageSize, Revision,
proxies::{
NewProxy, ProxyAccountListQuery, ProxyListQuery, ProxyMutation, ProxyRecord,
ProxyTestResult, UpdateProxy,
NewProxy, ProxyAccountListQuery, ProxyBatchResult, ProxyListQuery, ProxyMutation,
ProxyRecord, ProxyTestResult, TestProxy, UpdateProxy,
},
};
use serde::{Deserialize, Serialize};
@@ -50,6 +50,8 @@ struct RemoveAccountRequest {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CreateRequest {
#[serde(default)]
max_websocket_connections: u32,
#[serde(default)]
auto_location: bool,
location: Option<gateway_core::account::RequestLocation>,
@@ -60,6 +62,7 @@ struct CreateRequest {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct UpdateRequest {
max_websocket_connections: Option<u32>,
enabled: Option<bool>,
auto_location: Option<bool>,
#[serde(default, deserialize_with = "deserialize_location_update")]
@@ -146,6 +149,7 @@ struct ProxyAccountView {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyView {
max_websocket_connections: u32,
enabled: bool,
auto_location: bool,
detected_location: Option<gateway_admin::model::proxies::DetectedProxyLocation>,
@@ -170,6 +174,7 @@ impl From<(ProxyRecord, crate::time::TimePresenter)> for ProxyView {
let endpoint = record.proxy.endpoint();
Self {
auto_location: record.auto_location,
max_websocket_connections: record.proxy.max_websocket_connections(),
enabled: record.enabled,
detected_location: record.detected_location,
location: record.location,
@@ -342,38 +347,88 @@ where
))
}
#[derive(Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
enum BatchView<T> {
Succeeded {
data: T,
},
Failed {
#[serde(rename = "httpStatus")]
http_status: u16,
#[serde(flatten)]
error: super::wire::AdminErrorBody,
},
Skipped,
}
fn batch_response<T, V: Serialize>(
results: Vec<ProxyBatchResult<T>>,
project: impl Fn(T) -> V,
) -> axum::response::Response {
let diagnostic = results.iter().find_map(|result| match result {
ProxyBatchResult::Completed(Err(error)) if error.error_details().is_some() => {
Some(error.clone())
}
_ => None,
});
let items: Vec<BatchView<V>> = results
.into_iter()
.map(|result| match result {
ProxyBatchResult::Completed(Ok(value)) => BatchView::Succeeded {
data: project(value),
},
ProxyBatchResult::Completed(Err(error)) => {
let (http_status, error) = map_error(error).into_batch_error();
BatchView::Failed { http_status, error }
}
ProxyBatchResult::Skipped => BatchView::Skipped,
})
.collect();
let mut response = AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(items)).into_response();
if let Some(diagnostic) = diagnostic {
response.extensions_mut().insert(diagnostic);
}
response
}
async fn create<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<CreateRequest>,
AdminJson(requests): AdminJson<Vec<serde_json::Value>>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let proxy = request
.proxy_url
.0
.ok_or_else(|| AdminError::bad_request("代理 URL 不能为空"))?;
let result = state
.admin_services()
.proxies()
.create(
NewProxy {
let commands = requests
.into_iter()
.map(|value| {
// 每项单独解码,格式错误只拒绝当前项,错误文案不能回显代理认证
let request: CreateRequest = serde_json::from_value(value)
.map_err(|_| ServiceError::invalid("代理参数格式不正确"))?;
let proxy = request
.proxy_url
.0
.ok_or_else(|| ServiceError::invalid("代理 URL 不能为空"))?;
Ok(NewProxy {
auto_location: request.auto_location,
test: None,
location: request.location,
name: request.name,
proxy,
},
&auth.context().mutation_context(),
)
proxy: proxy.with_max_websocket_connections(request.max_websocket_connections),
})
})
.collect();
let results = state
.admin_services()
.proxies()
.create_batch(commands, &auth.context().mutation_context())
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::CREATED,
AdminEnvelope::ok(MutationView::from((result, time))),
))
Ok(batch_response(results, |value| {
MutationView::from((value, time))
}))
}
async fn remove_account<S>(
@@ -422,6 +477,7 @@ where
.proxies()
.update(
UpdateProxy {
max_websocket_connections: request.max_websocket_connections,
enabled: request.enabled,
auto_location: request.auto_location,
test: None,
@@ -480,7 +536,7 @@ where
let result = state
.admin_services()
.proxies()
.probe(&proxy, request.detect_location)
.probe(Some(&proxy), request.detect_location)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
@@ -492,25 +548,32 @@ where
async fn test<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<TestRequest>,
AdminJson(requests): AdminJson<Vec<serde_json::Value>>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let result = state
let commands = requests
.into_iter()
.map(|value| {
let request: TestRequest = serde_json::from_value(value)
.map_err(|_| ServiceError::invalid("代理参数格式不正确"))?;
Ok(TestProxy {
id: request.id,
revision: revision(request.revision)
.map_err(|error| ServiceError::invalid(error.to_string()))?,
detect_location: request.detect_location,
})
})
.collect();
let results = state
.admin_services()
.proxies()
.test(
&request.id,
revision(request.revision)?,
request.detect_location,
&auth.context().mutation_context(),
)
.test_batch(commands, &auth.context().mutation_context())
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ProxyView::from((result, time))),
))
Ok(batch_response(results, |value| {
ProxyView::from((value, time))
}))
}
@@ -66,6 +66,8 @@ pub struct RuntimeSettingsFields {
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
pub reset_credit_lead_minutes: u32,
pub reset_credit_max_weekly_remaining_percent: Option<u32>,
}
/// 运行配置投影与设置页字段的聚合响应
@@ -275,6 +277,10 @@ where
)
.route("/api/admin/settings/pricing/sync", post(sync_pricing::<S>))
.route("/api/admin/settings", get(settings::<S>))
.route(
"/api/admin/settings/location/detect",
post(detect_location::<S>),
)
.route(
"/api/admin/settings/client-profiles/{provider}",
get(client_profile_options::<S>),
@@ -648,6 +654,25 @@ fn deserialize_profile_update<'de, D: serde::Deserializer<'de>>(
serde_json::Map::<String, serde_json::Value>::deserialize(deserializer).map(Some)
}
async fn detect_location<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
.proxies()
.probe(None, true)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(result.location),
))
}
async fn client_profile_options<S>(
_auth: AdminAuth,
Path(provider): Path<String>,
@@ -762,6 +787,9 @@ impl RuntimeSettingsFields {
account_warmup_enabled: self.account_warmup_enabled,
account_warmup_schedule_time: self.account_warmup_schedule_time,
account_warmup_model: self.account_warmup_model,
reset_credit_lead_minutes: self.reset_credit_lead_minutes,
reset_credit_max_weekly_remaining_percent: self
.reset_credit_max_weekly_remaining_percent,
};
values.validate().map_err(settings_validation_error)?;
Ok(values)
@@ -802,6 +830,9 @@ impl From<gateway_admin::model::settings::RuntimeSettingsValues> for RuntimeSett
account_warmup_enabled: settings.account_warmup_enabled,
account_warmup_schedule_time: settings.account_warmup_schedule_time,
account_warmup_model: settings.account_warmup_model,
reset_credit_lead_minutes: settings.reset_credit_lead_minutes,
reset_credit_max_weekly_remaining_percent: settings
.reset_credit_max_weekly_remaining_percent,
}
}
}
@@ -817,6 +848,8 @@ fn settings_validation_error(field: &'static str) -> WireValidationError {
"account_auto_freeze_window_seconds" => "accountAutoFreezeWindowSeconds",
"account_warmup_enabled" => "accountWarmupEnabled",
"account_warmup_model" => "accountWarmupModel",
"reset_credit_lead_minutes" => "resetCreditLeadMinutes",
"reset_credit_max_weekly_remaining_percent" => "resetCreditMaxWeeklyRemainingPercent",
"account_warmup_schedule_time" => "accountWarmupScheduleTime",
"audit_retention_days" => "auditRetentionDays",
"concurrency_wait_timeout_seconds" => "concurrencyWaitTimeoutSeconds",
@@ -262,6 +262,10 @@ const SERVICE_UNAVAILABLE: AdminErrorSpec = AdminErrorSpec::new(
);
impl AdminError {
pub(super) fn into_batch_error(self) -> (u16, AdminErrorBody) {
(self.status.as_u16(), self.body)
}
fn new(status: StatusCode, code: AdminErrorCode, message: impl Into<String>) -> Self {
Self {
status,
@@ -5,7 +5,7 @@ use gateway_admin::model::{
client_keys::ClientKeySecret,
key_usage::{KeyUsageOverview, KeyUsageRecords},
observability::{
CostCoverage, CurrencyCost, Granularity, OpsError, RequestMetrics, UsageListRecord,
CostCoverage, CurrencyCost, Granularity, RequestMetrics, RequestOutcome, UsageListRecord,
},
system::SystemVersion,
};
@@ -228,8 +228,20 @@ struct OutputTimingView {
first_text_ms: Option<u64>,
}
fn success_record(value: UsageListRecord, time: crate::time::TimePresenter) -> RecordView {
let token_details = Some(usage_list_token_details(&value));
fn usage_record(value: UsageListRecord, time: crate::time::TimePresenter) -> RecordView {
let token_details = [
value.input_tokens,
value.output_tokens,
value.cached_input_tokens,
value.cache_write_input_tokens,
value.reasoning_output_tokens,
value.image_input_tokens,
value.image_output_tokens,
value.total_tokens,
]
.iter()
.any(Option::is_some)
.then(|| usage_list_token_details(&value));
let billing = billing_view(value.billing.as_ref());
RecordView {
id: value.id,
@@ -258,58 +270,30 @@ fn success_record(value: UsageListRecord, time: crate::time::TimePresenter) -> R
},
client_ip: value.client_ip,
user_agent: value.user_agent,
status: "success",
// 成功记录不保存 HTTP 状态,不能用 200 伪造缺失的原始事实
status_code: None,
}
}
fn error_record(value: OpsError, time: crate::time::TimePresenter) -> RecordView {
RecordView {
id: value.event_id,
created_at: value.occurred_at,
created_at_display: time.datetime(&value.occurred_at),
model: value.requested_model_id,
route: value.endpoint,
reasoning_effort: value.reasoning_effort,
client_transport: value.client_transport,
upstream_transport: value.upstream_transport,
token_details: None,
billing: None,
latency_ms: value.latency_ms,
first_token_latency_ms: None,
latency_details: OutputTimingView::default(),
client_ip: value.client_ip,
user_agent: value.user_agent,
status: "error",
status: match value.outcome {
RequestOutcome::Succeeded => "success",
RequestOutcome::Failed => "error",
RequestOutcome::Incomplete => "incomplete",
RequestOutcome::Cancelled => "cancelled",
RequestOutcome::Running => "running",
RequestOutcome::Other(_) => "unknown",
},
status_code: value.client_status_code,
}
}
pub(super) fn records(
value: KeyUsageRecords,
page: KeyUsageRecords,
time: crate::time::TimePresenter,
) -> PageData<RecordView> {
match value {
KeyUsageRecords::Success(page) => PageData {
items: page
.items
.into_iter()
.map(|value| success_record(value, time))
.collect(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
},
KeyUsageRecords::Error(page) => PageData {
items: page
.items
.into_iter()
.map(|value| error_record(value, time))
.collect(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
},
PageData {
items: page
.items
.into_iter()
.map(|value| usage_record(value, time))
.collect(),
current_page: page.current_page,
page_size: page.page_size,
total: page.total,
}
}
@@ -67,6 +67,7 @@ enum RecordKind {
#[default]
Success,
Error,
All,
}
#[derive(Deserialize)]
@@ -109,6 +110,7 @@ impl RecordsQuery {
kind: match self.kind {
RecordKind::Success => KeyUsageRecordKind::Success,
RecordKind::Error => KeyUsageRecordKind::Error,
RecordKind::All => KeyUsageRecordKind::All,
},
current_page,
page_size,
+1
View File
@@ -34,6 +34,7 @@ mod key_usage;
mod middleware;
pub mod openai;
mod provider;
mod request_context;
mod session_cookie;
mod time;
@@ -151,19 +151,6 @@ async fn handle(
if let Some(plan) = plan.as_ref().filter(|plan| plan.has_websocket()) {
request.extensions_mut().insert(plan.clone());
}
if inherited.is_none()
&& !plan
.as_ref()
.is_some_and(|plan| plan.has_http() || plan.has_service())
{
let settings = request
.extensions()
.get::<Settings>()
.and_then(|settings| settings.runtime.clone());
return SETTINGS
.scope(settings, dispatch(router, request, started))
.await;
}
let context = inherited.unwrap_or_else(|| {
let request_id = request
.headers()
@@ -176,7 +163,8 @@ async fn handle(
call_id: request_id.clone(),
request_id,
parent_call_id: None,
extensions: Default::default(),
extensions: gateway_core::engine::extensions::ExtensionCallScope::default()
.with_request(crate::request_context::capture(&request)),
cancellation: CancellationToken::new(),
plan: plan.clone(),
}
@@ -72,6 +72,7 @@ impl upgrade::WebSocketUpgrade for Upgrade {
let reader_task = tokio::spawn(read_messages(reader, incoming, cancellation));
Ok(Arc::new(Session {
reader: tokio::sync::Mutex::new(received), reader_task: reader_task.abort_handle(), writer,
extensions: context.extensions,
plan: context.plan, connection_id: context.request_id, headers,
cancellation: context.cancellation,
}) as Arc<dyn core::Session>)
@@ -108,6 +109,7 @@ pub(crate) async fn transform(
}
struct Session {
extensions: gateway_core::engine::extensions::ExtensionCallScope,
reader: tokio::sync::Mutex<tokio::sync::mpsc::Receiver<Message>>,
reader_task: tokio::task::AbortHandle,
writer: Arc<Writer>,
@@ -125,6 +127,7 @@ impl Session {
) -> Result<Option<core::Message>, MiddlewareError> {
transform(
core::Context {
extensions: self.extensions.clone(),
plan: self.plan.clone(),
connection_id: self.connection_id.clone(),
direction,
@@ -118,6 +118,9 @@ fn client_authentication_request(
.map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?;
let request = ClientAuthenticationRequest::new(raw.to_owned())
.map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?;
let request = request.with_request_context(
crate::middleware::current().and_then(|context| context.extensions.shared_request()),
);
Ok(match crate::middleware::current_settings() {
Some(settings) => request.with_settings(settings),
None => request,
@@ -1,6 +1,6 @@
//! OpenAI Provider 原生非流式 JSON 端点的公共交付边界
use std::net::IpAddr;
use std::{future::Future, net::IpAddr};
use axum::{
body::{Body, Bytes},
@@ -30,7 +30,7 @@ use super::{
};
/// Provider 自有端点也先冻结身份,再通过统一请求链进入原有执行与结算路径
pub(super) async fn provider_endpoint_response<F>(
pub(super) async fn provider_endpoint_response<F, Fut>(
service: OpenAiService,
client: AuthenticatedClient,
input: RequestInput,
@@ -39,7 +39,8 @@ pub(super) async fn provider_endpoint_response<F>(
decode: F,
) -> Response
where
F: FnOnce(Bytes, &HeaderMap) -> Result<Operation, GatewayError> + Send + 'static,
F: FnOnce(Bytes, HeaderMap) -> Fut + Send + 'static,
Fut: Future<Output = Result<Operation, GatewayError>> + Send,
{
let execution = service.execution();
let prepared = match execution.prepare_execution(client).await {
@@ -58,7 +59,7 @@ where
if protocol != "openai" {
return Err(MiddlewareError::Rejected);
}
let operation = request.apply_capabilities(decode(body, &headers)?)?;
let operation = request.apply_capabilities(decode(body, headers).await?)?;
let started = match service
.start_prepared_provider_endpoint(
prepared, operation,
@@ -100,12 +100,17 @@ async fn handle_image_request(
.await
}
fn image_operation(
async fn image_operation(
body: Bytes,
headers: &HeaderMap,
mut headers: HeaderMap,
kind: ImageRequestKind,
) -> Result<Operation, GatewayError> {
let mut context = OpenAiRequestHeaders::from_headers(headers).session_context();
let body = if kind == ImageRequestKind::Edit {
super::request::normalize_edit_body(body, &mut headers).await?
} else {
body
};
let mut context = OpenAiRequestHeaders::from_headers(&headers).session_context();
if let Some(turn_id) = headers
.get("x-codex-image-turn-id")
.and_then(|value| value.to_str().ok())
@@ -1,5 +1,6 @@
//! OpenAI/Codex Images JSON 透明 adapter
//! OpenAI/Codex Images JSON 透传与图片上传适配
mod http;
mod request;
pub(crate) use http::{image_edits, image_generations};
@@ -0,0 +1,131 @@
//! 将图片编辑 multipart 上传映射到原生 Images JSON,保留图片字节与字段语义
use axum::{
body::{Body, Bytes},
extract::{DefaultBodyLimit, FromRequest, Multipart, Request},
http::{
HeaderMap, HeaderValue,
header::{CONTENT_LENGTH, CONTENT_TYPE},
},
};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use serde::ser::{SerializeMap, Serializer as _};
use serde_json::{Number, Value, json};
pub(super) async fn normalize_edit_body(
body: Bytes,
headers: &mut HeaderMap,
) -> Result<Bytes, GatewayError> {
let is_multipart = headers
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(';').next())
.is_some_and(|media| media.trim().eq_ignore_ascii_case("multipart/form-data"));
if !is_multipart {
return Ok(body);
}
if headers.get_all(CONTENT_TYPE).iter().count() != 1 {
return Err(invalid("Image edit requires a single Content-Type header"));
}
let mut request = Request::new(Body::from(body));
*request.headers_mut() = headers.clone();
// 原路由不设正文私有上限,重新提取时不能引入 Axum 默认的 2 MiB 限制
DefaultBodyLimit::disable().apply(&mut request);
let mut multipart = Multipart::from_request(request, &())
.await
.map_err(|_| invalid("Image edit multipart boundary is invalid"))?;
let mut body = Vec::new();
let mut serializer = serde_json::Serializer::new(&mut body);
// 直接序列化字段,避免经过 Map 后覆盖同名参数
let mut fields = serializer.serialize_map(None).map_err(encode_error)?;
let mut images = Vec::new();
while let Some(field) = multipart
.next_field()
.await
.map_err(|_| invalid("Image edit multipart body is invalid"))?
{
let name = field
.name()
.ok_or_else(|| invalid("Image edit multipart fields must have names"))?
.to_owned();
let is_file = field.file_name().is_some();
let bytes = field
.bytes()
.await
.map_err(|_| invalid("Image edit multipart field is invalid"))?;
if matches!(name.as_str(), "image" | "image[]") {
// 按表单中的出现顺序追加,多图顺序参与编辑语义
images.push(image_reference(&bytes)?);
continue;
}
let value = if name == "mask" {
image_reference(&bytes)?
} else {
if is_file {
return Err(invalid(
"Only image, image[] and mask may contain image files",
));
}
scalar_field(&name, &bytes)?
};
fields
.serialize_entry(&name, &value)
.map_err(encode_error)?;
}
if images.is_empty() {
return Err(invalid(
"Image edit multipart requires an image or image[] upload",
));
}
fields
.serialize_entry("images", &images)
.map_err(encode_error)?;
fields.end().map_err(encode_error)?;
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
headers.remove(CONTENT_LENGTH);
Ok(Bytes::from(body))
}
fn scalar_field(name: &str, bytes: &[u8]) -> Result<Value, GatewayError> {
match name {
"n" | "output_compression" | "partial_images" => serde_json::from_slice::<Number>(bytes)
.map(Value::Number)
.map_err(|_| invalid("Image edit numeric fields must contain JSON numbers")),
"stream" => serde_json::from_slice::<bool>(bytes)
.map(Value::Bool)
.map_err(|_| invalid("Image edit stream field must contain a JSON boolean")),
// 其余表单字段保留名称与文本值,不因字段尚未识别而丢弃或拒绝
_ => {
let text = std::str::from_utf8(bytes)
.map_err(|_| invalid("Image edit text fields must be valid UTF-8"))?;
Ok(Value::String(text.to_owned()))
}
}
}
fn image_reference(bytes: &[u8]) -> Result<Value, GatewayError> {
// 与官方 Original 路径一样保留 PNG/JPEG/WebP 原始字节;不依赖文件名或上传 MIME
let mime = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
"image/png"
} else if bytes.starts_with(b"\xff\xd8\xff") {
"image/jpeg"
} else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
"image/webp"
} else {
return Err(invalid("Image edit uploads must be PNG, JPEG or WebP"));
};
Ok(json!({ "image_url": format!("data:{mime};base64,{}", STANDARD.encode(bytes)) }))
}
fn invalid(message: &'static str) -> GatewayError {
GatewayError::new(GatewayErrorKind::InvalidRequest, message)
}
fn encode_error(_: serde_json::Error) -> GatewayError {
GatewayError::new(
GatewayErrorKind::Internal,
"Could not encode image edit JSON",
)
}
@@ -96,7 +96,7 @@ fn percent_decode(value: &str) -> String {
index += 1;
}
}
String::from_utf8_lossy(&output).into_owned()
String::from_utf8_lossy_owned(output)
}
async fn open_sideband(
@@ -1,8 +1,10 @@
//! OpenAI Responses HTTP 与 SSE adapter
use crate::request_context::request_client_context;
use std::collections::VecDeque;
use std::future::Future;
use std::net::{IpAddr, SocketAddr};
use std::net::SocketAddr;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
@@ -11,7 +13,7 @@ use axum::{
extract::{Extension, State, connect_info::ConnectInfo},
http::{
HeaderMap, HeaderName, HeaderValue, StatusCode,
header::{CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT},
header::{CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE},
},
response::Response,
};
@@ -374,55 +376,6 @@ impl MiddlewareBody for AuthorizedResponseBody {
}
}
/// 从 socket 与标准转发头提取旧 Usage 页面使用的诊断事实
pub(crate) fn request_client_context(
headers: &HeaderMap,
peer_address: Option<SocketAddr>,
) -> (Option<IpAddr>, Option<String>) {
let client_ip = ["cf-connecting-ip", "x-real-ip"]
.into_iter()
.find_map(|name| header_ip(headers, name))
.or_else(|| forwarded_client_ip(headers))
.or_else(|| peer_address.map(|address| address.ip()));
let user_agent = headers
.get(USER_AGENT)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
(client_ip, user_agent)
}
fn header_ip(headers: &HeaderMap, name: &str) -> Option<IpAddr> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.and_then(|value| value.parse().ok())
}
fn forwarded_client_ip(headers: &HeaderMap) -> Option<IpAddr> {
let addresses = headers
.get("x-forwarded-for")?
.to_str()
.ok()?
.split(',')
.filter_map(|value| value.trim().parse::<IpAddr>().ok())
.collect::<Vec<_>>();
addresses
.iter()
.copied()
.find(|address| !is_private_or_loopback(*address))
.or_else(|| addresses.first().copied())
}
const fn is_private_or_loopback(address: IpAddr) -> bool {
match address {
IpAddr::V4(address) => address.is_private() || address.is_loopback(),
IpAddr::V6(address) => address.is_unique_local() || address.is_loopback(),
}
}
async fn execution_middleware_response(
started: StartedExecution,
connection_guard: Option<Box<dyn ConnectionGuard>>,
@@ -8,10 +8,10 @@ mod response;
mod validation;
pub mod websocket;
pub(crate) use crate::request_context::request_client_context;
pub use error::{ProtocolError, ProtocolErrorBody, RequestDecodeError, ResponseEncodeError};
pub(crate) use http::{
ResponseAuthorization, ResponsesHttpRequest, execute_prepared_responses,
request_client_context, responses,
ResponseAuthorization, ResponsesHttpRequest, execute_prepared_responses, responses,
};
pub use http::{collect_execution_response, stream_execution_response};
pub(crate) use request::decode_request_with_body;
@@ -332,6 +332,9 @@ where
let (command_tx, command_rx) = mpsc::channel(OUTBOUND_COMMAND_BUFFER);
let (incoming_tx, incoming_rx) = mpsc::channel(INBOUND_EVENT_BUFFER);
let (exit_tx, exit_rx) = oneshot::channel();
let extensions = crate::middleware::current()
.map(|context| context.extensions)
.unwrap_or_default();
let pump_task = tokio::spawn(run_pump(
socket,
command_rx,
@@ -345,6 +348,7 @@ where
config,
middleware,
headers,
extensions,
));
ResponsesWebSocketConnection {
connection_id,
@@ -100,6 +100,7 @@ pub(super) async fn transform(
};
crate::middleware::websocket::transform(
core::Context {
extensions: context.extensions.clone(),
plan: Some(plan.clone()),
connection_id: context.connection_id.to_string(),
direction,
@@ -37,11 +37,13 @@ pub(super) async fn run_pump<S, E>(
config: ConnectionConfig,
middleware: Option<FrozenMiddlewarePlan>,
headers: Arc<[MiddlewareHeader]>,
extensions: gateway_core::engine::extensions::ExtensionCallScope,
) where
S: Stream<Item = Result<Message, E>> + Sink<Message, Error = E> + Unpin + Send + 'static,
E: fmt::Display + Send + 'static,
{
let context = Arc::new(PumpContext {
extensions,
connection_id,
cancellation: cancellation.child_token(),
opened_at,
@@ -64,6 +64,7 @@ impl ConnectionStats {
}
pub(super) struct PumpContext {
pub(super) extensions: gateway_core::engine::extensions::ExtensionCallScope,
pub(super) middleware: Option<FrozenMiddlewarePlan>,
pub(super) headers: Arc<[MiddlewareHeader]>,
pub(super) connection_id: Arc<str>,
@@ -43,8 +43,7 @@ use crate::{
};
use super::{
http::request_client_context, request::OpenAiRequestHeaders,
validation::ResponseValidationFacts,
request::OpenAiRequestHeaders, request_client_context, validation::ResponseValidationFacts,
};
use connection::{ConnectionEvent, FramePhase, ResponsesWebSocketConnection, WriteContext};
use forward::{
@@ -54,7 +54,7 @@ pub(crate) async fn standalone_search(
},
client_ip,
user_agent,
search_operation,
async move |body, headers| search_operation(body, &headers),
)
.await
}
@@ -0,0 +1,85 @@
//! HTTP 入口快照与统一来源地址解析
use axum::{
extract::ConnectInfo,
http::{HeaderMap, Request, header::USER_AGENT},
};
use gateway_core::engine::{extensions::RequestContext, middleware::MiddlewareHeader};
use std::{
net::{IpAddr, SocketAddr},
sync::Arc,
};
pub(crate) fn capture<B>(request: &Request<B>) -> Arc<RequestContext> {
let peer_addr = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|info| info.0);
let (client_ip, _) = request_client_context(request.headers(), peer_addr);
Arc::new(RequestContext {
method: request.method().to_string(),
uri: request.uri().to_string(),
http_version: format!("{:?}", request.version()),
headers: request
.headers()
.iter()
.map(|(name, value)| {
MiddlewareHeader::new(
name.as_str(),
bytes::Bytes::copy_from_slice(value.as_bytes()),
)
})
.collect(),
peer_addr,
client_ip,
})
}
/// 保留宿主的来源地址解析顺序,插件仍可从入口快照自行解释转发头
pub(crate) fn request_client_context(
headers: &HeaderMap,
peer_address: Option<SocketAddr>,
) -> (Option<IpAddr>, Option<String>) {
let client_ip = ["cf-connecting-ip", "x-real-ip"]
.into_iter()
.find_map(|name| header_ip(headers, name))
.or_else(|| forwarded_client_ip(headers))
.or_else(|| peer_address.map(|address| address.ip()));
let user_agent = headers
.get(USER_AGENT)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
(client_ip, user_agent)
}
fn header_ip(headers: &HeaderMap, name: &str) -> Option<IpAddr> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.and_then(|value| value.parse().ok())
}
fn forwarded_client_ip(headers: &HeaderMap) -> Option<IpAddr> {
let addresses = headers
.get("x-forwarded-for")?
.to_str()
.ok()?
.split(',')
.filter_map(|value| value.trim().parse::<IpAddr>().ok())
.collect::<Vec<_>>();
addresses
.iter()
.copied()
.find(|address| !is_private_or_loopback(*address))
.or_else(|| addresses.first().copied())
}
const fn is_private_or_loopback(address: IpAddr) -> bool {
match address {
IpAddr::V4(address) => address.is_private() || address.is_loopback(),
IpAddr::V6(address) => address.is_unique_local() || address.is_loopback(),
}
}
@@ -372,3 +372,73 @@ async fn quota_forecast_requires_admin_and_valid_account_query() {
assert!(value["message"].is_string());
}
}
#[tokio::test]
async fn concurrency_details_require_admin_validate_paging_and_report_lease_time() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
*fixture.account.lock().unwrap() = Some(account_page_item(
gateway_core::account::AccountStatusFacts {
enabled: true,
credential_state: gateway_core::account::CredentialState::Ready,
access_token_expires_at: None,
quota: gateway_core::account::QuotaState::unknown(),
cooldown: None,
last_error_reason: None,
last_error_message: None,
},
chrono::Utc::now(),
"api_key",
));
let router = admin::accounts::router::<AdminTestState>().with_state(fixture.state());
for (authenticated, query, expected) in [
(false, "accountId=acct_cooldown", StatusCode::UNAUTHORIZED),
(
true,
"accountId=acct_cooldown&page=0",
StatusCode::BAD_REQUEST,
),
(
true,
"accountId=acct_cooldown&pageSize=101",
StatusCode::BAD_REQUEST,
),
(
true,
"accountId=acct_cooldown&page=4294967295&pageSize=100",
StatusCode::BAD_REQUEST,
),
(true, "accountId=acct_missing", StatusCode::NOT_FOUND),
(
true,
"accountId=acct_cooldown&page=2&pageSize=20",
StatusCode::OK,
),
] {
let mut request = Request::builder()
.uri(format!("/api/admin/accounts/concurrency?{query}"))
.header("x-request-id", "req_concurrency");
if authenticated {
request = request.header(header::COOKIE, "cpr_session=valid-session");
}
let response = router
.clone()
.oneshot(request.body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), expected, "{query}");
if expected == StatusCode::OK {
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
let body = to_bytes(response.into_body(), 8192).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(value["data"]["total"], 21);
assert_eq!(value["data"]["page"], 2);
assert_eq!(value["data"]["entries"][0]["occupiedMs"], 5000);
assert_eq!(value["data"]["entries"][0]["clientIp"], "2001:db8::376");
assert_eq!(
value["data"]["entries"][0]["startedAtDisplay"],
"2026-10-17 07:59:45"
);
}
}
}
@@ -646,6 +646,8 @@ impl SettingsStore for MemorySettingsStore {
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
reset_credit_max_weekly_remaining_percent: None,
},
};
*settings = updated.clone();
@@ -1130,6 +1132,15 @@ impl ClientKeyVerifier for UnusedClientKeyVerifier {
#[async_trait]
impl AccountStore for UnusedStore {
async fn account_exists(&self, id: &str) -> AdminStoreResult<bool> {
Ok(self
.account
.lock()
.expect("account")
.as_ref()
.is_some_and(|account| account.account.id == id))
}
async fn list_plugin_accounts(
&self,
_: gateway_admin::model::provider_credentials::PluginAccountListQuery,
@@ -1348,6 +1359,32 @@ impl AccountStore for UnusedStore {
#[async_trait]
impl AccountRuntimeStore for UnusedStore {
async fn account_concurrency(
&self,
account_id: &str,
offset: u64,
limit: u32,
) -> AdminStoreResult<gateway_admin::model::accounts::AccountConcurrencyPage> {
use gateway_admin::model::accounts::{AccountConcurrencyEntry, AccountConcurrencyPage};
assert_eq!(account_id, "acct_cooldown");
assert_eq!((offset, limit), (20, 20));
let now = "2026-10-17T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
Ok(AccountConcurrencyPage {
total: 21,
observed_at: now,
entries: vec![AccountConcurrencyEntry {
lease_id: "lease_current".into(),
request_id: Some("req_current".into()),
client_key_id: Some("key_fixture".into()),
client_key_name: Some("测试 Key".into()),
model: Some("gpt-5.4".into()),
client_ip: Some("2001:db8::376".into()),
started_at: Some(now - chrono::Duration::seconds(15)),
acquired_at: Some(now - chrono::Duration::seconds(5)),
}],
})
}
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot {
cooldown: BTreeMap::new(),
@@ -1789,6 +1826,8 @@ fn test_runtime_settings() -> RuntimeSettings {
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
reset_credit_max_weekly_remaining_percent: None,
},
}
}
@@ -1845,3 +1884,29 @@ fn unsupported_provider() -> ProviderAdminError {
fn unavailable_system() -> SystemOperationError {
SystemOperationError::new(SystemOperationErrorKind::Internal, "unused test operation")
}
#[async_trait]
impl gateway_admin::ports::reset_credit_automation::ResetCreditAutomationStore for UnusedStore {
async fn reset_credit_automation(
&self,
_: &str,
) -> AdminStoreResult<
Option<gateway_admin::model::reset_credit_automation::ResetCreditAutomation>,
> {
Ok(None)
}
async fn due_reset_credit_automations(
&self,
_: chrono::DateTime<chrono::Utc>,
_: u32,
) -> AdminStoreResult<Vec<String>> {
Ok(Vec::new())
}
async fn save_reset_credit_automation(
&self,
_: &gateway_admin::model::reset_credit_automation::ResetCreditAutomation,
_: Option<&gateway_admin::model::MutationContext>,
) -> AdminStoreResult<u64> {
Err(unavailable("reset-credit automation"))
}
}
@@ -25,6 +25,7 @@ async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headli
.dashboard_observation
.lock()
.expect("dashboard observation") = Some(DashboardObservation {
yesterday: Default::default(),
analytics: gateway_admin::model::observability::AnalyticsMetadata {
coverage_start: Some(now - Duration::hours(1)),
coverage_end: Some(now),
@@ -106,14 +107,14 @@ async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headli
}
#[tokio::test]
async fn dashboard_summary_should_use_lifetime_totals_for_card_footers() {
async fn dashboard_summary_should_keep_yesterday_comparison_separate_from_today_and_lifetime() {
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header},
};
use chrono::{Duration, Utc};
use gateway_admin::model::observability::{
AccountPoolMetrics, DashboardObservation, DashboardTotals, TimeRange,
AccountPoolMetrics, DashboardObservation, DashboardTotals, RequestMetrics, TimeRange,
};
use gateway_api::admin::observability;
use tower::ServiceExt as _;
@@ -127,6 +128,13 @@ async fn dashboard_summary_should_use_lifetime_totals_for_card_footers() {
.dashboard_observation
.lock()
.expect("dashboard observation") = Some(DashboardObservation {
yesterday: RequestMetrics {
request_count: 7,
input_tokens: 400,
cached_input_tokens: 200,
total_tokens: 500,
..Default::default()
},
analytics: Default::default(),
runtime_slots: None,
range: TimeRange::new(now - Duration::hours(1), now).expect("dashboard range"),
@@ -161,6 +169,11 @@ async fn dashboard_summary_should_use_lifetime_totals_for_card_footers() {
.expect("dashboard response body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("dashboard response JSON");
assert_eq!(status, StatusCode::OK, "{value}");
let cards = &value["data"]["cards"];
assert_eq!(cards["traffic"]["todayRequestsValue"], 0);
assert_eq!(cards["traffic"]["yesterdayRequestsValue"], 7);
assert_eq!(cards["tokens"]["yesterdayTokensValue"], 500);
assert_eq!(cards["cache"]["yesterdayHitRateValue"], 0.5);
assert_eq!(
value["data"]["analytics"]["coverageStartDisplay"],
serde_json::Value::Null
@@ -208,6 +221,7 @@ async fn dashboard_summary_should_default_to_current_deployment_day() {
.dashboard_observation
.lock()
.expect("dashboard observation") = Some(DashboardObservation {
yesterday: Default::default(),
analytics: Default::default(),
runtime_slots: None,
range: TimeRange::new(now - Duration::hours(1), now).expect("dashboard range"),
@@ -801,6 +815,9 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
.lock()
.expect("usage records")
.push(UsageListRecord {
error_code: None,
error_message: None,
client_status_code: None,
outcome: gateway_admin::model::observability::RequestOutcome::Succeeded,
client_api_key_name: Some("Production".to_owned()),
id: "request_endpoint".to_owned(),
@@ -892,12 +909,18 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
});
records.push(image);
}
let mut failed = records[0].clone();
failed.id = "request_failed".to_owned();
failed.outcome = gateway_admin::model::observability::RequestOutcome::Failed;
failed.error_code = Some("server_error".to_owned());
failed.error_message = Some("The upstream service is temporarily unavailable.".to_owned());
records.push(failed);
}
let response = observability::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/usage/records")
.uri("/api/admin/usage/records?status=all")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_usage_endpoint")
.body(Body::empty())
@@ -911,6 +934,13 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
.expect("usage response body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("usage response JSON");
assert!(value["data"]["items"][0]["errorCode"].is_null());
assert!(value["data"]["items"][0]["errorMessage"].is_null());
assert_eq!(value["data"]["items"][3]["errorCode"], "server_error");
assert_eq!(
value["data"]["items"][3]["errorMessage"],
"The upstream service is temporarily unavailable."
);
assert_eq!(value["data"]["items"][0]["clientApiKeyName"], "Production");
assert_eq!(value["data"]["items"][0]["accountNotes"], "Team workspace");
assert_eq!(
@@ -1075,6 +1105,7 @@ async fn calendar_queries_and_display_follow_deployment_timezone_with_one_anchor
let fixture = AdminTestFixture::with_timezone(timezone).await;
fixture.auth.insert_session("valid-session");
*fixture.dashboard_observation.lock().unwrap() = Some(DashboardObservation {
yesterday: Default::default(),
analytics: gateway_admin::model::observability::AnalyticsMetadata {
coverage_start: Some(range.start),
coverage_end: Some(end),

Some files were not shown because too many files have changed in this diff Show More