Compare commits

...
52 Commits
Author SHA1 Message Date
ZyyandCodex e30aad4755 chore(release): v3.18.2
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 03:41:55 +08:00
ZyyandCodex 945f7912ac chore(deps): sync UI v0.3.1 and plugin references
Update the admin UI dependency and lockfile, and align the UI and plugin submodules with the released modal fix.

Co-authored-by: Codex <noreply@openai.com>
2026-09-30 03:38:10 +08:00
ZyyandCodex 19038f264a fix(admin): correct usage diagnostics and refine management views
Use all matching requests for diagnostic shares and count retried requests once. Show current account plans in diagnostics and request records with compact, aligned columns.

Simplify quota forecasts and remove unused remaining-capacity response fields. Open system updates immediately with the existing loading skeleton and clip masked key prefixes without an ellipsis.

Co-authored-by: Codex <noreply@openai.com>
2026-09-30 03:28:58 +08:00
ZyyandCodex 71c3b03878 fix(openai): add built-in GPT-6.1 Sol pricing
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 03:27:01 +08:00
ZyyandCodex 70d1557b55 chore(release): v3.18.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 01:36:39 +08:00
ZyyandCodex b4d11694ae fix(update): confirm incompatible plugin shutdown before restart
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 01:36:32 +08:00
ZyyandCodex 718b959fc1 chore(release): v3.18.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-30 00:14:35 +08:00
ZyyandCodex 11c076ef5a fix(release): seal current plugin compatibility declaration
Validate the v2 host declaration while retaining the v1 release envelope, and sync the plugin submodule to stable v0.2.0.

Co-authored-by: Codex <noreply@openai.com>
2026-09-30 00:14:22 +08:00
ZyyandCodex b37d790144 fix(api): default HTTP responses to JSON when stream is omitted
Preserve explicit stream preferences and the WebSocket streaming default without rewriting request bodies or changing upstream execution.

Add decoder and HTTP delivery regression coverage.

Co-authored-by: Codex <noreply@openai.com>
2026-09-29 22:15:19 +08:00
ZyyandCodex 3a04e166c2 refactor: clarify ownership and simplify plugin management
Centralize audit classifications and retention policy, move cleanup orchestration to Host, and require explicit plugin lifecycle implementations.

Remove unused interfaces and redundant state, type-check the standalone plugin bridge, and split management flows by responsibility.

Update architecture boundaries and add focused regression coverage.

Co-authored-by: Codex <noreply@openai.com>
2026-09-29 20:56:10 +08:00
ZyyandCodex ec21d83415 refactor(backend): flatten leaf modules
Co-authored-by: Codex <noreply@openai.com>
2026-09-29 18:42:28 +08:00
ZyyandCodex fe78c230e5 feat(plugin): expose request lifecycles through onion middleware
Compose HTTP, WebSocket, request, attempt and public service calls through
shared onion middleware with scoped settings and invocation context.

Adopt trusted-process manifests, unified observation and forward-compatible
SDK contracts. Align management UI, documentation and the workbench reference.
Preserve native response bytes and WebSocket failure lifecycles.

Co-authored-by: Codex <noreply@openai.com>
2026-09-29 18:20:22 +08:00
Zyy d83d3eb803 feat(plugin): add managed upstream adapters (#326)
feat(plugin): add managed upstream adapters for built-in providers
2026-09-28 23:31:25 +08:00
ZyyandCodex faee3b2642 feat(plugin): add managed upstream adapters for built-in providers
Co-authored-by: Codex <noreply@openai.com>
2026-09-28 23:23:41 +08:00
ZyyandCodex a5a844af16 fix(openai): align connection settings with Codex defaults
Use the official reqwest defaults for idle pooling, TCP keepalive, and HTTP/2 keepalive.

Co-authored-by: Codex <noreply@openai.com>
2026-09-28 20:02:04 +08:00
dependabot[bot] d6bdb9d191 chore(deps): bump redis in /backend in the minor-and-patch group (#321)
Bumps the minor-and-patch group in /backend with 1 update: [redis](https://github.com/redis-rs/redis-rs).


Updates `redis` from 1.7.0 to 1.7.1
- [Release notes](https://github.com/redis-rs/redis-rs/releases)
- [Commits](https://github.com/redis-rs/redis-rs/compare/redis-1.7.0...redis-1.7.1)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 1.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 18:48:08 +08:00
dependabot[bot] 33aa161863 chore(deps): bump node from 2d49d87 to 662933c in /deploy (#320)
Bumps node from `2d49d87` to `662933c`.

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26-bookworm-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 18:47:30 +08:00
ZyyandCodex dc45aee5da ci: streamline cache handling and release jobs
Validate Cargo mount snapshots before saving, group cache tooling in a local action, and decouple image builds from release packaging. Use prebuilt security tooling and narrow unrelated CI triggers.

Co-authored-by: Codex <noreply@openai.com>
2026-09-28 18:33:20 +08:00
ZyyandCodex b3fa5322f1 perf(test): reduce backend fixture and timeout overhead
Reuse immutable plugin archives across nextest processes and share admin fixtures within test matrices. Reduce log scanning, hashing, compression, database setup, and SSE scanning costs while preserving isolation and boundary coverage.

Use controllable clocks for recovery budgets and quota scheduling, retaining real socket and HTTP verification.

Validation: 3200 backend tests, 132 plugin persistence tests, 54 Redis ACL tests, 30 clock stress iterations, clippy, and formatting checks passed. Local full-suite runtime improved from 182.8s to 74.0s at unchanged concurrency.

Co-authored-by: Codex <noreply@openai.com>
2026-09-28 17:49:23 +08:00
ZyyandCodex 8fb42300c1 ci: streamline release checks and protect contribution rules
Reuse test binaries and frontend builds, reduce redundant cache writes, and overlap release preparation with quality checks.

Protect skills, contribution rules, and GitHub configuration, and simplify workflow names.

Co-authored-by: Codex <noreply@openai.com>
2026-09-28 16:54:13 +08:00
ZyyandCodex a1fc368474 docs: streamline documentation and skill workflows
Keep documentation focused on current behavior and clear ownership.
Load skill references by task and clarify official source priority and reply tone.

Co-authored-by: Codex <noreply@openai.com>
2026-09-28 15:54:49 +08:00
dependabot[bot] 9a1e90dd3d chore(deps): bump jsonschema from 0.56.0 to 0.57.0 in /backend (#315)
Bumps [jsonschema](https://github.com/Stranger6667/jsonschema) from 0.56.0 to 0.57.0.
- [Release notes](https://github.com/Stranger6667/jsonschema/releases)
- [Changelog](https://github.com/Stranger6667/jsonschema/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stranger6667/jsonschema/compare/ruby-v0.56.0...ruby-v0.57.0)

---
updated-dependencies:
- dependency-name: jsonschema
  dependency-version: 0.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 14:40:21 +08:00
dependabot[bot] e7b0551f1a chore(deps): bump the minor-and-patch group in /backend with 6 updates (#314)
Bumps the minor-and-patch group in /backend with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [thiserror](https://github.com/dtolnay/thiserror) | `2.0.20` | `2.0.21` |
| [config](https://github.com/rust-cli/config-rs) | `0.15.25` | `0.15.26` |
| [headers](https://github.com/hyperium/headers) | `0.4.1` | `0.4.2` |
| [aws-sdk-s3](https://github.com/awslabs/aws-sdk-rust) | `1.148.0` | `1.149.0` |
| [aws-smithy-runtime-api](https://github.com/smithy-lang/smithy-rs) | `1.16.2` | `1.18.0` |
| [aws-smithy-types](https://github.com/smithy-lang/smithy-rs) | `1.6.4` | `1.8.1` |


Updates `thiserror` from 2.0.20 to 2.0.21
- [Release notes](https://github.com/dtolnay/thiserror/releases)
- [Commits](https://github.com/dtolnay/thiserror/compare/2.0.20...2.0.21)

Updates `config` from 0.15.25 to 0.15.26
- [Changelog](https://github.com/rust-cli/config-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-cli/config-rs/compare/v0.15.25...v0.15.26)

Updates `headers` from 0.4.1 to 0.4.2
- [Release notes](https://github.com/hyperium/headers/releases)
- [Commits](https://github.com/hyperium/headers/compare/headers-v0.4.1...headers-v0.4.2)

Updates `aws-sdk-s3` from 1.148.0 to 1.149.0
- [Release notes](https://github.com/awslabs/aws-sdk-rust/releases)
- [Commits](https://github.com/awslabs/aws-sdk-rust/commits)

Updates `aws-smithy-runtime-api` from 1.16.2 to 1.18.0
- [Release notes](https://github.com/smithy-lang/smithy-rs/releases)
- [Changelog](https://github.com/smithy-lang/smithy-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-rs/commits)

Updates `aws-smithy-types` from 1.6.4 to 1.8.1
- [Release notes](https://github.com/smithy-lang/smithy-rs/releases)
- [Changelog](https://github.com/smithy-lang/smithy-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-rs/commits)

---
updated-dependencies:
- dependency-name: thiserror
  dependency-version: 2.0.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: config
  dependency-version: 0.15.26
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: headers
  dependency-version: 0.4.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-sdk-s3
  dependency-version: 1.149.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-smithy-runtime-api
  dependency-version: 1.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: aws-smithy-types
  dependency-version: 1.8.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 14:39:54 +08:00
dde4a91974 feat(plugin): include account name and email in data facts (#313)
* feat(plugin): include account name and email in data facts

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

* fix(plugin): allow additive fields in data responses

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

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-09-28 14:25:01 +08:00
ZyyandCodex bb859455f9 fix(update): retain binary installation path for self-restart
Resolve and retain the executable path at startup so updates, rollbacks and
restarts keep using the installation path after the running binary is renamed.
Document binary self-restart configuration and cover renamed executable restarts.

Refs #316

Co-authored-by: Codex <noreply@openai.com>
2026-09-28 13:06:04 +08:00
ZyyandCodex e06ca56f93 chore(plugins): update examples and require official source references
Advance plugin examples to v0.1.17 and clarify source priorities for plugin development.

Co-authored-by: Codex <noreply@openai.com>
2026-09-28 03:25:57 +08:00
ZyyandCodex 2a65b53b44 fix(accounts): refine capacity alignment and daily usage display
Co-authored-by: Codex <noreply@openai.com>
2026-09-28 01:10:25 +08:00
ZyyandCodex 3915aa259b test(accounts): align capacity tests with module layout
Co-authored-by: Codex <noreply@openai.com>
2026-09-27 22:53:03 +08:00
ZyyandCodex d310286e33 chore(release): v3.17.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-27 22:41:22 +08:00
Zyy ea4e3059db feat(accounts): show current concurrency capacity 2026-09-27 22:36:51 +08:00
3bf7030fba fix(openai): widen and trace stream replay grace (#304)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-27 19:51:41 +08:00
5d5965c6ca feat(plugin): expose non-secret client key group facts (#302)
Co-authored-by: root <root@DESKTOP-PRVCERP.localdomain>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-27 19:47:35 +08:00
ZyyandCodex 0710ed1f7c feat(plugins): expose native budgets and quota observations (#300)
Expose independent key budget management and quota observation capabilities
through the public plugin SDK, runtime authorization, and native admin services.
Reuse existing budget transactions and Provider quota refresh paths without
adding workflow-specific state or automatic account-to-key orchestration.

Cover authorization, partial budget updates, resets, quota refresh projection,
transaction rollback, and SDK request behavior. Remove ignored external probes.

Co-authored-by: Codex <noreply@openai.com>
2026-09-27 18:44:32 +08:00
4634d8dc6d fix(openai): clarify quota failures without changing credential state (#301)
Preserve quota HTTP status and bounded error codes, and expose actionable
static messages through the existing admin error response. Record background
quota failures in structured logs without mutating credential snapshots.

Cover OAuth terminal results during concurrent quota rejection, successful
recovery, account fact preservation, and safe HTTP error propagation.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-27 15:31:37 +08:00
ZyyandCodex 8a0759905e fix(frontend): stabilize loading states and unify key usage actions
Keep client identity fields visible while presets load and preserve dashboard
content during silent refreshes. Align key usage toolbar backgrounds, spin the
refresh icon, and disable logout without replacing its icon.

Co-authored-by: Codex <noreply@openai.com>
2026-09-27 13:27:13 +08:00
ZyyandCodex e830b2ef48 fix(openai): isolate account context and scope websocket interrupts (#298)
Filter account-owned routing and identity context at API and Provider boundaries. Bind WebSocket interrupts to the active response on its original account and socket, preserving valid continuations.

Co-authored-by: Codex <noreply@openai.com>
2026-09-27 05:27:07 +08:00
ae8da00459 fix(openai): preserve affinity while waiting for account capacity (#297)
Classify saturated account snapshots as capacity unavailable and prefer
the bound account's existing queue when account waiting is enabled.
Preserve explicit policy picks, child affinity delegation and queue limits.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-27 03:30:09 +08:00
ZyyandCodex 7bbea42fba docs(redis): clarify ACL setup and add regression coverage
Document ACL usernames in connection URLs and the Compose URL override.
Run Store tests with a dedicated ACL user and the default user disabled
after the existing password-authenticated suite.

Co-authored-by: Codex <noreply@openai.com>
2026-09-27 02:49:41 +08:00
ZyyandCodex 2e0669d462 fix(frontend): distinguish historical system update failures
Keep previous operation failures available as history without marking the current installation as failed. Preserve current task errors, response-loss recovery, and restart status.

Fixes #236

Co-authored-by: Codex <noreply@openai.com>
2026-09-27 02:08:48 +08:00
ZyyandCodex 2df9494e99 fix(frontend): stabilize system update progress layout
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 22:26:10 +08:00
ZyyandCodex 0534dd8f26 chore(release): v3.16.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 22:02:00 +08:00
ZyyandCodex 525f3d51c1 refactor(store): consolidate unreleased scheduling migration
Fold smart scheduling settings into migration 0019 while preserving all released migrations through 0018. Update the checksum manifest and upgrade test.

Development databases that applied the previous 0019 or 0020 cannot upgrade in place because the migration history differs.

Co-authored-by: Codex <noreply@openai.com>
2026-09-26 21:59:05 +08:00
CharmskyandCodex 33cc427b86 feat(scheduling): add configurable smart scheduling preferences (#295)
Make smart scheduling coefficients configurable and expose optional
higher-weight switchback while preserving the original defaults.

Reuse current quota reset signals, process-local queues, and frozen request
settings while preserving continuation ownership and queue fairness.

Co-authored-by: Codex <noreply@openai.com>
2026-09-26 21:51:28 +08:00
ZyyandCodex d1bf90d5fd fix(frontend): support HTTP plugin pages and consolidate shared helpers
Reuse secure UUID generation for plugin frame channels and sessions on HTTP.
Consolidate utility modules and shorten the plugin views store name.
Declare route roles and default entries in metadata instead of guard mappings.
Keep source integration build output under node_modules/.vite.

Validated frontend lint, production and source builds, browser plugin flows,
and 20 route guard scenarios with simulated sessions and plugin resources.

Fixes #288

Co-authored-by: Codex <noreply@openai.com>
2026-09-26 19:33:17 +08:00
eb23a0fb3b fix(plugins): align management request timeouts (#294)
Use a shared 180-second timeout for management route HTTP calls and the page bridge while keeping the backend RPC limit at 120 seconds. Fix the existing Vue attribute-order lint warning.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 19:11:01 +08:00
niceSpriteandCodex ca90f2b586 fix(openai): recover oversized Codex requests before WebSocket send (#287)
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 19:03:40 +08:00
ZyyandCodex cfcf7c486a fix(plugins): detect latest releases and streamline upgrades (#293)
Refresh explicit GitHub release checks while preserving concurrent fetch sharing and fixed-release download caching.

Compare verified package versions and offer a direct upgrade with pinned digests, revision checks, permission disclosure, and configuration fallback.

Co-authored-by: Codex <noreply@openai.com>
2026-09-26 19:00:23 +08:00
b981e42342 feat(plugins): provision owned resources and reconcile account changes (#292)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-09-26 18:46:12 +08:00
ZyyandCodex 02177af921 fix(accounts): refresh OAuth tokens while scheduling is disabled (#291)
Separate scheduling availability from OAuth candidate selection and credential
state writes across OpenAI, xAI, and the shared account store. Cover disabled
refresh, terminal failures, and in-flight scheduling changes with regression tests.

Co-authored-by: Codex <noreply@openai.com>
2026-09-26 18:32:48 +08:00
ZyyandCodex 2d20f03bcb refactor(admin): simplify diagnostics and refresh shared UI integration
Adopt UI v0.3.0 and its shared Vite adapter, preserve request-volume ordering in diagnostics, stabilize sidebar animation, and track workbench v0.1.15.

Co-authored-by: Codex <noreply@openai.com>
2026-09-26 17:14:54 +08:00
ZyyandCodex c12c600b42 fix(models): scope catalog visibility to discovering accounts (#286)
Preserve OpenAI catalog account sources in runtime snapshots and apply
frozen account scope and model policies when publishing model metadata.
Keep discovery catalogs separate from inference authorization and cover
mixed policies, aliases, group changes, and catalog generation updates.

Co-authored-by: Codex <noreply@openai.com>
2026-09-26 15:51:13 +08:00
ZyyandCodex 1c6b5a8f10 fix(accounts): restore OAuth reauthorization flow
Generate authorization links when reopening account authorization and regenerate them in a single action. Remove the added authorization page shortcut and unused reset event wiring.

Co-authored-by: Codex <noreply@openai.com>
2026-09-26 05:32:04 +08:00
654 changed files with 41233 additions and 9880 deletions
+31 -40
View File
@@ -1,58 +1,49 @@
---
name: cpr-dev-guide
description: Codex Proxy RS 仓库开发与问题审查入口。用户要求“开发/实现这个功能”“直接改”“修复一下”“看下报错”“审查这个问题/代码”“自审当前改动”“调整页面”“更新文档”,或继续修改本仓库的 Rust、Vue、协议、存储、部署和 CI 时主动使用,无需点名技能。负责定位、方案、自审与验证;提交或审查 PR 另配合 cpr-github-pr。Use for implementation, debugging, code review and documentation in this repository, not generic advice for unrelated projects.
description: Codex Proxy RS 仓库的开发、修复、排障、代码审查和文档修改入口;PR、Issue、插件创作与发版按任务另用对应技能
---
# 开发指南
先读根目录的 [AGENTS.md](../../../AGENTS.md) 和 [CONTRIBUTING.md](../../../CONTRIBUTING.md)。
本技能只组织工作入口,架构、协议和视觉规则从下列权威文档读取,不维护第二份副本。
## 加载边界
普通问题审查、代码审查与排查由本技能进入贡献文档的审查标准;有 PR 链接、编号或提交 PR 的意图时再加载 `cpr-github-pr`。
审查请求先报告结论,不因加载开发技能而擅自修复;用户已要求修改时完成实现、自审和验证。
- 遵守已提供的 [AGENTS.md](../../../AGENTS.md),上下文已有的规则不重复读取
- 下表是条件导航,不是必读清单,只加载当前任务命中的技能与章节,进入新阶段再补读
- 文档链接带 `#章节` 不代表读取工具会自动截取内容,先用 `rg -n '^#{1,4} ' 文件` 找边界,再按范围读取;常规定位不整份加载 CONTRIBUTING、架构、API、主题或全部 references,确需全文审查时再展开
- 每次补读先明确要解决的未知项,已有资料足够就继续工作;文档提及 Rust、Vue、插件或发布,不等于要加载对应开发流程
## 按任务读取
| 任务 | 技能与文档入口 |
| 当前任务 | 必读的相关部分 |
| --- | --- |
| 用户能力、快速开始 | [README](../../../README.md) |
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
| Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) |
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
| 开发独立网关插件 | [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md):公开 SDK、能力选择、示例、打包与验证 |
| 前端开发、管理端组件、主题 Token、视觉验证 | 按下方「前端任务」加载设计约束,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
| 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 |
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
| 验证命令与提交规范 | [贡献与审查](../../../CONTRIBUTING.md) |
| 创建或审查 PR | [cpr-github-pr](../cpr-github-pr/SKILL.md) |
| 整理或提交 Issue | [cpr-github-issue](../cpr-github-issue/SKILL.md) |
| 发布说明、版本规划与发版 | [cpr-release](../cpr-release/SKILL.md) |
| 开发、排障、代码审查 | [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),需要定位代码时按 AGENTS 的 CodeGraph 约定执行 |
| 模块或调用链变更 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)及所属模块章节,不通读整份架构 |
| Rust 开发或审查 | `$rust-best-practices`、所属模块、[后端自审](../../../docs/architecture.md#后端自审),验证时读[命令](../../../docs/architecture.md#验证命令) |
| 页面开发 | `$frontend-design`、[文案与信息层级](../../../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) |
| 修改或审查文档 | [文档检查](references/documentation.md)及目标章节,不默认读取代码开发、页面验收或发布流程 |
| 普通代码审查 | [审查标准](../../../CONTRIBUTING.md#审查标准);审查默认只读 |
| PR / Issue / 发版 | 按当前意图选 [PR](../cpr-github-pr/SKILL.md)、[Issue](../cpr-github-issue/SKILL.md)或[发版](../cpr-release/SKILL.md),普通提交不创建 PR |
## 前端任务
## 参考仓库的优先级
- 加载并使用 `$frontend-design`,先读 [界面文案与信息层级](../../../docs/theme.md#界面文案与信息层级),将本项目的管理工具风格、现有字体与主题 Token 作为设计约束
- 开发前对照原页面及该章节的同类参考,确认可复用布局、组件与操作位置;修改已有页面前先留存截图,不为局部需求重做页面
- 页面保留操作所需的简短信息,重复文案删除,详细帮助按主题约定收进 Popover;关键限制和错误仍就近提示
- 交付前按该章节复核文案、层级与密度,按 [界面验证](../../../CONTRIBUTING.md#界面验证) 实际操作并保存前后截图;页面 PR 的截图必须可供审查者访问,预览步骤与构建不能替代
- 涉及 Codex 行为、协议或兼容性时,以官方 [openai/codex](https://github.com/openai/codex) 对应版本的源码与调用链为第一手资料,先查官方实现
- 优先使用用户提供或本机已有的官方仓库,核对来源、版本或提交;资料不足时再查官方对应版本的源码、测试与文档
- 三方仓库只能提供排查线索或实现参考,不能代替官方行为依据;与官方不一致时回到官方源码核实,不直接照搬三方结论
- 结论注明适用版本或提交,区分官方事实、三方参考与本项目推断;官方证据不足时保留不确定性
## 后端任务
## 执行与收尾
加载并使用 `$rust-best-practices`,先按架构文档定位事实归属与既有调用链,实现后逐项执行 [后端自审](../../../docs/architecture.md#后端自审) 中与本次改动相关的检查。
交付说明实际复用位置、重要边界的处理与验证缺口,不用“符合最佳实践”或“CI 已通过”代替自审结论。
1. 确认工作区、任务范围与已有改动,先定位事实归属、同类实现和调用链,再修改;编码前只读[项目约定](../../../CONTRIBUTING.md#项目约定)的对应条目
2. 自审完整差异,处理本次引入的职责越界、重复规则、冗余状态与无依据分支;仅审查时报告,不自行修复
3. 按变更范围读取[验证](../../../CONTRIBUTING.md#验证),区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
4. 交付说明实际结果与验证缺口;页面证据和后端生命周期检查按上表执行,不把构建通过当作行为验收
## 执行顺序
## 文档硬约束
1. 从仓库根目录确认工作区、当前任务范围和相关运行实例;保留已有改动。
2. 按 `AGENTS.md` 的 CodeGraph 约定定位入口、调用链和职责归属,再阅读相关实现。
3. 按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实触发条件、问题发生版本与目标分支现状,
优先依据适用的官方源码确定或评估方案。结合上表读取所需文档,先找可复用的同类实现,再按授权修改或审查所属模块。
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;
按 [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否修订,不为每次改动追加文档或发布说明。
5. 复核相对目标基线的完整差异及上下游:开发时清理本次引入的职责越界、重复逻辑、冗余状态和无依据的分支;仅审查时报告问题与依据。页面与后端分别按上述任务入口执行,不等待 PR 审查代做开发自审。
6. 交付前按文档职责逐段复核最终文档 diff:新增内容是否回答当前读者的使用或维护问题,是否属于该文档,
是否与其他位置重复,脱离本次变更背景能否独立理解。仅解释实现经过、前后对比或验证结果的内容移到
任务报告或 PR;仍影响升级、恢复的说明保留适用条件与操作。没有文档差异时,只核对行为变化是否使现有说明失真。
7. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。用户要求提交或更新 PR 时,进入 `cpr-github-pr` 流程整理最终证据与正文。
**常规文档只描述当前状态,不写变更历史**,这项检查适用于每次开发收尾,不限于专门的文档任务
发布任务按 `cpr-release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
- 动笔前确定读者、所属章节与必须说明的当前事实,没有必要就不改文档
- 有文档差异时执行[文档检查](references/documentation.md),逐段决定保留、改写、删除或移入任务报告,未完成内容审查不能称文档已检查
- 没有文档差异时只确认现有说明是否失真;普通开发不自动修改 `release/notes.md`
@@ -0,0 +1,38 @@
# 文档检查
仅在准备修改文档或最终差异含文档时读取,职责与行文依据是 [文档职责与更新条件](../../../../CONTRIBUTING.md#文档职责与更新条件),只读该节
## 写之前
先明确这段内容服务谁、解决哪个当前使用或维护问题、属于哪个现有章节,再决定详略
接口保留调用合同,架构保留职责与不变量,部署保留操作与排障;同一事实只在所属位置完整说明,其他位置链接
常规文档在原章节修订,不追加本次变更小节,不为了“同步文档”补写修复总结
内部重构、实现经过、前后对比、验证结果应留在 PR、Issue 或任务报告
## 逐段验收
检查工作区和暂存区中本任务的最终文档差异,按内容用途处理,不只搜索关键词:
| 段落回答的问题 | 处理 |
| --- | --- |
| 现在如何使用、配置或理解结果 | 保留当前事实与必要边界,放回所属章节 |
| 谁负责状态、事务、资源和失败处理 | 放入所属架构章节,避免在 API 或部署文档复述实现 |
| 这次做了什么、以前怎样、为什么这样修、跑过哪些测试 | 移入 PR、Issue 或任务报告,不以换成现在时的方式留在常规文档 |
| 特定配置或数据状态下如何升级、恢复 | 写明适用条件、操作及限制,删除演变过程 |
| 别处已完整说明的事实 | 合并或改为必要摘要与链接 |
脱离提交日期、Issue 和本次任务背景后,读者仍应能独立理解正文
业务中的请求历史、审计记录、旧数据兼容与回滚操作是当前功能,不能因含“历史”“旧”等词而删除
## 交付检查
- 检查新段落的必要性、归属、重复与当前状态表达,发现变更叙述先改完再交付
- 标题、短列表、表格和独立短段通常不加句号,长段按主题拆分,命令与字段保持准确
- 检查相对链接、章节锚点、表格与命令示例,`git diff --check` 或构建通过不能代替内容审查
- 报告实际覆盖和缺口即可,不把检查记录写进常规文档
只有明确的发布说明任务才进入 `cpr-release`,`release/notes.md` 用于该次发行,不向 README、API、架构或部署文档复制版本变化列表
@@ -0,0 +1,11 @@
# 插件职责边界
仅在开发或审查 SDK、Runtime、宿主扩展接口及独立网关插件时读取
先看[插件扩展](../../../../docs/architecture.md#31-插件扩展)中的职责与执行边界,不通读整份架构
**宿主可以暴露底层能力,不能替插件完成业务**
- 宿主提供可独立组合的受控原生能力,负责授权、资源隔离、事务与审计,复用已有服务与状态归属
- 插件自身的触发条件、跨资源关联、策略和流程编排由插件实现,不能把某个使用场景做成宿主专属流程、状态或补偿逻辑
- 发现能力缺口时,界定可独立使用的基础接口,说明输入输出、权限、事务、失败及重复调用语义,不夹带插件私有数据模型或工作流
- 自审调用链、合同、测试与文档中的业务判断、自动串联和专属状态,仅换成通用名称不算解除耦合
+44 -43
View File
@@ -1,61 +1,62 @@
---
name: cpr-github-issue
description: 整理、创建和补充 GitHub Issue。用户说“提/开/提交一个 Issue”“报个 bug 到仓库”“反馈给上游”“把问题或功能建议整理成 Issue”“补充这个 Issue”时主动使用;已有 Issue 反馈上下文中的“提交吧”“补上复现”也适用。处理缺陷、功能与协作规则反馈,区分草拟和发布。Use for drafting, filing and updating issues; standalone “审查问题/看下报错/修一下” belongs to cpr-dev-guide, not automatic Issue submission.
description: 草拟、创建、补充或回复 GitHub Issue,包括缺陷、功能和协作规则反馈;单独排障或修复不自动发布 Issue
---
# 整理与提交 Issue
# Issue 协作
本仓库流程见 [贡献与审查](../../../CONTRIBUTING.md),按场景选择当前的
[接口问题反馈](../../../.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题反馈](../../../.github/ISSUE_TEMPLATE/general-bug-report.yml)
或 [功能建议](../../../.github/ISSUE_TEMPLATE/feature-request.yml) 表单。从表单读取字段与必填项,不在技能中复制一份字段清单。
用户指定其他仓库时,使用该仓库当前的贡献规则与 Issue 模板,不能把这里的表单套过去。
## 目标与范围
## 确定目标和动作
从用户 URL、remote 和上下文确认接收仓库,fork 场景区分自己的仓库与上游
- 根据用户 URL、仓库上下文和 remote 确认接收 Issue 的仓库。fork 场景核对用户要反馈给自己的 fork 还是上游。
- “写一份、整理内容、草拟 Issue”只准备正文;明确要求“提交、创建 Issue”才发布。
已经授权提交且信息足够时直接完成,不重复索要确认;仅自动选中技能不能授权发布。
- “有个 bug”“这个报错怎么修”本身是排障语境,不能推断要发 Issue;结合已有反馈任务识别省略的目标和动作。
- “补充这个 Issue”先读取原帖与已有讨论,按授权更新正文或追加证据,不重复新建;需要排查或修改本仓库时先执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)。
- 缺少无法推断的目标或关键复现事实时,只问必要信息,并先整理其余已有证据。
- 使用可用的 GitHub 连接器或 `gh`。不要为提交 Issue 安装新工具、修改仓库配置或启用额外服务。
草拟只准备正文,明确创建或提交才发布;已有授权直接执行,自动命中技能本身不授权远程写入
## 证据与去重
补充已有 Issue 先读原帖和讨论,按授权更新或追加,不重复新建;无法推断的目标或关键事实才向用户补齐
1. 读取目标仓库实际 Issue 模板和配置,区分缺陷、功能建议与协作规则提案。
本仓库功能建议有独立表单,协作规则提案允许普通 Issue;其他仓库按其配置选择入口。
2. 先搜索 open 和 closed Issues,结合关键词、错误信息与触发条件检查重复,而不只比较标题。
同时核对相关 PR 与发布记录,关联已有修复及其发布版本;仅主分支已修复时明确尚未发布。
同一问题已有记录且没有新增证据时返回已有链接;用户仍明确要求新建时按其要求处理。
不因发现重复而擅自评论、重开或关闭已有 Issue。
3. 从用户描述、当前版本与已有诊断中整理事实:区分实际结果、预期结果、稳定复现条件和未经证实的推测。
不把本地开发版本当作发生问题的部署版本;无法复现时如实说明已知条件。
引用官方行为或提出实现方案时,按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 注明来源、版本与缺口;
不把找到官方源码或证明根因作为提交症状反馈的前提。
4. 只收集定位所需信息。正文、日志片段和附件去除密钥、Cookie、令牌、代理认证、私人请求内容等敏感信息;
不直接附完整配置或请求转储。记录与复现相关的代理、TUN、证书条件,不擅自修改这些环境来收集材料。
## 按需取材
## 编写正文
- 新建 Issue 时,本仓库只读[提交 Issue](../../../CONTRIBUTING.md#提交-issue)及本次匹配的一张表单:[接口问题](../../../.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题](../../../.github/ISSUE_TEMPLATE/general-bug-report.yml)或[功能建议](../../../.github/ISSUE_TEMPLATE/feature-request.yml);协作规则提案可用普通 Issue
- 回复时读取原帖、相关讨论与支撑答复的证据,按下方回复口吻组织内容,不套用新建 Issue 的表单
- 其他仓库读取它自己的相关规则与实际模板,长文档先定位标题再截取,不预读全部模板或 CONTRIBUTING
- 搜索同场景 open/closed Issues、相关 PR 和发布记录,区分主分支修复与已发布修复,不凭标题判断重复;没有新证据时返回已有链接,用户仍要求新建则按其要求处理
- 仅当需要额外排查或修改时使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),提交症状反馈不以找到根因为前提
- 引用上游行为或提出方案时读取[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考;注明适用版本、来源与缺口
- 标题写具体症状或所需行为,不预先断言尚未证明的根因。语言与目标仓库及用户要求一致。
- YAML Issue Form 按字段顺序使用 `attributes.label` 作为 Markdown 小标题,填写相应答案。
`gh issue create --body-file` 直接提交正文,不会替你完成网页表单校验;手动核对必填字段、选项和必选确认项。
不把原始 YAML、占位文字或尚未确认的勾选项当成答案提交。
- 无法获知的信息明确写未提供或不适用,并说明理由;如果该事实关系到能否理解或复现问题,发布前补齐。
功能或规则提案说明当前场景、缺少的能力、建议结果和必要取舍,不强行填写缺陷复现步骤。
- 附件只使用用户授权发布且实际可访问的地址;本机 `/tmp/...` 路径不能作为 GitHub 附件。
没有可用附件时保留操作描述并说明缺口,不声称截图已上传。
- 使用模板规定或用户要求的 labels、assignees;先确认它们在目标仓库存在,不自行猜测。
## 正文与证据
## 提交与收尾
标题说明症状或期望行为,区分发生问题的部署版本、实测结果、预期结果与根因假设
把核对后的 UTF-8 正文写入临时文件,或直接传给连接器的结构化字段。CLI 变量先从已确认的目标与正文赋值:
功能和规则提案说明场景、缺少的能力及建议结果,无需硬套缺陷复现步骤
YAML 表单按 `attributes.label` 和字段顺序组织正文,手工核对必填项、选项与确认项,CLI 不会代做表单校验
未知信息如实标明,影响理解或复现的关键事实先补齐,不提交原始 YAML、占位答案或未确认的勾选项
日志和附件仅含定位所需的脱敏片段,不公开凭据、完整配置或请求转储,不擅自修改代理、TUN、证书环境取证
附件须获授权且可访问,本机路径不算附件;labels 和 assignees 仅按模板或用户要求设置,并确认实际存在
Issue 可记录复现、排查和变更经过,不因此向常规文档追加历史;确需改文档时执行[文档检查](../cpr-dev-guide/references/documentation.md)
## 回复口吻
草拟和发布 Issue 回复时遵守以下约定:
- 用第一人称“我”表达,称呼对方为“佬友”,称呼自然出现即可,不逐句重复,也不用“我们”“本助手”代称自己
- 语气客气、委婉,直接回应对方的具体问题;补充信息或提出不同意见时说明缘由,避免命令式、责备式表达
- 使用自然短句,简单回复用短段,只有并列信息较多时才分点;少用句号,避免客服套话、机械模板和空泛保证
- 只把实际完成的排查、复现和验证写成“我已……”;未确认的事实如实说明,委婉表达也要说清结论、限制和下一步
## 发布与收尾
使用可用连接器或 `gh`,不为提交反馈安装工具、修改仓库配置或启用服务
多行正文通过结构化字段或 UTF-8 文件传递,以下变量须先从已确认的目标与正文赋值:
```bash
gh issue create --repo "$issue_repo" --title "$issue_title" --body-file "$issue_body_file"
```
发布前快速复核目标、必填内容、脱敏及重复记录;更新已有 Issue 时保留与本次无关的内容,核对目标编号和当前正文。
网络超时或返回不明确时先查询远端是否已经创建或更新,避免重复提交。
发布后读取目标 Issue 确认仓库、标题和正文,返回链接及必要的信息缺口。
只准备草稿或发布失败时提供可直接使用的标题与正文,明确尚未提交。
发布前核对目标、必填内容、脱敏与重复记录,更新时保留无关原文;不擅自评论、重开或关闭重复 Issue
超时或结果不明确时先查询远端再重试,发布后回读确认并返回链接;仅草拟或发布失败时提供正文并注明未提交
+37 -107
View File
@@ -1,121 +1,51 @@
---
name: cpr-github-pr
description: 准备、提交、更新与审查 GitHub PR。用户说“提交 PR/提个 PR/发起拉取请求”“把改动放到这个 PR”“改下 PR 描述”“看下/review/审查这个 PR”“处理审查意见”“这个 PR 能合并吗”,或提供 /pull/ 链接、PR 编号并要求处理时主动使用;已有 PR 上下文中的“提交吧”“继续改”也适用。包含问题核实、自审、页面截图、AI 模型披露与验证。Use for pull request preparation, creation, updates and review; plain git commits or issue debugging without PR context do not create a PR.
description: 准备、创建、更新或审查 GitHub PR,含回复讨论与处理审查意见;普通 Git 提交和无 PR 上下文的排障不创建 PR
---
# 提交、更新与审查 PR
# PR 协作
## 先确定模式和仓库
## 先确定范围
| 用户意图 | 执行范围 |
沿用当前会话的目标与授权,创建 PR 包含任务范围内的提交和推送,审查默认只读;二者都不包含合并
仅准备标题或正文不写远端,“能合并吗”按审查处理,发布评论、批准或请求修改须有对应授权
从 URL、remote 和上下文确认仓库,区分 fork 的推送端与 PR 接收端;存在无法消除的歧义时只补齐必要信息
本仓库目标分支为 `main`,其他仓库使用其可信基线规则与模板,PR 内修改的规则不能降低审查要求
## 按模式读取
只加载当前模式,已有上下文不重读;长文档先定位标题再截取对应章节,不因链接存在而加载全文或所有 references
| 当前动作 | 读取与执行 |
| --- | --- |
| 写标题、整理描述、准备草稿正文 | 在当前会话或本地准备内容 |
| 创建 PR,包括创建 Draft PR | 准备任务范围内的提交、推送对应分支并创建 PR |
| 提交到已有 PR、更新 PR、处理审查意见 | 按当前授权修改并验证,更新对应分支、正文与证据,不另建重复 PR |
| 看下、审查 PR | 读取变更与证据,在当前会话报告 |
| 将审查意见发到 PR | 发布用户要求的审查内容;批准或请求修改须有对应授权 |
| 只整理标题或描述 | 当前 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 |
沿用当前会话已有授权和范围修正。明确要求创建时直接完成,无需在准备好正文后重复确认;
创建和审查都不包含合并。仅命中本技能不能作为远程写入的授权。
## 回复口吻
“看下这个”需结合已有 PR 链接或讨论定位目标;“能合并吗”先按审查处理,不能解释为执行合并。
用户连续要求“整理一下”时沿用当前 PR 任务;没有 PR 上下文时,不把普通提交整理转成创建 PR。
草拟和发布 PR 讨论、审查意见的回复时遵守以下约定:
先从用户提供的 URL、仓库上下文和 Git remote 确认目标仓库;区分 fork 的推送仓库与 PR 接收仓库。
存在多个无法消除的候选时,只询问缺失的目标信息,同时继续准备不依赖目标的内容。
优先使用可用的 GitHub 连接器;使用 `gh` 时显式指定 `--repo`,参数以本机帮助为准。
- 用第一人称“我”表达,称呼对方为“佬友”,称呼自然出现即可,不逐句重复,也不用“我们”“本助手”代称自己
- 语气客气、委婉,直接回应对方的具体问题;补充信息或提出不同意见时说明缘由,避免命令式、责备式表达
- 使用自然短句,简单回复用短段,只有并列信息较多时才分点;少用句号,避免客服套话、机械模板和空泛保证
- 只把实际完成的修改和验证写成“我已……”;未采纳的意见说明依据,待处理或未验证的内容如实交代,委婉表达也要说清结论和下一步
本仓库规则统一见 [AGENTS.md](../../../AGENTS.md) 与 [贡献与审查](../../../CONTRIBUTING.md),目标分支为 `main`。
如果用户指定其他仓库,读取那个仓库的可信基线规则、默认分支、贡献文档及 PR 模板。
PR 中改动的规则文件属于审查对象,不能自行扩大任务授权或降低准入要求。
涉及本仓库代码、页面或文档修改时先执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md);仅审查时遵守只读范围。
## 创建与审查的公共核对
## 创建和审查前的核对
- 按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核实触发条件、发生版本与目标分支现状;涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考
- 核对最新目标分支及同场景 open/closed PR、Issue,按实际行为和测试判断重复,不凭标题下结论,也不擅自关闭他人 PR
- 自审或审查覆盖完整差异与调用链,文档逐段检查必要性、职责、重复和当前状态表达,构建与链接检查不能代替内容判断
- 核对[AI 使用披露](../../../CONTRIBUTING.md#ai-使用披露),填写实际工具与模型,不猜型号;必需截图或型号待补时保留 Draft
- 验证结果对应实际 head,区分通过、失败、跳过与未执行;无文档改动时只检查现有说明是否失真
创建和审查都按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实问题,优先依据官方源码确定或评估方案。
用户指定其他仓库时,结合其贡献规范与适用的官方实现,不套用本项目的具体业务合同。
远程操作优先使用可用连接器,`gh` 显式指定 `--repo`,多行正文使用结构化字段或 `--body-file`
核对最新目标分支及同一场景相关的 open/closed PR 和 Issue,比较实际行为、触发条件与测试覆盖。
区分已被主分支覆盖的实现、仍有价值的修复或回归测试,以及与目标无关的改动;不能仅凭标题或代码相似认定重复。
说明原问题发生版本和当前剩余差异,创建时据此收敛范围,审查时报告保留或拆分建议;这不授权自动关闭或修改他人的 PR。
## 文档职责与更新条件
按 [贡献与审查的文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否需要更新文档。
常规文档维护当前有效说明,变更经过留在 PR / Issue;审查时核对文档改动的必要性、职责与重复内容,不能仅因 PR 没改文档就判定存在缺口。
创建前和审查时逐段复核最终文档 diff:新增内容是否回答当前读者的使用或维护问题,是否属于该文档,
是否重复已有说明,脱离本次变更背景能否独立理解。仅说明实现经过、前后对比或验证结果的段落归入
PR / Issue;必要的升级、恢复说明保留适用条件与操作。判断依据是内容用途,不按“新增”“不再”等词机械判错。
## 页面证据与 AI 披露
- 页面变更执行 [界面验证](../../../CONTRIBUTING.md#界面验证):现有页面提供实际前后截图,新增页面提供完成态与受影响状态截图,正文嵌入或链接可访问的图片并标明对应提交。截图能看清原设计的复用与变化,Popover 等浮层补充展开状态。
- 截图随视觉或交互修改更新。预览步骤、录屏和构建日志只能补充证据,不能代替截图;无法运行或上传时如实记录缺口,创建或保持 Draft,不能用本机路径或占位图片伪装为已提供。
- 按 [AI 使用披露](../../../CONTRIBUTING.md#ai-使用披露) 记录实际使用的工具与模型型号,多个模型分别列出。不能猜测模型、把工具名当型号或漏掉更新阶段使用的模型;未知型号作为待补项。
## 创建或更新 PR
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
不用提交日志拼接正文,也不假定仓库一定有模板。
2. 更新并确认 base/head,查看相对 merge-base 的完整差异和待提交内容;核对仓库的受保护文件与准入规则。
只纳入任务范围的文件和提交;需要整理时用独立功能分支,保留用户其他改动,不擅自改写已发布历史。
3. 按上述核对流程,在目标分支基线核实问题,追踪官方与本项目的相关调用链,先确定解决方向再实现。
若改动已完成,也要据此复核问题与方案;需要修正时只修改已授权的范围。仅整理标题或正文时报告差异与缺口,
不自行修改实现。原问题不成立时不将它包装成已修复的缺陷;证据不足时明确保留结论。
按 `cpr-dev-guide` 完成实现自审;后端按 [后端自审](../../../docs/architecture.md#后端自审) 核对归属、状态、失败出口与副作用,
页面先复用已有设计并检查文案密度。处理本次引入的冗余与越界问题,功能可用和检查通过不能替代可维护性自查。
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
更新现有标题或正文须在本次整理/更新请求范围内。
5. 按贡献文档执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
按上述文档复核步骤检查最终 diff;没有文档差异时,核对行为变化是否使现有说明失真。
页面改动按上述规则补齐截图与实际交互证据,核对模型披露是否完整。
6. 将任务范围内的提交推送到正确远端,核对远端 head。标题遵循项目约定,正文先说明问题成立的依据和最终行为,
再给必要的设计取舍、自审结论、验证与遗留事项,按模板附页面截图和 AI 使用情况。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
本次实现与它的关系及必要的偏离原因;区分源码推断和实际复现,不用检查通过替代方案论证。按规模压缩,不强填无关章节。
7. 明确要求草稿或仍有阻塞项时创建或保持 Draft PR,并解释原因;必需截图或 AI 模型披露待补时也不标为可审阅。具备审查条件且未要求草稿时创建普通 PR。
GitHub Draft 状态与“只写本地草稿正文”不同,后者不创建远程对象。
8. 创建或更新后回读正文与附件,核对链接、base、head、草稿状态、模型披露和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
CLI 示例中的变量必须先根据上述检查赋值;`pr_body_file` 是精确写入 UTF-8 正文的临时文件:
```bash
gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
--title "$pr_title" --body-file "$pr_body_file"
```
按需添加 `--draft`。不要用 `gh pr create --dry-run` 做只读预览,它仍可能推送 Git 变更。
多行正文通过文件或连接器的结构化字段传递,不把未转义正文拼进 shell 命令。
创建完成返回链接、目标分支和验证摘要;不要称为已合并或已获维护者批准。
## 审查 PR
1. 获取 PR 的 base/head SHA、描述、文件差异、已有讨论及 CI 状态。记录实际审查的 head,使用 merge-base 差异。
需要源码上下文时先遵守目标仓库的定位规则;需检出时使用隔离工作区,保留用户当前分支和运行环境。
2. 先核实 PR 要解决的问题是否成立:提取触发场景、预期行为和声称的原因,在目标分支基线检查实际行为。
按上述核对流程收集一手证据,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
3. 从官方方案评估解决方向:说明官方如何处理同一场景,检查 PR 是否遵循其协议语义和职责归属、
是否复用已有能力或正确传递官方合同;核对偏离官方方案的理由与运行证据。
审查 OpenAI 官方上游与 Codex 的对接时,默认官方返回与 Codex 兼容、直接对接正常,重点检查本项目是否误解、
改写或破坏官方合同。不得仅凭自行构造的异常上游数据揣测官方不兼容,也不据此报告缺陷、提出改进建议,
或要求增加防御分支与测试。讨论特定版本或场景的对接例外须有真实响应或实际复现证据,并区分代理链路与官方直连。
4. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
不能仅因功能可用、CI 通过而略过,也不把个人写法偏好判为缺陷。
5. UI 改动同时对照原页面、主题、共用组件和同类页面,核对结构变化的必要性、文案是否重复及帮助是否适合收进 Popover。
按页面类型检查必需截图是否齐全、可访问且对应待审实现;缺少必需截图时要求补齐并列为未具备审阅条件。
浏览器可用时检查相关交互;未运行的检查列为验证缺口,不从截图或源码推断所有状态都正确。
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
按上述文档复核步骤检查最终 diff,不能把构建或链接检查通过当作文档内容已经合格。
核对 AI 使用与模型披露,缺项明确指出。工具不可用、集成测试跳过或环境不足时具体说明影响范围。
7. 按 [审查标准](../../../CONTRIBUTING.md#审查标准) 用中文先回答原问题是否成立、解决方向是否合理,
再列出实现问题与验证缺口,结论附具体依据。
实现问题区分必须修复项、可选建议和验证缺口;没有的类别可省略。
每个问题给变更中的文件位置、触发条件或规范依据、影响和最小修复方向。
复用建议指出具体已有实现,不把个人偏好写成缺陷;未发现问题时直接说明并交代覆盖范围。
8. 结束前核对 head 是否变化。发生变化时检查新增差异并更新结论,或明确结论只适用于原 SHA。
审查本身不修改源码、不发表评论、不提交 GitHub 的批准或请求修改状态。
用户明确要求发布时,先复核当前 head 和重复评论,再通过结构化字段或 `--body-file` 发布对应内容;
需要绑定具体提交时使用支持 commit ID 的审查接口。不要在旧提交结论上批准新的 head。
返回链接或会话内结论,说明实际范围与缺口,不将检查通过称为维护者批准
@@ -0,0 +1,35 @@
# 创建或更新 PR
仅在创建、推送或更新 PR 时读取,先完成[入口](../SKILL.md)的范围判断与公共核对
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
不用提交日志拼接正文,也不假定仓库一定有模板。
2. 更新并确认 base/head,查看相对 merge-base 的完整差异和待提交内容;核对仓库的受保护文件与准入规则。
只纳入任务范围的文件和提交;需要整理时用独立功能分支,保留用户其他改动,不擅自改写已发布历史。
3. 按入口的公共核对,在目标分支基线核实问题,追踪官方与本项目的相关调用链,先确定解决方向再实现。
若改动已完成,也要据此复核问题与方案;需要修正时只修改已授权的范围。仅整理标题或正文时报告差异与缺口,
不自行修改实现。原问题不成立时不将它包装成已修复的缺陷;证据不足时明确保留结论。
按 `cpr-dev-guide` 完成实现自审;后端按 [后端自审](../../../../docs/architecture.md#后端自审) 核对归属、状态、失败出口与副作用,
页面先复用已有设计并检查文案密度。处理本次引入的冗余与越界问题,功能可用和检查通过不能替代可维护性自查。
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
更新现有标题或正文须在本次整理/更新请求范围内。
5. 按[验证](../../../../CONTRIBUTING.md#验证)执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
有文档差异时按[文档检查](../../cpr-dev-guide/references/documentation.md)检查最终 diff;没有文档差异时,核对行为变化是否使现有说明失真。
页面改动按入口链接的界面验证要求补齐截图与实际交互证据,核对模型披露是否完整。
6. 将任务范围内的提交推送到正确远端,核对远端 head。标题遵循项目约定,正文先说明问题成立的依据和最终行为,
再给必要的设计取舍、自审结论、验证与遗留事项,按模板附页面截图和 AI 使用情况。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
本次实现与它的关系及必要的偏离原因;区分源码推断和实际复现,不用检查通过替代方案论证。按规模压缩,不强填无关章节。
7. 明确要求草稿或仍有阻塞项时创建或保持 Draft PR,并解释原因;必需截图或 AI 模型披露待补时也不标为可审阅。具备审查条件且未要求草稿时创建普通 PR。
GitHub Draft 状态与“只写本地草稿正文”不同,后者不创建远程对象。
8. 创建或更新后回读正文与附件,核对链接、base、head、草稿状态、模型披露和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
CLI 示例中的变量必须先根据上述检查赋值;`pr_body_file` 是精确写入 UTF-8 正文的临时文件:
```bash
gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
--title "$pr_title" --body-file "$pr_body_file"
```
按需添加 `--draft`。不要用 `gh pr create --dry-run` 做只读预览,它仍可能推送 Git 变更。
多行正文通过文件或连接器的结构化字段传递,不把未转义正文拼进 shell 命令。
创建完成返回链接、目标分支和验证摘要;不要称为已合并或已获维护者批准。
@@ -0,0 +1,33 @@
# 审查 PR
仅在审查 PR 时读取,执行[入口](../SKILL.md)的公共核对,默认只在当前会话报告
1. 获取 PR 的 base/head SHA、描述、文件差异、已有讨论及 CI 状态。记录实际审查的 head,使用 merge-base 差异。
需要源码上下文时先遵守目标仓库的定位规则;需检出时使用隔离工作区,保留用户当前分支和运行环境。
2. 先核实 PR 要解决的问题是否成立:提取触发场景、预期行为和声称的原因,在目标分支基线检查实际行为。
按入口的公共核对收集一手证据,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
3. 从官方方案评估解决方向:说明官方如何处理同一场景,检查 PR 是否遵循其协议语义和职责归属、
是否复用已有能力或正确传递官方合同;核对偏离官方方案的理由与运行证据。
审查 OpenAI 官方上游与 Codex 的对接时,默认官方返回与 Codex 兼容、直接对接正常,重点检查本项目是否误解、
改写或破坏官方合同。不得仅凭自行构造的异常上游数据揣测官方不兼容,也不据此报告缺陷、提出改进建议,
或要求增加防御分支与测试。讨论特定版本或场景的对接例外须有真实响应或实际复现证据,并区分代理链路与官方直连。
4. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
不能仅因功能可用、CI 通过而略过,也不把个人写法偏好判为缺陷。
5. UI 改动同时对照原页面、主题、共用组件和同类页面,核对结构变化的必要性、文案是否重复及帮助是否适合收进 Popover。
按页面类型检查必需截图是否齐全、可访问且对应待审实现;缺少必需截图时要求补齐并列为未具备审阅条件。
浏览器可用时检查相关交互;未运行的检查列为验证缺口,不从截图或源码推断所有状态都正确。
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
有文档差异时按[文档检查](../../cpr-dev-guide/references/documentation.md)检查最终 diff,不能把构建或链接检查通过当作文档内容已经合格。
核对 AI 使用与模型披露,缺项明确指出。工具不可用、集成测试跳过或环境不足时具体说明影响范围。
7. 按 [审查标准](../../../../CONTRIBUTING.md#审查标准) 用中文先回答原问题是否成立、解决方向是否合理,
再列出实现问题与验证缺口,结论附具体依据。
实现问题区分必须修复项、可选建议和验证缺口;没有的类别可省略。
每个问题给变更中的文件位置、触发条件或规范依据、影响和最小修复方向。
复用建议指出具体已有实现,不把个人偏好写成缺陷;未发现问题时直接说明并交代覆盖范围。
8. 结束前核对 head 是否变化。发生变化时检查新增差异并更新结论,或明确结论只适用于原 SHA。
审查本身不修改源码、不发表评论、不提交 GitHub 的批准或请求修改状态。
用户明确要求发布时,先复核当前 head 和重复评论,再通过结构化字段或 `--body-file` 发布对应内容;
需要绑定具体提交时使用支持 commit ID 的审查接口。不要在旧提交结论上批准新的 head。
+26 -71
View File
@@ -1,85 +1,40 @@
---
name: cpr-plugin-dev
description: 开发、排查和打包 Codex Proxy RS 网关插件。用户说“写/做一个网关插件”“给插件加能力或页面”“改 plugin.json”“接入公开 SDK”“插件不生效/接入报错”“打包插件”时主动使用,覆盖能力选择、SDK 处理器、管理页面和安装包验证。Use for gateway plugin authoring and debugging; host Runtime/installer changes use cpr-dev-guide, not this workflow, and OpenAI .codex-plugin or Vue plugins are outside its scope.
description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改宿主 Runtime、安装器或管理端时使用 cpr-dev-guide,不适用于 Codex 插件或 Vue 插件
---
# cpr-plugin-dev
# 网关插件开发
面向插件作者。插件在独立项目中实现,通过公开 SDK 与宿主通信,不向宿主业务模块添加分支。
仅修改宿主安装器、Runtime 或管理端时,使用仓库开发指南,不按本技能创建插件工程。
本仓库开发与排查同时执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),准备、提交或审查插件 PR 时执行 [cpr-github-pr](../cpr-github-pr/SKILL.md)。
插件在独立项目中通过公开 SDK 实现业务,不向宿主业务模块添加专属流程
## 先建立正确的模型
同时使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的对应任务分支,已加载则复用,不递归重读
```text
选择能力 → 作者清单 + SDK 处理器 → 构建二进制/页面 → cpr-plugin package
↓
实际业务验证 ← 查看权限并安装、自动准备配置 ← tar.gz + sha256
```
## 范围与合同
- `contributes` 声明提供什么,`permissions` 声明访问域;安装接受记录派生授权,`bindings` 只决定功能挂载和请求匹配
- 安装包、运行配置和已启用功能不是同一件事;安装成功不能代替功能验证
- `trustedProcess` 与宿主使用相同系统身份,不是操作系统沙箱
- 确认用途、工程位置、目标宿主版本与平台,沿用已有工程;位置无法推断时只补齐该信息,不把插件源码塞进宿主 workspace
- 核对运行宿主、[SDK 版本](../../../backend/crates/gateway-plugin/sdk/Cargo.toml)、[宿主支持清单](../../../backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json)及所用 CLI,清单、进程和能力版本分别判断
- `contributes` 是能力,`permissions` 是访问域,`bindings` 是挂载与匹配范围;安装成功不等于功能生效,`trustedProcess` 也不是操作系统沙箱
- 涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考;宿主能力以目标版本的 SDK 为准
## 开始工作
## 按当前步骤读取
1. 确认插件用途、已有工程/输出目录、目标宿主版本与平台。已有项目沿用其结构;没有指定工程时先确认目录,不把插件源码塞进宿主 workspace
2. 先读 [SDK 入口](../../../backend/crates/gateway-plugin/sdk/README.md) 和[清单](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md),再按下表只选本次需要的能力章节
3. 核对实际运行宿主的版本、目标版本的 [SDK Cargo.toml](../../../backend/crates/gateway-plugin/sdk/Cargo.toml) 与[宿主支持清单](../../../backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json)。同时确认打包 CLI 使用相同作者清单合同;不要只看源码版本或依赖包版本号。清单版本、通信版本、能力版本各自独立
4. 需要构建、联调或交付时读取 [构建与验证](references/development.md),先检查实际依赖和脚本,不假设包已公开发布;通过 GitHub 交付时另读其中的[发布与安装来源](references/development.md#发布与安装来源)
只读取命中行与链接的指定章节,长文档先列标题再截取;不预读全部 SDK、示例、前端和打包资料
本技能相对链接以各文件所在目录解析;主仓根目录是本文件所在目录向上三级。插件工程或同级示例不在磁盘上时,请用户提供位置或使用选定版本的公开资料,不猜开发机绝对路径,也不自动克隆或创建远程仓库。
## 按任务选择能力
| 用户要做什么 | 优先使用 | 需要读取的合同 |
| --- | --- | --- |
| 改写请求/响应、处理流式内容、协议转换 | `middleware` | [洋葱中间件](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#洋葱中间件) |
| 改变模型选择或账号排序 | `model_router` / `scheduler` | [策略类型](../../../backend/crates/gateway-plugin/sdk/src/call/policy/mod.rs),宿主继续复验范围、资格和租约 |
| 发布模型别名 | `model_catalog` | [模型目录](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#模型目录),注册时冻结直接目标,不需要请求绑定 |
| 读取基础数据制作管理页面 | `management` + `data` 权限 | [基础事实](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#基础事实),不需要账号凭据授权 |
| 选择宿主允许的重试动作 | `retry_policy` | [重试决策](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#重试决策),不能修改预算或自行重复调用 next |
| 统计成功失败、用量或观察 WS 帧 | `request_lifecycle` / `usage` / `web_socket_observer` | [路由、调度与观察](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#路由调度与观察),观察不改写业务结果 |
| 接受外部客户端身份 | `frontend_authentication` | [数据面入口认证](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#数据面入口认证),映射现有 Client Key,不接管后台登录 |
| 增加插件页面与管理操作 | `management` | [管理页面、公开入口与 CLI](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#管理页面公开入口与-cli) |
| 提供终端命令 | `command_line` | [管理页面、公开入口与 CLI](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#管理页面公开入口与-cli) |
只声明实现了的能力。Provider 固定为宿主内置的 OpenAI 与 xAI;插件只能扩展已开放的处理与管理能力。
网络、账号、模型调用和私有状态属于宿主回调与授权,不重复声明为顶层能力。
## 实现时的关键选择
### 清单与处理器
- `publisher` 与机器短名 `name` 派生插件 ID,`displayName` 用于展示;复制示例后同时调整扩展项 ID 与注册结果
- `contributes` 每种能力最多一项,普通贡献项可省略派生 ID、能力版本和固定阶段;中间件阶段仍需显式选择。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
- 作者清单用 `Manifest::from_author_slice` 规范化,不手写 `package`、固定阶段或重复注册;CLI 生成平台、协议与资源摘要,版本范围不能使用全版本通配
- 安装后宿主会按 `configurationSchema` 准备默认配置,配置完整即可启用;默认绑定的空范围不限制请求。需要业务参数时声明真实必填项,通过 `secretFields` 声明敏感字段,不依赖用户再走一遍自建安装向导
- Rust 插件只依赖公开 `gateway-plugin-sdk`;需要异步会话辅助时开启 `io`,使用 `PluginSession` 管理握手、回调关联、流控、取消和关闭
- 优先用 `PluginBuilder::from_json` 组合 `.middleware`、`.management`、`.command_line` 与 `.on(methods::..., handler)`,由构建器生成注册并检查处理器;单一中间件也可用 `MiddlewarePlugin`
- stdout 是二进制协议通道,不能用 `println!` 输出诊断。使用基础设施 `host.log`,不输出 secret 或完整请求
### 中间件与宿主服务
- `request` 包裹整个逻辑请求,`attempt` 在每次选定账号的尝试中执行;根据作用范围选择,避免重试时重复副作用
- `next` 只能消费一次。使用 SDK 的正文保留与流式映射能力,不自行拼一套 SSE/取消/流控机制;请求、响应及策略处理使用 `requests` 域
- 持久化状态使用已声明命名空间的 `host.state.*`,账号读写使用 `host.auth.*`,不直接访问宿主数据库。见[账号与凭据](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#账号与凭据)和[状态、日志与迁移](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#状态日志与迁移)
- 权限只声明 `network`、`models`、`accounts`、`data`、`requests`、`public_endpoints`;日志和自身状态无需授权项。安装器不配置 Key/账号白名单,资源选择属于插件业务
- 模型、网络和亲和查询使用当前父调用的受管回调。管理/CLI 等独立入口调用模型时传明确的 Key ID;请求处理阶段继承父身份。见[Key、模型与模型调用](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#key模型与模型调用)
- SDK 操作名不是管理员 HTTP 路由,不能据此拼接口地址;宿主身份、账单事实和调用授权也不能由插件覆盖
- 路由与调度只读取宿主投影的请求事实,身份头可能被隐藏;使用已有会话和亲和合同,不依赖原始认证头或根据缓存键补造客户端身份
### 只有需要页面时才创建前端
- 沿用已有插件页面与 [管理端主题](../../../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 实例
- 管理路由的响应正文由插件业务定义,不默认套用宿主管理 API 的 `{ code, message, data }`;页面与处理器共享实际合同,GET 无正文时不附带 JSON 正文声明
- `host.model.*` 子请求跳过发起插件,不能用它证明本插件中间件/路由已参与;需要完整请求链的示例使用页面模型桥或真实客户端
- JS、CSS、图标等资源随包构建和声明;沿用宿主桥的主题同步。Vite 本地预览不等于已安装插件的热更新,宿主验证仍需重新构建、打包和切换版本
| 当前工作 | 入口 |
| --- | --- |
| 选择能力或确认能力缺口 | [能力与方法](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)、所需能力章节、[插件职责边界](../cpr-dev-guide/references/plugin-boundaries.md) |
| 清单与 Rust 处理器 | [实现参考](references/implementation.md)、所用 SDK 类型;只有修改清单字段时读[清单](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md)对应章节 |
| 创建或修改页面 | [页面参考](references/frontend.md),不需要页面的插件不创建前端 |
| 准备工程或联调依赖 | [工程与依赖](references/development.md#工程与依赖),需要示例时再读[复用示例](references/development.md#复用示例) |
| 构建或打包 | [构建与打包](references/development.md#构建与打包) |
| 实际验收 | [验证与交付](references/development.md#验证与交付),按实现范围验证 |
| 分发插件 | [发布与安装来源](references/development.md#发布与安装来源),不自动进入宿主发版流程 |
| 修改插件文档 | [文档检查](../cpr-dev-guide/references/documentation.md),只写当前用法、合同和限制,不附修复过程 |
## 交付
按[构建与验证](references/development.md#验证与交付)提供源码位置、能力/权限/绑定摘要、实际运行结果及验证缺口。
开发插件不默认授权安装并启用本机进程、调用真实上游、发布依赖或推送仓库;超出当前授权时交付可安装包及操作步骤。
说明源码与产物位置、兼容范围、能力与权限、实际验证及缺口,只报告真正生成的安装包或完成的业务验证
修改源码不默认授权在目标实例安装启用、调用真实上游或发布,沿用用户当前授权判断后续动作
PR 任务才使用 [cpr-github-pr](../cpr-github-pr/SKILL.md),文档检查按开发入口收尾执行
@@ -1,6 +1,7 @@
# 构建、联调与验证
仅在准备工程、构建或验证插件时读取。这里给出执行入口,不复制 SDK 协议或安装 API 的字段手册。
仅在准备工程、构建或验证插件时读取对应章节,分发规则只在准备分发时读取
这里给出执行入口,不复制 SDK 协议或安装 API 的字段手册
## 工程与依赖
@@ -11,11 +12,11 @@
| [codex-proxy-plugins](https://github.com/zyycn/codex-proxy-plugins) 的 `examples/workbench/` | 基础能力体验与文本工作流示例,按需选择功能,不整体复制 |
| [codex-proxy-ui](https://github.com/zyycn/codex-proxy-ui) | 可选的 Vue UI 库、组件文档与 playground |
先检查用户给定工程,或主仓父目录下的同级仓库是否存在;读取实际仓库约定、README、`Cargo.toml`、`package.json` 与锁文件。
先检查用户给定工程、`modules/plugins` 子模块或同级仓库是否存在,读取实际仓库约定及当前步骤所需的 README 章节、依赖与锁文件,不预读无关前端配置
只需中间件时不复制示例的管理 API、页面和相关权限;CLI 等能力从 SDK 对应合同起步,不给示例添加无关能力。
本地源码联调可以使用 Cargo 路径依赖和 pnpm 的包级链接/override;路径必须真实可解析,并记录在消费方依赖配置中。
这不等于允许从业务源码跨仓 `import .../src/...`,也不能证明第三方能独立安装。
本仓库子模块联调使用[开发文档](../../../../docs/development.md#源码联调)中的 source 模式和本地 SDK 验证入口,不改写正式依赖或锁文件
其他工程按其自身联调配置临时使用包级链接或 override,路径须真实可解析,不从业务源码跨仓 `import .../src/...`
对外交付前核实 UI/SDK 的可用版本和安装来源:仅引用确实存在的发布版本,或包含所需合同的固定 Git commit;
不要把浮动 `main` 当版本,也不要自动发布依赖以补齐条件。本地 override 尚未移除时,明确说明独立安装缺口。
@@ -23,7 +24,7 @@
## 复用示例
找到 `codex-proxy-plugins` 后先读根目录与 `examples/workbench/README.md`。
找到 `codex-proxy-plugins` 后,只读根目录约定与 `examples/workbench/README.md` 中当前能力或构建所需的章节
该仓库使用 `bash scripts/package` 构建页面、Rust 二进制并打包;前端依赖和工具配置位于示例的 `frontend/`,根目录不维护 Node 工程。执行前核对脚本适用的插件路径和目标平台。
| 任务 | 示例入口 |
@@ -0,0 +1,11 @@
# 插件页面
仅在创建或修改插件页面时读取,同时使用[开发入口](../../cpr-dev-guide/SKILL.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 实例
- 管理路由的响应正文由插件业务定义,不默认套用宿主管理 API 的 `{ code, message, data }`;页面与处理器共享实际合同,GET 无正文时不附带 JSON 正文声明
- `host.model.*` 子请求跳过发起插件,不能用它证明本插件中间件/路由已参与;需要完整请求链的示例使用页面模型桥或真实客户端
- JS、CSS、图标等资源随包构建和声明;沿用宿主桥的主题同步。Vite 本地预览不等于已安装插件的热更新,宿主验证仍需重新构建、打包和切换版本
@@ -0,0 +1,24 @@
# 清单与处理器实现
仅在编写或排查插件清单、Rust 处理器时读取,涉及 Rust 时使用[开发入口](../../cpr-dev-guide/SKILL.md)的 Rust 分支
选择能力时只读 SDK 的[能力与方法](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)及所需能力章节
## 清单与处理器
- `publisher` 与机器短名 `name` 派生插件 ID,`displayName` 用于展示;复制示例后同时调整扩展项 ID 与注册结果
- `contributes` 每种能力最多一项,普通贡献项可省略派生 ID、能力版本和固定阶段;中间件阶段仍需显式选择。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
- 作者清单用 `Manifest::from_author_slice` 规范化,不手写 `package`、固定阶段或重复注册;CLI 生成平台、协议与资源摘要,版本范围不能使用全版本通配
- 安装后宿主会按 `configurationSchema` 准备默认配置,配置完整即可启用;默认绑定的空范围不限制请求。需要业务参数时声明真实必填项,通过 `secretFields` 声明敏感字段,不依赖用户再走一遍自建安装向导
- Rust 插件只依赖公开 `gateway-plugin-sdk`;需要异步会话辅助时开启 `io`,使用 `PluginSession` 管理握手、回调关联、流控、取消和关闭
- 优先用 `PluginBuilder::from_json` 组合 `.middleware`、`.management`、`.command_line` 与 `.on(methods::..., handler)`,由构建器生成注册并检查处理器;单一中间件也可用 `MiddlewarePlugin`
- stdout 是二进制协议通道,不能用 `println!` 输出诊断。使用基础设施 `host.log`,不输出 secret 或完整请求
## 中间件与宿主服务
- `request` 包裹整个逻辑请求,`attempt` 在每次选定账号的尝试中执行;根据作用范围选择,避免重试时重复副作用
- `next` 只能消费一次。使用 SDK 的正文保留与流式映射能力,不自行拼一套 SSE/取消/流控机制;请求、响应及策略处理使用 `requests` 域
- 持久化状态使用已声明命名空间的 `host.state.*`,账号读写使用 `host.auth.*`,不直接访问宿主数据库。见[账号与凭据](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#账号与凭据)和[状态、日志与迁移](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#状态日志与迁移)
- 权限从目标 SDK 的[访问域](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#访问域)选择,不在技能中维护枚举副本;日志和自身状态无需授权项,安装器不配置 Key/账号白名单,资源选择属于插件业务
- 模型、网络和亲和查询使用当前父调用的受管回调。管理/CLI 等独立入口调用模型时传明确的 Key ID;请求处理阶段继承父身份。见[Key、模型与模型调用](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#key模型与模型调用)
- SDK 操作名不是管理员 HTTP 路由,不能据此拼接口地址;宿主身份、账单事实和调用授权也不能由插件覆盖
- 路由与调度只读取宿主投影的请求事实,身份头可能被隐藏;使用已有会话和亲和合同,不依赖原始认证头或根据缓存键补造客户端身份
+28 -100
View File
@@ -1,110 +1,38 @@
---
name: cpr-release
description: 准备、执行和排查 Codex Proxy RS 发版。用户说“发个版本/正式发版”“准备 vX.Y.Z”“推荐版本号”“整理更新日志/发布说明”“更新 release/notes.md”“发布 alpha/beta/rc/exp/实验版”“打发行 tag”“重试失败发版”时主动使用;已有发版上下文中的“发布吧”“继续发布”也适用。Use for release planning, notes, publishing and recovery; deployment/restarts and ordinary Git tag cleanup are not release requests.
description: Codex Proxy RS 的版本规划、发布说明、正式或预发行发布与失败恢复;部署重启和普通 Git tag 清理不属于发版
---
# 发版
## 识别任务范围
## 范围与授权
| 用户意图 | 执行范围 |
沿用用户确定的版本、范围与授权,准备版本或询问能否发布不执行远程写入;明确发布时完成已授权步骤,无需重复确认
仅打 tag 不自动扩展为完整发版,发布也不包含运行实例升级或合并实验功能
稳定版与 alpha、beta、rc 从 `main` 发布,exp 从对应 `experimental/<用途>` 分支发布
其他仓库使用其发布入口与规则,不套用本仓库路径;规则已在上下文中时不重复读取
## 按任务读取
只读当前阶段和指定章节,先定位标题再截取,不预读整份 CONTRIBUTING、工作流或所有参考文件;实际发布前仍须核对发布入口的完整副作用
| 任务 | 读取范围 |
| --- | --- |
| 推荐版本号 | 根据本次变更与已有版本给出建议及理由 |
| 整理更新日志、写 note、准备发版 | 核对变更、编写说明、检查发布条件;未要求提交时保留本地改动 |
| 正式发布、发布指定版本、打版本 tag | 在已授权范围内执行对应步骤;仅打 tag 的请求不自动扩展为完整发版 |
| 发布 alpha、beta、rc | 从 `main` 发布,确定目标正式版本和阶段,核对预发行标记与晋级路径 |
| 发布实验版、exp 版本 | 从目标实验分支发布,按下方实验版流程确定版本、文案和渠道隔离 |
| 重试失败的发版 | 核对上次执行状态,从未完成的步骤恢复 |
| 推荐版本号 | [基线与版本](references/preparation.md#基线与版本) |
| 编写或翻译发布说明 | [基线与版本](references/preparation.md#基线与版本)、[发布说明](references/preparation.md#发布说明) |
| 检查发行条件 | [发行准备检查](references/preparation.md#发行准备检查) |
| 实际发布 | 完成准备,再读[通道核对](references/operations.md#发行通道核对)和[执行发版](references/operations.md#执行发版) |
| 失败恢复或验收 | [恢复与验收](references/operations.md#恢复与验收),先确定已完成步骤,不直接重跑入口 |
| 实验版 | 在对应阶段补读[实验流程](references/experimental.md)的版本、文案或验收章节 |
| 修复代码、工作流或常规文档 | 使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),有 PR 任务再使用 [cpr-github-pr](../cpr-github-pr/SKILL.md) |
沿用当前会话明确的版本、范围与授权;用户已授权发布目标版本且条件满足时直接执行,不重复确认。
“准备好了”“可以发版吗”属于准备或检查,不能解释成正式发布;创建本技能也不触发发版。
发行版发布与运行实例升级分开判断,“上线”需结合上下文确定用户指哪一种。
需要修复本仓库代码、工作流或文档时先执行 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),准备或更新相关 PR 时执行 [cpr-github-pr](../cpr-github-pr/SKILL.md),不以发版步骤替代开发自审。
## 必须保持的边界
稳定版以及面向下一正式版本的 alpha、beta、rc 从 `main` 发布,预发行 tag 使用 `vX.Y.Z-<阶段>.N`。
发版前读取[版本命名与升级规则](../../../deploy/README.md#版本命名与升级规则),作为命名、阶段晋级和更新边界的权威约定。
实验版从对应的 `experimental/<用途>` 分支发布,版本与 tag 采用 `vX.Y.Z-exp.N`,
展示标题包含分支用途名;先读 [实验版发布](references/experimental.md) 确定标题与编号,再复用本文的准备、执行与验收步骤。
实验功能是否合入 `main` 是单独的维护决策,发布实验版不包含合并功能的授权。
## 读取当前流程
先读 [AGENTS.md](../../../AGENTS.md) 与 [贡献与审查](../../../CONTRIBUTING.md),再按任务检查:
| 事实 | 权威入口 |
| --- | --- |
| 当前版本 | [release/version.yaml](../../../release/version.yaml) |
| 本次发布说明及语言约定 | [release/notes.md](../../../release/notes.md)、贡献文档的项目约定 |
| 版本提交、tag、推送与工作流触发 | [release/publish](../../../release/publish) |
| 构建、检查、GitHub Release 和镜像发布 | [发布工作流](../../../.github/workflows/release.yml) |
| 平台与产物清单 | [release/platforms.yaml](../../../release/platforms.yaml) |
命令从仓库根目录执行;每次以当前脚本和工作流为准,不另写发布脚本或绕过已有质量检查。
用户明确指定其他仓库时先读取其发布流程,不能直接套用这里的路径和命令。
## 发行通道核对
- alpha 是下一正式版本的早期开发阶段,beta 是功能测试阶段,rc 是发布候选,exp 是临时功能实验。
预发行采用 `vX.Y.Z-<阶段>.N`,`N` 从 1 开始;正式版不带预发行后缀,不新增未约定的通道名称。
- 更新候选由本次检查或安装选择的通道决定:Stable 仅正式版,RC 包含正式版,Beta 包含 RC 与正式版,Alpha 包含所有常规阶段。
仅接收同一大版本内严格更高的版本,可跟随后续小版本与补丁版本,默认按运行版本推导通道;临时选择不保存、不允许降级。
exp 仅接收同基线、同一轮实验的后续编号,不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。
- 核对目标工作流的构建类型:正式版及 alpha、beta、rc 为 `release`,exp 为 `experimental`。
所有预发行均标记为 Pre-release,不设置 GitHub Latest、不覆盖镜像 `latest`。
发布预发行前记录这两个稳定入口,发布后回读确认未改变。
- 验收更新器时,核对检查更新与执行更新使用相同规则。只有当前构建支持更新且存在允许的更高目标时
才能返回 `hasUpdate=true`;被禁止的目标即使版本领先,也不能出现在更新标记、可用版本或发布说明中。
检查失败须单独报告。核对上一发行实际包含的更新能力,不能因新包支持在线更新就宣称旧包也能在线安装它。
## 准备版本与说明
1. 确认发布类型、对应分支、upstream、目标仓库、工作区和完整待推送范围;
未提交或未跟踪内容分别核对,不因脚本只检查已跟踪文件就认定所有内容都已纳入发布。
2. 读取版本文件、已有 tag 和实际发行记录,确认上一发布基线与本次目标提交。
根据基线到目标的提交和最终差异归纳变更,剔除回退、重复和未进入发布范围的内容。
不能只用当前版本文件或最近几条提交判断“本次更新”。
3. 使用用户指定或当前会话已确定的目标版本;需要建议时按兼容性、能力新增和修复范围说明理由。
目标 tag 必须未被占用;遇到已有同名版本先判断是重试还是新发版,不自动递增版本掩盖冲突。
4. 按贡献文档维护 `release/notes.md`。正文写中文,保留版本号和必要技术标识;
按用户可感知的最终行为归并,不逐条翻译 commit,也不把排查过程或未验证承诺写入说明。
可按新增功能、问题修复、体验优化、升级说明组织,省略没有内容的章节。
5. 替换为本次版本说明,首行与目标 tag 一致且正文非空。升级事项应能追溯到实际迁移、配置或兼容变化;
敏感信息脱敏,未合入功能不宣称已发布。已有准确说明仅需翻译时保持其事实和范围。
6. 按贡献规范检查变更与现有 CI 证据,明确通过、失败、跳过和未执行。
涉及数据库迁移时,对照同一大版本的上一正式 Release 及本轮预发行/实验线的已发布版本检查迁移是否保持不变,并验证升级路径;
跨大版本按项目部署约定判断,不默认支持在线升级。区分未发布迁移的整理、已执行这些迁移的源码部署实例,
以及 CI 实际采用的冻结基线。未发版不代表迁移从未被执行,CI 冻结检查失败也不等于正式版数据库升级失败;
按各自证据报告兼容性与检查结果,不因未发版而自动放宽现有冻结规则。
只修改说明时无需重建前后端;实际发版仍要跟踪发布工作流针对目标提交的检查与产物。
## 执行发版
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
本任务需要的提交按贡献文档保留英文 Conventional Commits 与相应署名。
在准备完成且用户已授权发布后,将 `release_version` 设为已确定的版本,执行:
```bash
release/publish "$release_version"
```
该命令会创建包含版本号和发布说明的单次提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
只调整已发布页面的标题或正文时,编辑对应 GitHub Release 的 `name` 或 `body` 并回读核对;
保持已有 tag、源码提交、附件和预发行状态,不重新执行发布入口。
## 恢复与验收
- 脚本中断或网络超时时,先核对本地提交与 tag、远端分支与 tag、对应工作流 run 及 Release,确定已完成到哪一步。
若本地已创建版本提交或 tag,不直接重跑入口,也不通过删 tag、回退版本或强推来消除报错。
- 提交和 tag 已推送但工作流未触发时,核对目标后使用脚本提示的 dispatch 命令补触发;
工作流已存在则跟踪或恢复对应 run,避免重复发布。发现 tag 指向不同提交时报告冲突,不移动已发布 tag。
- 跟踪与本次 tag 对应的 run ID,核对工作流解析的目标提交。触发工作流成功只表示已开始,不能当作发布完成。
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
Release 和镜像已生成也不表示用户的运行实例已经升级。
- `release/publish` 会提交、打 tag、推送并触发工作流,没有只读预演模式,不能用于探测或演练
- 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突
- 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对
- `release/notes.md` 只承载当前发行的说明,常规文档只写当前状态,不追加版本历史;其变更执行[文档检查](../cpr-dev-guide/references/documentation.md)
- 交付版本、提交、Release/工作流链接与实际结果,未完成构建或缺失产物时不称发布成功
@@ -1,7 +1,7 @@
# 实验版发布
适用于独立于主版本维护、供用户主动选择体验的实验功能。发布入口仍是仓库的 `release/publish`,
构建复用目标实验分支的 `.github/workflows/release.yml` 与 `release/platforms.yaml`。
按当前任务只读版本、发布能力、文案或验收章节,不因准备实验说明而读取执行流程
适用于独立于主版本维护、供用户主动选择体验的实验功能,入口使用仓库的 `release/publish`,构建复用目标实验分支的工作流与平台清单
## 分支与版本
@@ -14,10 +14,10 @@
不为统一名称移动 tag 或替换原有产物。
- GitHub Release 展示标题采用 `vX.Y.Z-<用途>.M`,`<用途>` 取实验分支名去掉 `experimental/` 前缀后的部分。
发布前通过 `gh api` 核对相同正式版基线与用途的已有 Release 标题和旧命名 tag,新发行取已有最大编号加 1,首次从 1 开始。
展示编号 `M` 独立于实际 tag 的 `exp.N`,旧命名发行也计入,不能因改用 `exp.N` 而重置;同一发行重试沿用已确定编号。
展示编号 `M` 独立于实际 tag 的 `exp.N`,所有同基线、同用途的已有发行均计入,不随 tag 命名格式重置;同一发行重试沿用已确定编号。
例如已有 `v3.10.0-codex-anti-degradation.1`,下一次 tag 即使是 `v3.10.0-exp.1`,标题也应为 `v3.10.0-codex-anti-degradation.2`。
- tag 和产物继续使用 `exp.N`:镜像版本去掉 tag 开头的 `v`,安装包沿用项目的版本、系统和架构命名。
发布说明首行仍匹配实际 tag;关联 Issue 和试验目的写在正文中。新标题规则不自动改名已有 Release。
发布说明首行匹配实际 tag,关联 Issue 和试验目的写在正文中,已有 Release 不因新发行而改名
## 先核对发布能力
@@ -54,12 +54,12 @@
## 执行与验收
按主技能准备说明和其他变更后,在实验分支执行 `release/publish <X.Y.Z-exp.N>`。
按[版本与说明准备](preparation.md)的对应章节准备后,再按[执行发版](operations.md#执行发版)在实验分支调用入口
入口创建版本提交和 tag,并从该分支 dispatch 发布工作流;不要从 `main` 发布实验代码。
按主技能的「发行通道核对」确认稳定入口未改变,并检查实验线隔离与上一版本的更新能力。
按[发行通道核对](operations.md#发行通道核对)确认稳定入口未改变,并检查实验线隔离与上一版本的更新能力。
检查展示标题的分支用途与历史续号、它所对应的实际 tag,以及目标 tag 的源码提交、完整产物、校验和、多架构平台和 Pre-release 标记。
同基线的 `X.Y.Z-exp.N` 按 SemVer 排序低于正式 `X.Y.Z`;渠道隔离仍以更新器过滤和发布标签规则为准。
失败时按主技能从未完成步骤恢复,不通过换版本号或移动 tag 掩盖失败。
失败时按[恢复与验收](operations.md#恢复与验收)从未完成步骤恢复,不通过换版本号或移动 tag 掩盖失败。
仅维护已有页面文案时不重新构建;报告实际完成状态、发布链接及验证缺口。
@@ -0,0 +1,49 @@
# 执行、恢复与验收
只在实际发布、恢复失败步骤或验收发行时读取对应章节,先核对[入口](../SKILL.md)中的范围与授权
## 发行通道核对
- alpha 是下一正式版本的早期开发阶段,beta 是功能测试阶段,rc 是发布候选,exp 是临时功能实验。
预发行采用 `vX.Y.Z-<阶段>.N`,`N` 从 1 开始;正式版不带预发行后缀,不新增未约定的通道名称。
- 更新候选由本次检查或安装选择的通道决定:Stable 仅正式版,RC 包含正式版,Beta 包含 RC 与正式版,Alpha 包含所有常规阶段。
仅接收同一大版本内严格更高的版本,可跟随后续小版本与补丁版本,默认按运行版本推导通道;临时选择不保存、不允许降级。
exp 仅接收同基线、同一轮实验的后续编号,不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。
- 核对目标工作流的构建类型:正式版及 alpha、beta、rc 为 `release`,exp 为 `experimental`。
所有预发行均标记为 Pre-release,不设置 GitHub Latest、不覆盖镜像 `latest`。
发布预发行前记录这两个稳定入口,发布后回读确认未改变。
- 验收更新器时,核对检查更新与执行更新使用相同规则。只有当前构建支持更新且存在允许的更高目标时
才能返回 `hasUpdate=true`;被禁止的目标即使版本领先,也不能出现在更新标记、可用版本或发布说明中。
检查失败须单独报告。核对上一发行实际包含的更新能力,不能因新包支持在线更新就宣称旧包也能在线安装它。
## 执行发版
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
本任务需要的提交按[项目约定](../../../../CONTRIBUTING.md#项目约定)保留英文 Conventional Commits 与相应署名。
在准备完成且用户已授权发布后,将 `release_version` 设为已确定的版本,执行:
```bash
release/publish "$release_version"
```
该命令会创建包含版本号和发布说明的单次提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
只调整已发布页面的标题或正文时,编辑对应 GitHub Release 的 `name` 或 `body` 并回读核对;
保持已有 tag、源码提交、附件和预发行状态,不重新执行发布入口。
## 恢复与验收
- 脚本中断或网络超时时,先核对本地提交与 tag、远端分支与 tag、对应工作流 run 及 Release,确定已完成到哪一步。
若本地已创建版本提交或 tag,不直接重跑入口,也不通过删 tag、回退版本或强推来消除报错。
- 提交和 tag 已推送但工作流未触发时,核对目标后使用脚本提示的 dispatch 命令补触发;
工作流已存在则跟踪或恢复对应 run,避免重复发布。发现 tag 指向不同提交时报告冲突,不移动已发布 tag。
- 跟踪与本次 tag 对应的 run ID,核对工作流解析的目标提交。触发工作流成功只表示已开始,不能当作发布完成。
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
Release 和镜像已生成也不表示用户的运行实例已经升级。
@@ -0,0 +1,36 @@
# 版本与说明准备
只在规划版本、编写发布说明或准备发行时读取对应部分,不因准备文案加载执行流程
## 基线与版本
1. 确认类型、分支、upstream、目标仓库、工作区与完整待发布范围,保留无关改动;未跟踪文件也需核对
2. 读取 [version.yaml](../../../../release/version.yaml)、相关 tag 和实际 Release,确定上一发布基线与本次目标提交,再核对二者间的最终 diff,不只看最近几条提交
3. 使用用户确定的目标版本,建议版本时说明兼容性、能力或修复依据;同名 tag 先区分重试与新发行,不递增版本掩盖冲突
4. 命名与晋级按[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则),实验版另读[实验流程](experimental.md#分支与版本)
## 发布说明
读取当前 [notes.md](../../../../release/notes.md)与[项目约定](../../../../CONTRIBUTING.md#项目约定)中的发布说明条目
- 只在发布说明任务中修改 `release/notes.md`,替换为本次版本内容,首行为目标 tag,正文使用中文
- 按用户可感知的最终变化归并,省略空章节,不逐条翻译提交,不写排查过程或未经验证的承诺
- 剔除回退、重复及未进入发行范围的内容,升级事项须能追溯到实际迁移、配置或兼容变化
- 只翻译已有准确说明时保持事实与范围,不混入其他变更
- 常规文档仍只描述当前状态;必要修订执行[文档检查](../../cpr-dev-guide/references/documentation.md),不复制发布说明中的版本变化列表
实际发布由 `release/publish` 提交说明与版本文件,准备说明时不要提前单独提交或把版本文件改成目标版本
用户只要求准备时保留本地改动,纯说明修改无需重建前后端
## 发行准备检查
进入实际发行准备后,读取目标分支的 [release/publish](../../../../release/publish)、[工作流](../../../../.github/workflows/release.yml)与[平台清单](../../../../release/platforms.yaml),按当前入口执行,不另写脚本或绕过检查
按[验证要求](../../../../CONTRIBUTING.md#验证)核对目标提交的证据,区分通过、失败、跳过与未执行
数据库变更同时读取[迁移冻结规则](../../../../backend/migrations/README.md#冻结规则):
- 对照同一大版本的上一正式版和本轮预发行/实验线的已发布版本,验证迁移未改写及升级路径
- 区分未发布迁移、已执行迁移的源码部署实例与 CI 冻结基线,未发版不代表未执行,也不授权放宽冻结规则
- 兼容性与 CI 检查分别报告,跨大版本不默认支持在线升级
正式执行时再读取[通道核对](operations.md#发行通道核对)与[执行发版](operations.md#执行发版),触发工作流不等于发布成功
+41
View File
@@ -0,0 +1,41 @@
name: Build frontend
description: Validate and build the frontend, optionally uploading release assets
inputs:
upload-artifact:
description: Upload frontend-dist for downstream release jobs
default: 'false'
runs:
using: composite
steps:
- name: Setup pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
package_json_file: frontend/package.json
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install frontend dependencies
shell: bash
run: pnpm --dir frontend install --frozen-lockfile
- name: Frontend format
shell: bash
run: pnpm --dir frontend format:check
- name: Frontend build
shell: bash
run: pnpm --dir frontend build
- name: Upload frontend artifact
if: inputs.upload-artifact == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-dist
path: frontend/dist
if-no-files-found: error
retention-days: 1
+27
View File
@@ -0,0 +1,27 @@
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
FROM busybox:1.37.0-musl@sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092 AS restore
ARG CACHE_STAMP
RUN --mount=type=bind,target=/cache \
--mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/backend/target \
test -n "$CACHE_STAMP" \
&& cp -a /cache/registry/. /usr/local/cargo/registry/ \
&& cp -a /cache/git/. /usr/local/cargo/git/ \
&& cp -a /cache/target/. /app/backend/target/
FROM busybox:1.37.0-musl@sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092 AS collect
ARG CACHE_STAMP
# 一次导出三个 mount,避免逐目录创建镜像、载入 Docker 再复制文件。
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/backend/target \
test -n "$CACHE_STAMP" \
&& mkdir -p /cache/registry /cache/git /cache/target \
&& cp -a /usr/local/cargo/registry/. /cache/registry/ \
&& cp -a /usr/local/cargo/git/. /cache/git/ \
&& cp -a /app/backend/target/. /cache/target/
FROM scratch AS snapshot
COPY --from=collect /cache/ /
+31
View File
@@ -0,0 +1,31 @@
name: Cargo cache mounts
description: Restore or export a validated Cargo snapshot for a BuildKit builder
inputs:
operation:
description: Cache operation, restore or snapshot
required: true
builder:
description: Buildx builder name
required: true
path:
description: Cargo snapshot directory on the runner
required: true
architecture:
description: Cargo target directory architecture, amd64 or arm64
required: true
outputs:
usable:
description: Whether the restored or exported snapshot contains Cargo dependencies
value: ${{ steps.cache.outputs.usable }}
runs:
using: composite
steps:
- id: cache
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
OPERATION: ${{ inputs.operation }}
BUILDER: ${{ inputs.builder }}
CACHE_PATH: ${{ inputs.path }}
ARCHITECTURE: ${{ inputs.architecture }}
run: bash "$ACTION_PATH/cargo-cache.sh" "$OPERATION" "$BUILDER" "$CACHE_PATH" "$ARCHITECTURE"
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 4 ]; then
echo "usage: $0 restore|snapshot BUILDER DIRECTORY amd64|arm64" >&2
exit 2
fi
operation="$1"
builder="$2"
directory="$(realpath -m "$3")"
architecture="$4"
case "$operation" in restore|snapshot) ;; *) exit 2 ;; esac
case "$architecture" in amd64|arm64) ;; *) exit 2 ;; esac
dockerfile="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/Dockerfile"
usable() {
local root="$1" path crates=false libraries=false
# 目录或 buildstamp 存在不代表编译发生过,必须同时有下载包与目标依赖。
for path in "$root"/registry/cache/*/*.crate; do
if [ -f "$path" ] && [ -s "$path" ]; then crates=true; break; fi
done
for path in "$root"/target/"$architecture"/release/deps/*.rlib; do
if [ -f "$path" ] && [ -s "$path" ]; then libraries=true; break; fi
done
[ "$crates" = true ] && [ "$libraries" = true ]
}
command=(docker buildx build --builder "$builder" --file "$dockerfile" --progress plain)
# mount 的内容不参与层指纹,用时间戳重跑复制;--no-cache 会重置 cache mount。
command+=(--build-arg "CACHE_STAMP=$(date +%s%N)" --target "$operation")
valid=false
if [ "$operation" = restore ]; then
if usable "$directory"; then
mkdir -p "$directory"/{registry,git,target}
"${command[@]}" --output type=cacheonly "$directory"
valid=true
else
echo "No usable Cargo snapshot; cached image layers may still be available"
fi
else
# 导出只发送空上下文,新快照独立校验后替换旧目录,避免残留文件造成误判。
mkdir -p "$(dirname "$directory")"
temporary="$(mktemp -d "${directory}.XXXXXX")"
trap 'rm -rf "$temporary"' EXIT
mkdir "$temporary/context"
"${command[@]}" --output "type=local,dest=$temporary/snapshot" "$temporary/context"
if usable "$temporary/snapshot"; then
rm -rf "$directory"
mv "$temporary/snapshot" "$directory"
valid=true
fi
fi
echo "Cargo snapshot for $architecture: usable=$valid"
if [ "$valid" = true ]; then du -sh "$directory"; fi
if [ -n "${GITHUB_OUTPUT:-}" ]; then printf 'usable=%s\n' "$valid" >> "$GITHUB_OUTPUT"; fi
+3 -1
View File
@@ -68,7 +68,9 @@ updates:
- patch
- package-ecosystem: docker
directory: /deploy
directories:
- /deploy
- /.github/actions/cargo-cache
labels: []
schedule:
interval: weekly
+33 -18
View File
@@ -1,4 +1,4 @@
name: Reusable Container
name: Container
on:
workflow_call:
@@ -30,30 +30,25 @@ jobs:
id: buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Cache Cargo mounts
- name: Restore Cargo snapshot
id: cargo-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ runner.temp }}/container-cargo
key: container-cargo-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/**/Cargo.toml', 'deploy/Dockerfile') }}-${{ inputs.ref }}
# 同一平台和依赖复用快照,源码变化由 Cargo 指纹判定是否重编译
key: container-cargo-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml', 'deploy/Dockerfile', '.github/actions/cargo-cache/Dockerfile') }}
restore-keys: |
container-cargo-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/**/Cargo.toml', 'deploy/Dockerfile') }}-
container-cargo-v1-${{ runner.os }}-${{ runner.arch }}-
container-cargo-v3-${{ runner.os }}-${{ runner.arch }}-
container-cargo-v2-${{ runner.os }}-${{ runner.arch }}-
# The GHA layer exporter does not persist RUN --mount=type=cache contents.
# Extraction runs before actions/cache saves its directories in post steps.
- name: Restore Cargo mounts to BuildKit
uses: reproducible-containers/buildkit-cache-dance@5422eac04292c961a382e0f584ea0f03ad9da723 # v3.4.0
id: cargo-restore
uses: ./.github/actions/cargo-cache
with:
operation: restore
builder: ${{ steps.buildx.outputs.name }}
scratch-dir: ${{ runner.temp }}/container-cache-dance
cache-map: |
{
"${{ runner.temp }}/container-cargo/registry": "/usr/local/cargo/registry",
"${{ runner.temp }}/container-cargo/git": "/usr/local/cargo/git",
"${{ runner.temp }}/container-cargo/target": "/app/backend/target"
}
skip-extraction: ${{ steps.cargo-cache.outputs.cache-hit }}
path: ${{ runner.temp }}/container-cargo
architecture: amd64
- name: Build runtime image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
@@ -67,7 +62,7 @@ jobs:
runtime-base
runtime
cache-from: type=gha,scope=runtime-ci
cache-to: type=gha,mode=max,scope=runtime-ci
cache-to: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'type=gha,mode=max,scope=runtime-ci' || '' }}
- name: Verify runtime image
uses: ./.github/actions/verify-container
@@ -75,3 +70,23 @@ jobs:
image: ${{ env.CPR_IMAGE }}
platform: linux/amd64
artifact-name: container-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}-amd64
# 构建层命中时 mount 仍可能为空,只有实际检查过内容的快照才允许保存。
- name: Export Cargo snapshot
if: >-
github.event_name == 'push' && github.ref == 'refs/heads/main' &&
(steps.cargo-cache.outputs.cache-hit != 'true' || steps.cargo-restore.outputs.usable != 'true')
id: cargo-snapshot
uses: ./.github/actions/cargo-cache
with:
operation: snapshot
builder: ${{ steps.buildx.outputs.name }}
path: ${{ runner.temp }}/container-cargo
architecture: amd64
- name: Save Cargo snapshot
if: steps.cargo-snapshot.outputs.usable == 'true' && steps.cargo-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ runner.temp }}/container-cargo
key: ${{ steps.cargo-cache.outputs.cache-primary-key }}
+46 -38
View File
@@ -1,4 +1,4 @@
name: Reusable Quality
name: Quality
on:
workflow_call:
@@ -7,11 +7,6 @@ on:
description: Exact commit SHA to validate
required: true
type: string
upload_frontend:
description: Upload the built frontend for release jobs
required: false
default: false
type: boolean
run_backend:
description: Run backend quality checks
required: false
@@ -111,6 +106,7 @@ jobs:
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: backend -> target
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
- name: Rust format
working-directory: backend
@@ -120,6 +116,19 @@ jobs:
working-directory: backend
run: cargo clippy --all-targets --all-features --locked -- -D warnings
- name: Setup nextest
uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21
with:
tool: cargo-nextest@0.9.143
fallback: none
- name: Build Rust tests once
working-directory: backend
run: |
cargo metadata --format-version 1 --locked > "$RUNNER_TEMP/nextest-cargo-metadata.json"
cargo nextest list --test main --locked --list-type binaries-only --message-format json \
> "$RUNNER_TEMP/nextest-binaries.json"
- name: Configure PostgreSQL and Redis test authentication
shell: bash
run: |
@@ -144,7 +153,30 @@ jobs:
- name: Rust tests
working-directory: backend
run: cargo test --test main --locked
run: |
cargo nextest run \
--cargo-metadata "$RUNNER_TEMP/nextest-cargo-metadata.json" \
--binaries-metadata "$RUNNER_TEMP/nextest-binaries.json" \
--no-tests fail
- name: Store tests with Redis ACL authentication
working-directory: backend
shell: bash
run: |
set -euo pipefail
redis_acl_password="$(openssl rand -hex 24)"
echo "::add-mask::$redis_acl_password"
docker exec "${{ job.services.redis.id }}" redis-cli -e --no-auth-warning \
-u "$CPR_TEST_REDIS_URL" --user default ACL SETUSER cpr_acl reset on \
">$redis_acl_password" '~*' '&*' '+@all' >/dev/null
docker exec --env REDISCLI_AUTH="$redis_acl_password" "${{ job.services.redis.id }}" \
redis-cli -e --user cpr_acl ACL SETUSER default off >/dev/null
export CPR_TEST_REDIS_URL="redis://cpr_acl:${redis_acl_password}@127.0.0.1:6379"
# 从同一份构建清单选择 Redis 测试,保留 workspace 特性图且不重复运行 PostgreSQL
cargo nextest run \
--cargo-metadata "$RUNNER_TEMP/nextest-cargo-metadata.json" \
--binaries-metadata "$RUNNER_TEMP/nextest-binaries.json" \
--no-tests fail -E 'package(=gateway-store) & test(/^redis::/)'
frontend:
if: inputs.run_frontend
@@ -158,35 +190,8 @@ jobs:
with:
ref: ${{ inputs.ref }}
- name: Setup pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
package_json_file: frontend/package.json
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install frontend dependencies
run: pnpm --dir frontend install --frozen-lockfile
- name: Frontend format
run: pnpm --dir frontend format:check
- name: Frontend build
run: pnpm --dir frontend build
- name: Upload frontend artifact
if: inputs.upload_frontend
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-dist
path: frontend/dist
if-no-files-found: error
retention-days: 1
- name: Validate and build frontend
uses: ./.github/actions/build-frontend
workflow-lint:
runs-on: ubuntu-latest
@@ -206,5 +211,8 @@ jobs:
-color
.github/workflows/*.yml
- name: Check release script syntax
run: bash -n release/publish
- name: Check workflow script syntax
run: |
bash -n release/publish
shopt -s globstar nullglob
for script in .github/**/*.sh; do bash -n "$script"; done
+13 -3
View File
@@ -38,14 +38,21 @@ jobs:
id: filter
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
with:
# 目录规则取并集,再统一排除说明文档,保留隐藏配置与子模块指针
predicate-quantifier: some-with-excludes
filters: |
backend:
- 'backend/**'
- 'release/version.yaml'
- '.github/workflows/**'
- '.github/workflows/ci.yml'
- '.github/workflows/_quality.yml'
- '!**/*.md'
frontend:
- 'frontend/**'
- '.github/workflows/**'
- '.github/actions/build-frontend/**'
- '.github/workflows/ci.yml'
- '.github/workflows/_quality.yml'
- '!**/*.md'
source:
- '.gitmodules'
- 'modules/ui'
@@ -53,15 +60,18 @@ jobs:
- 'frontend/**'
- 'backend/crates/gateway-plugin/sdk/**'
- '.github/workflows/ci.yml'
- '!**/*.md'
container:
- 'backend/**'
- 'frontend/**'
- 'deploy/**'
- '.dockerignore'
- 'release/version.yaml'
- '.github/actions/**'
- '.github/actions/verify-container/**'
- '.github/actions/cargo-cache/**'
- '.github/workflows/_container.yml'
- '.github/workflows/ci.yml'
- '!**/*.md'
quality:
needs: changes
+75 -29
View File
@@ -165,12 +165,64 @@ jobs:
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
# 保留旧更新器可解析的传输格式,实际支持范围仍取自当前宿主合同。
host = host.merge("schema_version" => 1, "permissions" => [])
# 清单只依赖版本与兼容声明,平台归档和镜像消费同一份封口结果。
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: Upload sealed plugin assets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: plugin-release-assets
path: dist/plugin-release-assets
if-no-files-found: error
retention-days: 1
quality:
needs: [release-info]
uses: ./.github/workflows/_quality.yml
with:
ref: ${{ needs.release-info.outputs.git_sha }}
upload_frontend: true
run_frontend: false
frontend:
needs: release-info
runs-on: ubuntu-latest
timeout-minutes: 15
env:
CI: 'true'
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-info.outputs.git_sha }}
- name: Validate and build frontend
uses: ./.github/actions/build-frontend
with:
upload-artifact: 'true'
build-docker-binary:
name: build-docker-binary (${{ matrix.os }}-${{ matrix.arch }})
@@ -194,10 +246,10 @@ jobs:
~/.cargo/registry
~/.cargo/git
backend/target
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}-${{ needs.release-info.outputs.git_sha }}
# 按目标、工具链和依赖保存,不为每个发行提交复制整个 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') }}
restore-keys: |
cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
cargo-v2-${{ runner.os }}-${{ matrix.target }}-
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
- name: Build binary
shell: bash
@@ -260,10 +312,9 @@ jobs:
~/.cargo/registry
~/.cargo/git
backend/target
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}-${{ needs.release-info.outputs.git_sha }}
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') }}
restore-keys: |
cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
cargo-v2-${{ runner.os }}-${{ matrix.target }}-
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
- name: Build binary
shell: bash
@@ -287,7 +338,7 @@ jobs:
retention-days: 1
package-assets:
needs: [release-info, quality, build-docker-binary, build-binary]
needs: [release-info, frontend, build-docker-binary, build-binary]
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
@@ -313,12 +364,17 @@ jobs:
pattern: codex-proxy-rs-*
path: dist/binaries
- name: Download sealed plugin assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: plugin-release-assets
path: dist/plugin-release-assets
- name: Package release assets
shell: bash
env:
VERSION: ${{ needs.release-info.outputs.version }}
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
run: |
set -euo pipefail
ruby <<'RUBY' > release-platforms.json
@@ -393,21 +449,8 @@ jobs:
raise SystemExit(f"Unsupported archive format: {platform['archive']}")
release_packages.append((platform, package_root, archive_path))
plugin_host_path = pathlib.Path("backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json")
plugin_host = json.loads(plugin_host_path.read_text())
if plugin_host.get("schema_version") != 1:
raise SystemExit(f"Invalid plugin host compatibility declaration: {plugin_host_path}")
plugin_manifest_path = release_dir / "plugin-release-manifest.json"
plugin_manifest_path.write_text(json.dumps({
"schema_version": 1,
"sealed": True,
"gateway_version": version,
"gateway_git_sha": os.environ["GIT_SHA"],
"plugin_host": plugin_host,
# 示例独立发版;当前不内置插件,仍保留更新与回滚所需的封口清单。
"plugins": [],
}, indent=2, sort_keys=True) + "\n")
shutil.copy2("dist/plugin-release-assets/plugin-release-manifest.json", plugin_manifest_path)
assets.append(plugin_manifest_path)
# 保留运行时清单目录合同,宿主包不再携带教学示例。
@@ -440,10 +483,12 @@ jobs:
path: dist/release
if-no-files-found: error
retention-days: 1
# tar.gz 已压缩,Actions 只负责传递原始发行字节。
compression-level: 0
build-docker-image:
name: build-docker-image (${{ matrix.dockerPlatform }})
needs: [release-info, quality, build-docker-binary, package-assets]
needs: [release-info, frontend, build-docker-binary]
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.release-info.outputs.docker_matrix) }}
@@ -476,11 +521,11 @@ jobs:
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
path: dist/docker-artifacts/codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
- name: Download sealed release assets
- name: Download sealed plugin assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-assets
path: dist/release-assets
name: plugin-release-assets
path: dist/plugin-release-assets
- name: Prepare Docker prebuilt assets
shell: bash
@@ -508,7 +553,7 @@ jobs:
shutil.copy2(binary_src, binary_dest)
binary_dest.chmod(0o755)
release_dir = pathlib.Path("dist/release-assets")
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:
@@ -601,7 +646,8 @@ jobs:
retention-days: 1
publish:
needs: [release-info, package-assets, build-docker-image]
# 产物准备可与测试重叠,版本标签和 GitHub Release 必须等全部质量与镜像验证通过
needs: [release-info, quality, package-assets, build-docker-image]
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
@@ -1,4 +1,4 @@
name: Agent Instructions Guard
name: Rules Guard
on:
pull_request_target:
@@ -13,7 +13,7 @@ permissions:
pull-requests: write
concurrency:
group: agents-md-guard-${{ github.event.pull_request.number }}
group: rules-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
@@ -22,7 +22,7 @@ jobs:
timeout-minutes: 5
steps:
# 只查询 PR 元数据,禁止检出或执行贡献分支中的代码。
- name: Enforce agent instructions policy
- name: Check protected files
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
@@ -33,12 +33,15 @@ jobs:
...params,
per_page: 100,
});
const isInstructions = path => typeof path === 'string'
&& (path === 'AGENTS.md' || path.endsWith('/AGENTS.md'));
const isProtectedRule = path => typeof path === 'string'
&& (path === 'AGENTS.md' || path.endsWith('/AGENTS.md')
|| path === 'CONTRIBUTING.md'
|| path === '.agents/skills' || path.startsWith('.agents/skills/')
|| path === '.github' || path.startsWith('.github/'));
// 同时检查重命名前后的路径,避免通过移走规则文件绕过检查。
if (!files.some(file => isInstructions(file.filename) || isInstructions(file.previous_filename))) {
core.info('No agent instruction changes detected.');
if (!files.some(file => isProtectedRule(file.filename) || isProtectedRule(file.previous_filename))) {
core.info('No protected contribution rule changes detected.');
return;
}
@@ -49,27 +52,31 @@ jobs:
return;
}
// 通知标记保持稳定,以便复用机器人已有评论,避免重复提醒
const marker = '<!-- agents-md-guard -->';
const body = [
marker,
'此 PR 修改了 `AGENTS.md`,已按贡献规则关闭。',
'佬友,我看到这个 PR 涉及受保护的协作规则文件,按仓库约定需要先关闭',
'',
'PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或重命名。',
'请撤销相关变更后重新打开 PR;如需调整协作规则,请先提交 Issue 说明原因和建议,由维护者处理。',
'保护范围包括根目录或子目录的 `AGENTS.md`、根目录的 `CONTRIBUTING.md`,以及 `.agents/skills/` 和 `.github/` 中的所有文件,涵盖新增、修改、删除和重命名',
'',
'麻烦先撤销这些变更,再重新打开 PR;如果希望调整协作规则,可以先提个 Issue 说明原因和建议,交由维护者处理,感谢理解',
].join('\n');
try {
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pr.number, per_page: 100,
});
const notified = comments.some(comment => comment.user?.login === 'github-actions[bot]'
const notification = comments.find(comment => comment.user?.login === 'github-actions[bot]'
&& comment.body?.includes(marker));
if (!notified) {
if (!notification) {
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
} else if (notification.body !== body) {
await github.rest.issues.updateComment({ owner, repo, comment_id: notification.id, body });
}
} catch (error) {
core.warning(`Unable to post policy explanation: ${error.message}`);
}
await github.rest.pulls.update({ ...params, state: 'closed' });
core.setFailed('Pull requests must not change AGENTS.md.');
core.setFailed('Pull requests must not change protected contribution rules (AGENTS.md, CONTRIBUTING.md, .agents/skills/, .github/).');
@@ -1,4 +1,4 @@
name: Security Scan
name: Security
on:
push:
@@ -14,7 +14,7 @@ permissions:
contents: read
concurrency:
group: security-scan-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
group: security-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
@@ -27,20 +27,15 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Setup Rust
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
- name: Cache Rust
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: backend -> target
- name: Install cargo-audit
run: cargo install cargo-audit --version 0.22.2 --locked
uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21
with:
tool: cargo-audit@0.22.2
fallback: none
- name: Run Cargo audits
run: |
cargo audit --file backend/Cargo.lock
cargo-audit audit --file backend/Cargo.lock
frontend-security:
runs-on: ubuntu-latest
-1
View File
@@ -6,7 +6,6 @@
# Frontend
/frontend/node_modules/
/frontend/dist/
/frontend/.vite/
/frontend/.pnpm-store/
/frontend/.pnpm-cache/
*.tsbuildinfo
+14 -7
View File
@@ -5,15 +5,21 @@
| 任务意图与常见说法 | 必用技能 |
| --- | --- |
| 开发、实现、修复、排查报错、审查问题或代码、修改文档 | [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 | [cpr-github-issue](.agents/skills/cpr-github-issue/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 |
| 发版、准备版本、发布 beta/实验版、整理发布说明、打发行 tag | [cpr-release](.agents/skills/cpr-release/SKILL.md) |
结合上下文识别“提交吧”“看看这个问题”等省略说法:普通 Git 提交不自动变成 PR,代码排查不自动变成 Issue;审查与发版准备不代表获准远程写入。
任务进入新阶段时补载对应技能,不能只读取文件而跳过其中的问题核实、自审、验证与交付步骤。协作流程和审查标准见 [CONTRIBUTING.md](CONTRIBUTING.md)。
结合上下文识别“提交吧”“看看这个问题”等省略说法:普通 Git 提交不自动变成 PR,代码排查不自动变成 Issue;审查与发版准备不代表获准远程写入
只加载当前任务命中的技能、模式和文档章节,复用已有上下文,进入新阶段再补读;链接是按需入口,不是全文或全部参考文件的必读清单
执行所选流程中的问题核实、自审、验证与交付要求,协作流程和审查标准见 [CONTRIBUTING.md](CONTRIBUTING.md)
Issue 和 PR 回复分别按对应技能的[Issue 回复口吻](.agents/skills/cpr-github-issue/SKILL.md#回复口吻)与[PR 回复口吻](.agents/skills/cpr-github-pr/SKILL.md#回复口吻)执行
模块职责以 [系统架构](docs/architecture.md) 为准,界面约定以 [管理端主题](docs/theme.md) 为准;
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则。
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则
<!-- CODEGRAPH_START -->
## CodeGraph
@@ -28,11 +34,12 @@ If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is
## 工作方式
- 开发和审查按 [问题与方案依据](CONTRIBUTING.md#问题与方案依据) 先核实问题,以官方源码为首要参考确定或评估方案。
- 开发和审查按 [问题与方案依据](CONTRIBUTING.md#问题与方案依据) 先核实问题;涉及 Codex 行为、协议或兼容性时,按[参考仓库优先级](.agents/skills/cpr-dev-guide/SKILL.md#参考仓库的优先级)以官方 Codex 仓库对应版本源码为第一手资料,三方仓库仅作参考
- 先定位变更所属模块、同类实现和上下游调用关系,再选择最小合理改动。复用已有能力,也避免为尚不存在的需求增加抽象。
- 提交前自审最终差异,先处理本次引入的职责混淆、重复规则、冗余状态和无依据的兼容分支;后端按 [后端自审](docs/architecture.md#后端自审) 追踪状态与资源生命周期,不能把自审留给 CI 或 PR 审查者。
- 页面变更先复用已有页面设计与组件,不因个人偏好重排页面或另建交互。文案取舍遵循 [界面文案与信息层级](docs/theme.md#界面文案与信息层级),页面 PR 必须提供 [实际截图](CONTRIBUTING.md#界面验证)。
- 代码注释使用中文,解释原因与边界;提交信息使用英文,沿用历史中的 Conventional Commits 格式。
- 常规文档只描述当前状态,不记录变更历史;按[文档职责与更新条件](CONTRIBUTING.md#文档职责与更新条件)在所属章节就地修订,分清层次与详略,短段和列表少用句号,交付前逐段检查必要性、归属、重复与当前状态表达
- 按变更范围执行验证,记录命令、结果和缺口。跳过的测试不算通过;界面与集成行为需要对应运行证据,构建通过不能替代实际验收。
- 审查请求默认只读。修复、提交、推送和合并按用户当前授权执行;“本地验证通过”不等于用户已经审阅批准。
- 不读取或输出无关凭据,不把真实密钥、代理认证、账号令牌或请求转储放入提交、截图及审查报告。
@@ -43,7 +50,7 @@ If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is
### 审查范围
- 确认目标分支与当前变更范围,结合调用方、被调用方及已有测试审查;结论对应实际审查的提交或工作区状态。
- 按 CONTRIBUTING.md 的维度检查设计、正确性、安全、复杂度、性能、可读性、验证、界面和文档,深度与变更风险相称。
- 按[审查标准](CONTRIBUTING.md#审查标准)检查设计、正确性、安全、复杂度、性能、可读性、验证、界面和文档,深度与变更风险相称
- 重点核对现有架构和数据合同:共享业务事实是否重复解释,异步与并发操作是否破坏状态,权限和敏感数据是否越界,协议适配是否影响兼容性。
- 修改既有特殊分支时先查场景和历史;放宽检查、修改测试或新增例外需要业务依据,不能仅以让检查通过为理由。
+95 -68
View File
@@ -1,47 +1,47 @@
# 贡献与审查
贡献应解决明确的问题,并保持或改善项目的可维护性。提交者、自动检查和审查者使用同一套标准,让重复问题在提交和初审阶段得到处理。
贡献应解决明确的问题,并保持或改善项目的可维护性。提交者、自动检查和审查者使用同一套标准,让重复问题在提交和初审阶段得到处理
本文定义协作流程与审查标准;模块职责以 [系统架构](docs/architecture.md) 为准,视觉规则以 [管理端主题](docs/theme.md) 为准。没有明确约定时遵循相邻实现;具体页面的布局决定不自动成为全项目限制。
本文定义协作流程与审查标准;模块职责以 [系统架构](docs/architecture.md) 为准,视觉规则以 [管理端主题](docs/theme.md) 为准。没有明确约定时遵循相邻实现;具体页面的布局决定不自动成为全项目限制
## 交流与反馈
[讨论区](https://github.com/zyycn/codex-proxy-rs/discussions) 用于使用问答、想法讨论、实验反馈和经验分享。
提问时按需补充版本、部署方式、已尝试的方法及脱敏信息;问题解决后,请在「使用问答」中标记有帮助的答案。
提问时按需补充版本、部署方式、已尝试的方法及脱敏信息;问题解决后,请在「使用问答」中标记有帮助的答案
明确的 Bug、具体功能需求或协作规则建议使用 Issue,无需先开讨论。
讨论形成可执行事项后,可以新建 Issue 并互相附链接;已有 Issue 的后续信息继续补充在原帖,避免重复记录。
围绕问题友善交流,欢迎不同使用场景和方案观点。
围绕问题友善交流,欢迎不同使用场景和方案观点
### 提交 Issue
按场景选择 [接口问题反馈](.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题反馈](.github/ISSUE_TEMPLATE/general-bug-report.yml) 或 [功能建议](.github/ISSUE_TEMPLATE/feature-request.yml) 表单,按对应提示提供信息。故障反馈填写实际运行版本和中转链路;接口问题区分客户端显示与网关实际响应,页面问题附相关截图或录屏。
按场景选择 [接口问题反馈](.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题反馈](.github/ISSUE_TEMPLATE/general-bug-report.yml) 或 [功能建议](.github/ISSUE_TEMPLATE/feature-request.yml) 表单,按对应提示提供信息。故障反馈填写实际运行版本和中转链路;接口问题区分客户端显示与网关实际响应,页面问题附相关截图或录屏
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议说明使用场景、当前不便和期望行为;协作规则调整可直接提交普通 Issue,说明原因和建议方案。
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议说明使用场景、当前不便和期望行为;协作规则调整可直接提交普通 Issue,说明原因和建议方案
## 问题与方案依据
开发、创建 PR 和审查都先核实待解决的问题、触发条件与预期行为,再确定或评估实现。区分问题发生版本、目标分支现状和本次变更后的行为,不把需求描述、PR 描述或第三方项目的做法直接当成事实。目标分支已修复的问题,应说明当前仍需补齐的差异,不能据此否定旧版本中的问题。
开发、创建 PR 和审查都先核实待解决的问题、触发条件与预期行为,再确定或评估实现。区分问题发生版本、目标分支现状和本次变更后的行为,不把需求描述、PR 描述或第三方项目的做法直接当成事实。目标分支已修复的问题,应说明当前仍需补齐的差异,不能据此否定旧版本中的问题
涉及上游协议、客户端行为或兼容性时,以对应版本的官方源码为首要资料。优先读取用户提供或本机已有的官方源码,核对仓库来源、版本或提交;本地资料不足时查官方仓库的对应版本源码,再结合官方测试、文档、发布说明和运行证据。第三方资料只作线索或补充。引用具体文件、符号或行号,对外引用优先使用固定提交的源码链接;源码不可得或版本不匹配时说明证据缺口,不把最新实现当成当前部署行为。无上游依赖的内部变更,以本项目代码、合同和运行证据为依据。
涉及上游协议、客户端行为或兼容性时,以对应版本的官方源码为首要资料。优先读取用户提供或本机已有的官方源码,核对仓库来源、版本或提交;本地资料不足时查官方仓库的对应版本源码,再结合官方测试、文档、发布说明和运行证据。第三方资料只作线索或补充。引用具体文件、符号或行号,对外引用优先使用固定提交的源码链接;源码不可得或版本不匹配时说明证据缺口,不把最新实现当成当前部署行为。无上游依赖的内部变更,以本项目代码、合同和运行证据为依据
官方已有同一场景的处理方式时,优先沿用其协议语义、职责归属和解决路径,检查是否只需复用已有能力或正确传递官方合同。新增覆盖配置、补偿逻辑或采用第三方兼容方案,应说明官方路径为何不足;偏离官方方案需有本项目的实际约束和验证依据,不机械照搬源码,也不默认接受已有提案。
官方已有同一场景的处理方式时,优先沿用其协议语义、职责归属和解决路径,检查是否只需复用已有能力或正确传递官方合同。新增覆盖配置、补偿逻辑或采用第三方兼容方案,应说明官方路径为何不足;偏离官方方案需有本项目的实际约束和验证依据,不机械照搬源码,也不默认接受已有提案
反馈 Issue 时区分观察到的症状、根因假设和方案建议,关联已有 Issue、PR 及包含修复的发布版本。能说明问题的反馈可以先提交,不要求报告者先找到官方源码或证明根因;引用官方行为或提出实现方案时,按上述原则注明依据与缺口。
反馈 Issue 时区分观察到的症状、根因假设和方案建议,关联已有 Issue、PR 及包含修复的发布版本。能说明问题的反馈可以先提交,不要求报告者先找到官方源码或证明根因;引用官方行为或提出实现方案时,按上述原则注明依据与缺口
## PR 流程
1. **准备变更。** 按上述依据核实问题与方案,找到所属模块、相关调用链和最接近的现有实现,在功能分支完成修改。提交前自审完整差异,清理本次引入的重复逻辑、冗余状态与职责越界;后端按 [后端自审](docs/architecture.md#后端自审) 执行。涉及架构、公开合同或较大交互变化时,先说明方案与取舍。
2. **发起 PR。** 目标分支使用 `main`,按 [PR 模板](.github/pull_request_template.md) 说明问题依据、触发场景、最终行为、自审和实际验证结果;涉及官方行为时注明源码位置、版本和方案关系。页面变更必须按 [界面验证](#界面验证) 附实际截图,使用 AI 时按 [AI 使用披露](#ai-使用披露) 写明模型。一个 PR 围绕一个明确目标,必要重构说明关联原因。
3. **自动检查与初审。** CI 执行适用的格式、静态分析、测试和构建检查;启用 AI 审查后,由它按本规范辅助检查语义、复用和一致性。提交者先处理明确问题,维护者重点审阅方案、风险与用户体验。
4. **修改与合并。** 更新代码后重新执行受影响的检查,确认审查意见已处理,验证证据对应当前变更。维护者审阅批准后再合并;需要升级说明的变更同步相关文档。
1. **准备变更**:按上述依据核实问题与方案,找到所属模块、相关调用链和最接近的现有实现,在功能分支完成修改。提交前自审完整差异,清理本次引入的重复逻辑、冗余状态与职责越界;后端按 [后端自审](docs/architecture.md#后端自审) 执行。涉及架构、公开合同或较大交互变化时,先说明方案与取舍
2. **发起 PR**:目标分支使用 `main`,按 [PR 模板](.github/pull_request_template.md) 说明问题依据、触发场景、最终行为、自审和实际验证结果;涉及官方行为时注明源码位置、版本和方案关系。页面变更必须按 [界面验证](#界面验证) 附实际截图,使用 AI 时按 [AI 使用披露](#ai-使用披露) 写明模型。一个 PR 围绕一个明确目标,必要重构说明关联原因
3. **自动检查与初审**:CI 执行适用的格式、静态分析、测试和构建检查;启用 AI 审查后,由它按本规范辅助检查语义、复用和一致性。提交者先处理明确问题,维护者重点审阅方案、风险与用户体验
4. **修改与合并**:更新代码后重新执行受影响的检查,确认审查意见已处理,验证证据对应当前变更。维护者审阅批准后再合并;需要升级说明的变更同步相关文档
AI 开发和审查入口是根目录的 [AGENTS.md](AGENTS.md)。在仓库上下文中要求“审查 PR #编号”时,应按下列标准执行,无需再次列举要求。
AI 开发和审查入口是根目录的 [AGENTS.md](AGENTS.md)。在仓库上下文中要求“审查 PR #编号”时,应按下列标准执行,无需再次列举要求
### 项目技能
仓库技能位于 `.agents/skills/`,随代码共享。技能提供执行流程,协作与审查标准仍以本文和 `AGENTS.md` 为准。
AI 开发、排查、代码审查和文档修改必须执行 `cpr-dev-guide`;准备、创建、更新及审查 PR 必须执行 `cpr-github-pr`,不以用户是否显式点名技能为条件。PR 内修复仍须经过开发流程,不能用填写 PR 正文代替实现自审与验证。
AI 开发、排查、代码审查和文档修改必须执行 `cpr-dev-guide`;准备、创建、更新及审查 PR 必须执行 `cpr-github-pr`,不以用户是否显式点名技能为条件。PR 内修复仍须经过开发流程,不能用填写 PR 正文代替实现自审与验证
| 技能 | 用途 | 调用示例 |
| --- | --- | --- |
@@ -51,26 +51,33 @@ AI 开发、排查、代码审查和文档修改必须执行 `cpr-dev-guide`;
| [cpr-github-issue](.agents/skills/cpr-github-issue/SKILL.md) | 整理或提交 Issue | `$cpr-github-issue 提交当前问题` |
| [cpr-release](.agents/skills/cpr-release/SKILL.md) | 准备发布说明或正式发版 | `$cpr-release 整理本次发布说明` / `$cpr-release 发布 vX.Y.Z` |
审查请求默认在当前会话报告;明确要求创建 PR、提交 Issue 或正式发版时才执行对应远程操作。技能安装不等于启用 GitHub 自动审查。
审查请求默认在当前会话报告;明确要求创建 PR、提交 Issue 或正式发版时才执行对应远程操作。技能安装不等于启用 GitHub 自动审查
### AI 使用披露
PR 正文说明提交者是否使用 AI。使用 AI 参与方案、代码、文档、测试或自审时,写明工具名称和实际模型型号;使用多个模型时分别列出,可简述各自参与范围。
`Codex`、`ChatGPT`、`Claude Code` 等工具或产品名称不能代替模型型号,优先填写工具显示或调用配置中的具体模型标识。
无法获知型号时如实标记「型号未提供,待补充」,不能猜测或伪造,也不能把待补信息称为已完成披露;补齐前保留 Draft。
更新 PR 时同步新增的模型信息;这一披露不替代提交者自审、验证责任或已有提交署名约定。
更新 PR 时同步新增的模型信息;这一披露不替代提交者自审、验证责任或已有提交署名约定
### 规则文件保护
PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或重命名。调整这些文件时先提交 Issue 说明原因和建议,由维护者处理;[规则保护工作流](.github/workflows/agents-md-guard.yml) 会提示并关闭包含此类变更的 PR。撤销相关变更后可以重新打开 PR,继续正常检查。
PR 不接受以下协作规则文件的新增、修改、删除或重命名:
源码变更遵守现有架构边界与迁移冻结规则,由对应 CI 检查执行;不额外设立整块业务目录的 PR 禁改清单。构建、测试和安全扫描复用现有工作流。
- 根目录及任意子目录的 `AGENTS.md`
- 根目录的 `CONTRIBUTING.md`
- `.agents/skills/` 中的所有文件,包括技能入口、引用文档、配置和脚本
- `.github/` 中的所有文件,包括工作流、Actions、Issue/PR 模板和依赖更新配置
调整这些文件时先提交 Issue 说明原因和建议,由维护者处理;[规则保护工作流](.github/workflows/rules.yml) 会提示并关闭包含此类变更的 PR。撤销相关变更后可以重新打开 PR,继续正常检查
源码变更遵守现有架构边界与迁移冻结规则,由对应 CI 检查执行;不额外设立整块业务目录的 PR 禁改清单。构建、测试和安全扫描复用现有工作流
## 审查标准
按改动涉及的范围选择检查深度,无需为每个 PR 填写所有维度的报告。
按改动涉及的范围选择检查深度,无需为每个 PR 填写所有维度的报告
结论先回答原问题是否成立(成立、部分成立、不成立或证据不足),说明触发条件和依据;再判断解决方向是否合理,交代适用的官方方案、本次实现与它的关系及必要调整,最后报告实现问题与验证缺口。原问题是否成立与 PR 实现是否正确分别判断。源码推断、实际复现和检查结果分别说明,CI 通过不能代替问题与方案的论证。
结论先回答原问题是否成立(成立、部分成立、不成立或证据不足),说明触发条件和依据;再判断解决方向是否合理,交代适用的官方方案、本次实现与它的关系及必要调整,最后报告实现问题与验证缺口。原问题是否成立与 PR 实现是否正确分别判断。源码推断、实际复现和检查结果分别说明,CI 通过不能代替问题与方案的论证
| 维度 | 检查重点 |
| --- | --- |
@@ -86,59 +93,81 @@ PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或
审查意见区分三类:
- **必须修复:** 有具体触发条件的缺陷、违反现有合同或明确项目规范的问题。说明位置、依据、影响和最小修复方向。
- **建议:** 不影响合同或规范的替代实现、命名偏好和局部优化。个人偏好不能单独阻止合并,也不应强求与本次目标无关的重构。
- **验证缺口:** 尚未检查的行为、状态或运行环境。说明缺少什么证据,由维护者评估是否需要补齐,不能把未验证当作已通过或已发现缺陷。
- **必须修复:** 有具体触发条件的缺陷、违反现有合同或明确项目规范的问题。说明位置、依据、影响和最小修复方向
- **建议:** 不影响合同或规范的替代实现、命名偏好和局部优化。个人偏好不能单独阻止合并,也不应强求与本次目标无关的重构
- **验证缺口:** 尚未检查的行为、状态或运行环境。说明缺少什么证据,由维护者评估是否需要补齐,不能把未验证当作已通过或已发现缺陷
复杂度不按行数判断,复用不只看语法相似。建议合并重复逻辑时应指出共享的业务含义;建议保留特殊分支时应能说明它服务的场景。例外应有依据,不能仅为绕过检查而放宽规则。
复杂度不按行数判断,复用不只看语法相似。建议合并重复逻辑时应指出共享的业务含义;建议保留特殊分支时应能说明它服务的场景。例外应有依据,不能仅为绕过检查而放宽规则
## 项目约定
- AI 进行 Rust 开发时,加载并使用 `$rust-best-practices` 技能。模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置。
- Vue 使用 TypeScript、`<script setup>` 和现有 ESLint 配置;API 请求通过已有模块及 `request` 封装,沿用逐行请求配置格式。
- 业务数据的解析、校验和展示优先复用已有逻辑。新增抽象放在拥有该职责的模块,避免重复事实源或无实际需求的通用层。
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并;确由 Codex 参与的提交保留 `Co-authored-by: Codex <noreply@openai.com>`。
- 发布说明写入 `release/notes.md`,标题首行为目标版本的 `# vX.Y.Z`,章节标题和正文使用中文;命令、配置键、协议名等保留原文。只说明本次版本的实际变化,按影响交代升级事项。
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理。
- AI 进行 Rust 开发时,加载并使用 `$rust-best-practices` 技能。模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置
- Vue 使用 TypeScript、`<script setup>` 和现有 ESLint 配置;API 请求通过已有模块及 `request` 封装,沿用逐行请求配置格式
- 业务数据的解析、校验和展示优先复用已有逻辑。新增抽象放在拥有该职责的模块,避免重复事实源或无实际需求的通用层
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并;确由 Codex 参与的提交保留 `Co-authored-by: Codex <noreply@openai.com>`
- 发布说明写入 `release/notes.md`,标题首行为目标版本的 `# vX.Y.Z`,章节标题和正文使用中文;命令、配置键、协议名等保留原文。只说明本次版本的实际变化,按影响交代升级事项
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理
## 文档职责与更新条件
常规文档说明当前有效的用法、接口合同、模块职责和维护约束,不承担变更历史。
只有改动使对应说明失真、缺少必要信息,或改变读者需要执行的操作与判断时,才更新文档。
内部修复、重构和界面微调不默认附带文档改动;审查时不能仅因 PR 没改文档就判定存在缺口。
**文档只描述当前有效状态,不记录变更历史**,在所属章节就地修订,不追加“本次新增”“此前行为”或修复经过
- 按 [开发指南的文档入口](.agents/skills/cpr-dev-guide/SKILL.md#按任务读取) 找到内容所属文档,在原有章节就地修订,
不按每次变更追加说明。同一事实只保留一处完整说明,其他文档按读者任务提供必要摘要和链接。
- API 文档保留调用所需的字段、状态、语义和限制;架构文档保留职责与不变量;部署文档保留配置、操作和排障。
不把界面布局、内部锁或索引实现写进接口说明,也不把前端库的实现选择写进部署说明。
- 改动原因、前后对比、排查经过和本次验证结果放在 PR / Issue 或任务报告。版本变更与升级影响按维护者的发版安排维护
`release/notes.md`,不为普通开发任务或 PR 自动追加发布说明。
- 仍影响实际升级、恢复或兼容使用的限制与操作应保留,并注明适用条件;不能仅因涉及旧版就删除。
审查文档改动时核对必要性、所属职责与重复内容,区分当前使用说明和仅解释此次修复的内容。
只有用法、合同、职责、约束或读者需要执行的操作发生变化时才更新,内部重构和界面微调不默认附带文档改动
### 内容归属
| 文档 | 回答的问题 | 详略边界 |
| --- | --- | --- |
| [README](README.md) | 项目能做什么,如何开始 | 简短介绍、最短可用路径和文档导航 |
| [部署与运维](deploy/README.md) | 如何安装、接入、排障和恢复 | 前置条件、配置、操作步骤、结果判断与风险 |
| [开发与源码联调](docs/development.md) | 如何准备环境、运行源码和联调独立仓库 | 可执行命令、目录关系和依赖维护 |
| [API 参考](docs/api.md) | 调用者如何正确请求和解释结果 | 鉴权、字段、默认值、错误、限制与幂等语义,不展开内部实现或界面布局 |
| [系统架构](docs/architecture.md) | 业务事实归谁,调用如何协作 | 模块边界、状态所有权、生命周期和不变量,不重复字段清单 |
| [管理端主题](docs/theme.md) | 界面如何保持一致,主题如何扩展 | 文案与视觉约束、Token 合同、实现入口和验收范围 |
| [插件使用](docs/plugins.md) | 如何安装、授权、配置和使用插件 | 管理员操作与影响,不展开 SDK 调用或宿主事务实现 |
| [插件 SDK](backend/crates/gateway-plugin/sdk/README.md) / [CLI](backend/apps/plugin-cli/README.md) | 如何编写和打包插件 | 作者合同、权限边界、示例与打包验证 |
| [数据库迁移](backend/migrations/README.md) | 如何维护 schema 和准备测试库 | 冻结规则、升级约束和测试环境,不罗列迁移历史 |
| 本文 | 如何协作、审查和交付 | 流程、判断标准与验证要求,任务入口由 [开发技能](.agents/skills/cpr-dev-guide/SKILL.md) 串联 |
- 同一事实只在所属文档完整说明,其他位置按读者任务保留必要摘要和链接
- 改动原因、前后对比、排查经过和验证结果放在 PR、Issue 或任务报告;`release/notes.md` 是发版时使用的发布说明,不作为常规文档的历史章节,也不随普通开发任务自动追加
- 仍影响升级、恢复或兼容使用的内容,写成明确的适用条件与操作,保留必要限制,不叙述演变过程
### 层次与行文
- 按读者任务组织章节,先给入口和操作,再解释限制;长篇参考文档提供导航,独立主题使用小标题
- 快速开始只保留走通流程的必要步骤;API、配置、恢复和权限等容易误用的内容写清输入、默认值、边界与失败处理
- 短说明直接成段,并列信息用列表,可比较的字段或职责用表格,避免一段堆叠多个主题
- 标题、表格单元格、短列表项和独立短段通常不加句号;长段优先拆分,确需区分完整句意时保留句号,不机械替换成逗号或分号
- 命令、URL、版本号、字段名和原始输出保持准确,不为统一标点改写代码示例
审查时逐段核对:是否属于当前状态、是否回答读者问题、是否放在正确文档、是否重复、脱离变更背景能否独立理解
没有文档改动本身不构成验证缺口,判断依据是当前说明是否仍然准确、完整
## 界面验证
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有页面为依据,优先延续原页面的布局、信息层级与交互;设计技能不构成重做页面的理由。
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有页面为依据,优先延续原页面的布局、信息层级与交互;设计技能不构成重做页面的理由
开发前查找原页面、同类页面及 `modules/ui/src/components` 中的基础组件,明确参考实现和可复用部分,再确定最小改动。只有原设计不能满足本次操作或存在具体问题时才调整结构,并在 PR 中说明原因;不顺带换风格、重排无关区域或增加重复的模式入口。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
开发前查找原页面、同类页面及 `modules/ui/src/components` 中的基础组件,明确参考实现和可复用部分,再确定最小改动。只有原设计不能满足本次操作或存在具体问题时才调整结构,并在 PR 中说明原因;不顺带换风格、重排无关区域或增加重复的模式入口。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令
提交者在浏览器中验证受影响路径,页面变更的 PR 必须提供实际运行截图,包括布局、文案、样式和交互状态的变化:
- 修改现有页面提供变更前后截图,尽量保持相同数据、主题和窗口尺寸;新增页面提供完成态截图。截图保留必要上下文,让审查者能判断布局和信息密度,不只截取孤立控件。
- 按影响范围补充浅色/深色、窄窗口及空/加载/错误等受影响状态;共用主题或基础组件变更按主题文档扩大验证范围。弹出层中的内容需展示展开状态,不能只截关闭后的页面。
- 在 PR 正文嵌入或链接审查者可访问的图片,注明页面入口、状态和对应提交。录屏、预览地址和复现步骤用于补充,不能替代截图;本机绝对路径不算可供远端审阅的附件。
- 视觉或交互继续修改后更新受影响截图,使证据对应待审实现。截图与模拟数据不得包含真实凭据,不用设计稿、生成图片或旧版截图冒充运行结果。
- 环境不足时说明缺少的截图和交互验证,不宣称验收通过;页面 PR 在截图补齐前保留 Draft,不标为可审阅或建议合并。
- 修改现有页面提供变更前后截图,尽量保持相同数据、主题和窗口尺寸;新增页面提供完成态截图。截图保留必要上下文,让审查者能判断布局和信息密度,不只截取孤立控件
- 按影响范围补充浅色/深色、窄窗口及空/加载/错误等受影响状态;共用主题或基础组件变更按主题文档扩大验证范围。弹出层中的内容需展示展开状态,不能只截关闭后的页面
- 在 PR 正文嵌入或链接审查者可访问的图片,注明页面入口、状态和对应提交。录屏、预览地址和复现步骤用于补充,不能替代截图;本机绝对路径不算可供远端审阅的附件
- 视觉或交互继续修改后更新受影响截图,使证据对应待审实现。截图与模拟数据不得包含真实凭据,不用设计稿、生成图片或旧版截图冒充运行结果
- 环境不足时说明缺少的截图和交互验证,不宣称验收通过;页面 PR 在截图补齐前保留 Draft,不标为可审阅或建议合并
截图用于视觉审阅,保存、切换、错误恢复和键盘操作等行为仍需实际验证并记录覆盖范围,不能仅凭静态图片推断交互正确。
截图用于视觉审阅,保存、切换、错误恢复和键盘操作等行为仍需实际验证并记录覆盖范围,不能仅凭静态图片推断交互正确
涉及浏览器能力或访问环境差异时,按项目支持的部署方式核对相关 HTTP/HTTPS、localhost/实际部署地址与浏览器版本,记录实际覆盖的条件;本地预览正常不能替代部署环境中的交互验收。
涉及浏览器能力或访问环境差异时,按项目支持的部署方式核对相关 HTTP/HTTPS、localhost/实际部署地址与浏览器版本,记录实际覆盖的条件;本地预览正常不能替代部署环境中的交互验收
## 验证
选择与变更相关的检查,纯文档修改无需重建前后端。行为修复补充能覆盖触发条件和结果的测试,不增加仅复述实现的断言。
选择与变更相关的检查,纯文档修改无需重建前后端。行为修复补充能覆盖触发条件和结果的测试,不增加仅复述实现的断言
| 范围 | 现有检查 | 仍需提供的证据 |
| --- | --- | --- |
@@ -148,15 +177,9 @@ AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方
| 依赖安全 | Cargo Audit、pnpm Audit | 本次变更的权限、数据与信任边界 |
| 工作流与容器 | Actionlint、相关容器检查 | 改动涉及的运行行为 |
UI 与示例源码分别在独立的 `codex-proxy-ui`、`codex-proxy-plugins` 仓库维护。前端的依赖、锁文件和 ESLint 配置均在 `frontend/` 管理,仓库根目录不维护 Node 包。UI 使用固定 GitHub 标签或提交依赖,锁文件记录实际提交、源码归档地址及完整性摘要,常规开发和 CI 不需要检出同级仓库。安装时 pnpm 按 UI 自身锁文件准备构建依赖,再执行 `prepack` 生成 JS、CSS 和类型声明。开发服务通过 UI 包的 `development` 入口使用 Vue SFC,生产构建使用 `dist`。
环境准备、依赖安装和服务启动见 [开发与源码联调](docs/development.md),联合修改组件库或插件时使用其中的子模块入口
pnpm 版本由 `frontend/package.json` 的 `packageManager` 固定,CI 和 Docker 从该字段读取。`frontend/pnpm-workspace.yaml` 的 `allowBuilds` 按仓库放行自有 UI 包的构建脚本;升级依赖时核对源码并更新锁文件,无需逐次修改构建许可,不开放全部依赖脚本。
```bash
pnpm --dir frontend install --frozen-lockfile
```
需要联合修改组件库或插件时,使用 [源码联调](docs/development.md) 中的子模块与开发入口;联调只临时替换解析路径,不改写正式依赖或锁文件。独立插件的 SDK 固定到包含所需合同的 Git 提交,不依赖浮动分支。
正式依赖固定到标签或提交,联调只临时替换解析路径,不改写正式依赖或锁文件
前端从仓库根目录执行:
@@ -166,16 +189,20 @@ pnpm --dir frontend build
git diff --check
```
`build` 已包含类型检查;不维护独立前端测试代码。后端的工具链、命令与线程栈设置统一见 [修改与验收](docs/architecture.md#12-修改与验收)。
`build` 已包含类型检查;不维护独立前端测试代码。后端的工具链、命令与线程栈设置统一见 [修改与验收](docs/architecture.md#12-修改与验收)
依赖 PostgreSQL / Redis 的测试使用 [专用测试环境](backend/migrations/README.md#本地测试库),未配置导致跳过时明确记录。CI 中已有对应服务和检查,见 [质量工作流](.github/workflows/_quality.yml) 与 [安全扫描](.github/workflows/security-scan.yml)。
依赖 PostgreSQL / Redis 的测试使用 [专用测试环境](backend/migrations/README.md#本地测试库),未配置导致跳过时明确记录。CI 中已有对应服务和检查,见 [质量工作流](.github/workflows/_quality.yml) 与 [安全扫描](.github/workflows/security.yml)
集成变更应验证实际业务路径及结果,不能只凭健康检查或探测成功认定全链路正常。协议和网络改动按需核对认证、证书、代理、流式交付及客户端结果;记录影响复现的环境条件。缺少运行条件时说明缺口,不用模拟结果代替真实集成结论。
集成变更应验证实际业务路径及结果,不能只凭健康检查或探测成功认定全链路正常。协议和网络改动按需核对认证、证书、代理、流式交付及客户端结果;记录影响复现的环境条件。缺少运行条件时说明缺口,不用模拟结果代替真实集成结论
## 自动检查与合并条件
[CI](.github/workflows/ci.yml) 已按变更范围调用现有质量检查。新增自动规则优先使用现有工具,要求依据明确、结果稳定、误报可控;无法可靠判定的设计与视觉问题保留审查判断,避免把具体源码写法锁成测试。
[CI](.github/workflows/ci.yml) 按变更范围调用质量检查,Markdown 不触发后端、前端、容器和源码联调任务,工作流语法检查独立执行
AI 自动审查需要在所用工具中单独启用。其支持的检查范围和优先级过滤以工具说明为准;即使能读取 `AGENTS.md`,也不能保证所有可维护性和视觉问题都会被报告。配置参考 [Codex GitHub 审查文档](https://learn.chatgpt.com/docs/third-party/github)。
后端 CI 使用 nextest 执行 `main` 集成测试,默认 Redis 密码模式执行完整测试集,ACL 用户模式复用同一批构建产物执行 Store 的 Redis 测试;本地验证命令见 [架构文档](docs/architecture.md#验证命令)
要由 GitHub 强制执行合并条件,维护者需配置对应分支的必需状态检查、审阅批准和讨论解决要求,并按需要设置新提交后的重新审阅。配置前核对实际检查名称与触发范围,见 [GitHub 分支保护](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches)。规则文档本身不会启用 AI 服务或更改分支设置。
新增自动规则优先使用现有工具,要求依据明确、结果稳定、误报可控;无法可靠判定的设计与视觉问题保留审查判断,避免把具体源码写法锁成测试
AI 自动审查需要在所用工具中单独启用。其支持的检查范围和优先级过滤以工具说明为准;即使能读取 `AGENTS.md`,也不能保证所有可维护性和视觉问题都会被报告。配置参考 [Codex GitHub 审查文档](https://learn.chatgpt.com/docs/third-party/github)
要由 GitHub 强制执行合并条件,维护者需配置对应分支的必需状态检查、审阅批准和讨论解决要求,并按需要设置新提交后的重新审阅。配置前核对实际检查名称与触发范围,见 [GitHub 分支保护](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches)。规则文档本身不会启用 AI 服务或更改分支设置
+19 -12
View File
@@ -30,14 +30,17 @@
| 账号 | `admin@cpr.local` |
| 密码 | `039c18de2aeac46d23ead7766bb07bbbe3747233c66a128e` |
预览服务运行已发布版本,展示的账号、代理与使用记录均为模拟数据,每天北京时间 `00:00` 自动生成当天数据。
预览服务运行已发布版本,展示的账号、代理与使用记录均为模拟数据,每天北京时间 `00:00` 自动生成当天数据
这是公开共享的功能预览环境,不提供真实模型调用;请勿导入真实账号、密钥或其他敏感信息
## 快速开始
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis。
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL。已有部署请先看
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL
已有部署请先看 [升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
### 一键安装
@@ -48,7 +51,9 @@ curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/in
```
[安装脚本](deploy/install.sh) 默认安装到当前目录下的 `codex-proxy-rs/`,下载同一正式 Release 的部署文件,
自动生成密码、设置目录权限并启动服务。完成后会显示访问地址和管理员密码,请保存密码,再按下方步骤
自动生成密码、设置目录权限并启动服务
完成后会显示访问地址和管理员密码,请保存密码,再按下方步骤
[添加账号与客户端密钥](#添加账号与客户端密钥)
可在执行 `bash install.sh` 时传入环境变量:
@@ -65,7 +70,7 @@ curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/in
INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh
```
重复运行时请使用同一安装目录。检测到 `deploy/config.yaml` 后,脚本保留现有配置和部署文件,
重复运行时请使用同一安装目录,检测到 `deploy/config.yaml` 后,脚本保留现有配置和部署文件,
忽略传入的管理员密码,也不执行版本升级
### 手动安装
@@ -74,9 +79,9 @@ INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh
### 登录管理端
部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录。
API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage` 查看自己的用量、趋势、请求日志、额度与健康时间线;不能访问管理员页面。
页面右上角的「密钥配置」支持复制 Codex 配置文件和导入 CCSwitch,导入时同时启用当前 Key 的日/周额度查询,默认刷新间隔为 30 分钟
部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录
API Key 持有者可在登录页切换身份,查看自己的用量与额度,并通过「密钥配置」复制 Codex 配置或导入 CCSwitch
默认地址只能在服务器本机访问,从其他设备使用时,需要配置
[HTTPS 反向代理](deploy/README.md#公网访问)
@@ -89,8 +94,9 @@ API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage`
## 客户端接入
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入。
合并到客户端配置后重启 Codex。完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入,合并配置后重启 Codex
完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)
**其他 Responses API 客户端**:填写以下信息:
@@ -114,7 +120,8 @@ curl http://127.0.0.1:8080/v1/models \
| 接口集成 | [API 参考](docs/api.md) · [模型定价](docs/api.md#模型定价) |
| 使用插件 | [安装、配置与使用](docs/plugins.md) |
| 开发插件 | [SDK 与合同](backend/crates/gateway-plugin/sdk/README.md) · [打包工具](backend/apps/plugin-cli/README.md) |
| 开发宿主 | [贡献与验证](CONTRIBUTING.md) · [源码联调](docs/development.md) · [系统架构](docs/architecture.md) · [管理端主题](docs/theme.md) · [数据库迁移](backend/migrations/README.md) |
| 开发宿主 | [开发与源码联调](docs/development.md) · [系统架构](docs/architecture.md) · [管理端主题](docs/theme.md) · [数据库迁移](backend/migrations/README.md) |
| 参与协作 | [贡献、审查与验证](CONTRIBUTING.md) |
## 社区
+96 -102
View File
@@ -242,9 +242,9 @@ dependencies = [
[[package]]
name = "aws-runtime"
version = "1.9.4"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25b43ad47adc2517efe3d706559d94b97e50e80e0321b3cadbc9f77cee88adcd"
checksum = "2b8a9911551b4ea6ca13805ef52ed96f7d2bbb43cc3b4a14cb0776a71f33cfaa"
dependencies = [
"aws-credential-types",
"aws-sigv4",
@@ -258,10 +258,8 @@ dependencies = [
"bytes",
"bytes-utils",
"fastrand",
"http 0.2.12",
"http 1.5.0",
"http-body 0.4.6",
"http-body 1.0.1",
"http",
"http-body",
"percent-encoding",
"pin-project-lite",
"tracing",
@@ -270,9 +268,9 @@ dependencies = [
[[package]]
name = "aws-sdk-s3"
version = "1.148.0"
version = "1.149.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a61c1db3987ab6c8740fb87f248864687d50549401115244a84f02c3047b2d5"
checksum = "d48257fb0eb1907a02a965ff7d1b910770a6de2c59a2d03461678df989c5cdcd"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -294,9 +292,8 @@ dependencies = [
"fastrand",
"hex",
"hmac",
"http 0.2.12",
"http 1.5.0",
"http-body 1.0.1",
"http",
"http-body",
"lru",
"percent-encoding",
"regex-lite",
@@ -307,9 +304,9 @@ dependencies = [
[[package]]
name = "aws-sigv4"
version = "1.5.3"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31d955e76ff96acd555bf06fa0fa6d5bf9335fa84ae7c64481b20ae61d231f70"
checksum = "2312577f088c9fbf4206dfdb884cf1de9407b43e1a923cbed5237775116fc24b"
dependencies = [
"aws-credential-types",
"aws-smithy-eventstream",
@@ -320,8 +317,7 @@ dependencies = [
"form_urlencoded",
"hex",
"hmac",
"http 0.2.12",
"http 1.5.0",
"http",
"percent-encoding",
"sha2 0.11.0",
"time",
@@ -350,8 +346,8 @@ dependencies = [
"bytes",
"crc-fast",
"hex",
"http 1.5.0",
"http-body 1.0.1",
"http",
"http-body",
"http-body-util",
"md-5",
"pin-project-lite",
@@ -384,8 +380,8 @@ dependencies = [
"bytes-utils",
"futures-core",
"futures-util",
"http 1.5.0",
"http-body 1.0.1",
"http",
"http-body",
"http-body-util",
"percent-encoding",
"pin-project-lite",
@@ -403,7 +399,7 @@ dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
"h2",
"http 1.5.0",
"http",
"hyper",
"hyper-rustls",
"hyper-util",
@@ -439,9 +435,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime"
version = "1.14.2"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3296253d3a91b3f938a3f2bcce4daebadcb4aa4228153fcec90f9d23532b4484"
checksum = "591e0024cdf2a8de8711860f7e1d5cfbca26b800ce13793a9aa2f4f5918e6d95"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
@@ -452,10 +448,8 @@ dependencies = [
"aws-smithy-types",
"bytes",
"fastrand",
"http 0.2.12",
"http 1.5.0",
"http-body 0.4.6",
"http-body 1.0.1",
"http",
"http-body",
"http-body-util",
"pin-project-lite",
"pin-utils",
@@ -465,16 +459,15 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime-api"
version = "1.16.2"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d881a7b7ad179fd6611680c9de89f716fb00ab40299a9a7b8c6913e8f7511a8"
checksum = "9bb0deb40a69de59f7809251cfe1ab16de135f42c49d222d1202b4d0ce19720f"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api-macros",
"aws-smithy-types",
"bytes",
"http 0.2.12",
"http 1.5.0",
"http",
"pin-project-lite",
"tokio",
"tracing",
@@ -500,23 +493,21 @@ checksum = "e8f395d93304280b64b7632fea798d177e74897fe7f063416ce627cd6fa24829"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
"http 1.5.0",
"http",
]
[[package]]
name = "aws-smithy-types"
version = "1.6.4"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b791f3ac597193fe1d08b82366986eb1f5bc31f2ac6c194c0855276116c76cd"
checksum = "69bb407740a197147da48238ecc94498493c9e85445732360cec180296ca45f1"
dependencies = [
"base64-simd",
"bytes",
"bytes-utils",
"futures-core",
"http 0.2.12",
"http 1.5.0",
"http-body 0.4.6",
"http-body 1.0.1",
"http",
"http-body",
"http-body-util",
"itoa",
"num-integer",
@@ -567,8 +558,8 @@ dependencies = [
"bytes",
"form_urlencoded",
"futures-util",
"http 1.5.0",
"http-body 1.0.1",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
@@ -599,8 +590,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http 1.5.0",
"http-body 1.0.1",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
@@ -935,9 +926,9 @@ dependencies = [
[[package]]
name = "config"
version = "0.15.25"
version = "0.15.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85f248a4de22d204ceabc6299d89d2c70fbd7f09fea53c06c852369652d8139"
checksum = "790374c727a4cb20877dd2cc99ce7011cbe03b0b8df083acccd37df0d161304d"
dependencies = [
"pathdiff",
"serde_core",
@@ -1683,6 +1674,8 @@ dependencies = [
"gateway-core",
"heck",
"hex",
"prettyplease",
"quote",
"rand_core 0.6.4",
"secrecy",
"semver",
@@ -1690,6 +1683,7 @@ dependencies = [
"serde_json",
"sha2 0.11.0",
"subtle",
"syn 2.0.119",
"tempfile",
"thiserror",
"tokio",
@@ -1712,6 +1706,9 @@ dependencies = [
"gateway-core",
"gateway-protocol",
"headers",
"http",
"http-body",
"http-body-util",
"secrecy",
"serde",
"serde_json",
@@ -1737,6 +1734,9 @@ dependencies = [
"chrono-tz",
"futures",
"futures-timer",
"http",
"http-body",
"http-body-util",
"semver",
"serde",
"serde_json",
@@ -1763,7 +1763,7 @@ dependencies = [
"gateway-admin",
"gateway-core",
"hex",
"http 1.5.0",
"http",
"http-body-util",
"hyper",
"hyper-rustls",
@@ -1785,6 +1785,7 @@ dependencies = [
"thiserror",
"tokio",
"tokio-rustls",
"tokio-tungstenite 0.29.0",
"tower-service",
"tracing",
"tracing-appender",
@@ -1809,6 +1810,9 @@ dependencies = [
"gateway-protocol",
"gateway-store",
"hex",
"http",
"http-body",
"http-body-util",
"image",
"jsonschema",
"roxmltree",
@@ -1822,6 +1826,7 @@ dependencies = [
"tempfile",
"thiserror",
"tokio",
"tokio-tungstenite 0.29.0",
"tracing",
"url",
"uuid",
@@ -1843,6 +1848,7 @@ dependencies = [
name = "gateway-protocol"
version = "0.0.0"
dependencies = [
"memchr",
"serde",
"serde_json",
"thiserror",
@@ -1971,7 +1977,7 @@ dependencies = [
"fnv",
"futures-core",
"futures-sink",
"http 1.5.0",
"http",
"indexmap",
"slab",
"tokio",
@@ -2012,14 +2018,14 @@ dependencies = [
[[package]]
name = "headers"
version = "0.4.1"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
checksum = "fc2700e3fe555c3310aa7286cac6167449f2c87e0eb58769c9208a1c58a1d106"
dependencies = [
"base64 0.22.1",
"bytes",
"headers-core",
"http 1.5.0",
"http",
"httpdate",
"mime",
"sha1 0.10.7",
@@ -2031,7 +2037,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
dependencies = [
"http 1.5.0",
"http",
]
[[package]]
@@ -2080,17 +2086,6 @@ dependencies = [
"markup5ever",
]
[[package]]
name = "http"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
dependencies = [
"bytes",
"fnv",
"itoa",
]
[[package]]
name = "http"
version = "1.5.0"
@@ -2101,17 +2096,6 @@ dependencies = [
"itoa",
]
[[package]]
name = "http-body"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
dependencies = [
"bytes",
"http 0.2.12",
"pin-project-lite",
]
[[package]]
name = "http-body"
version = "1.0.1"
@@ -2119,7 +2103,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http 1.5.0",
"http",
]
[[package]]
@@ -2130,8 +2114,8 @@ checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
dependencies = [
"bytes",
"futures-core",
"http 1.5.0",
"http-body 1.0.1",
"http",
"http-body",
"pin-project-lite",
]
@@ -2175,8 +2159,8 @@ dependencies = [
"futures-channel",
"futures-core",
"h2",
"http 1.5.0",
"http-body 1.0.1",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
@@ -2192,7 +2176,7 @@ version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http 1.5.0",
"http",
"hyper",
"hyper-util",
"log",
@@ -2229,8 +2213,8 @@ dependencies = [
"bytes",
"futures-channel",
"futures-util",
"http 1.5.0",
"http-body 1.0.1",
"http",
"http-body",
"hyper",
"ipnet",
"libc",
@@ -2450,9 +2434,9 @@ dependencies = [
[[package]]
name = "jsonschema"
version = "0.56.0"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6a806f80c1f5560431009ce5ec29b59d38f950e0cd7db5f1b8925e6d8104e21"
checksum = "71160ed5f6dbe36a2d6be79f4ca03ee09a99d2866f340faed49458913449aefa"
dependencies = [
"ahash",
"bytecount",
@@ -2478,18 +2462,18 @@ dependencies = [
[[package]]
name = "jsonschema-regex"
version = "0.56.0"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb862addfa7782108933abcf842fe25334523a9df5b89ea4d9620e3dd7b42181"
checksum = "48d2120d8466ffcdc1b3be4b88ff0e9191bf5b6b09b1b1af1eef9aff8f465ea3"
dependencies = [
"regex-syntax",
]
[[package]]
name = "jsonschema-value"
version = "0.56.0"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a05cd404c5ff6e2731dbbf7e290a27417750acfb59e329560a1d0af384c93fb0"
checksum = "3fbfa40a42415369d940b3848f3ce08099f1f1ac04d73e8580f4d652617c4f44"
dependencies = [
"ahash",
"bytecount",
@@ -3175,6 +3159,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.119",
]
[[package]]
name = "primefield"
version = "0.14.0"
@@ -3478,9 +3472,9 @@ dependencies = [
[[package]]
name = "redis"
version = "1.7.0"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acbc41a996f7652b2ddd9dfd98cc4ff602cfd742ae35382f07f608405ab50ed"
checksum = "ed5b98ae99461e02895c3a96a25dbe4d852a3d5e5b815d31d5d9e4b5344d99e9"
dependencies = [
"arc-swap",
"arcstr",
@@ -3534,9 +3528,9 @@ dependencies = [
[[package]]
name = "referencing"
version = "0.56.0"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3b4a92fac7e28c27de3ad26df2ecb9e652f227b258e845af034052e5b22c96c"
checksum = "b06f6798be4fed305e74df8b1fb90cf59c6b8cc17aa3febeb1830c0c6b627b3e"
dependencies = [
"ahash",
"fluent-uri",
@@ -3598,8 +3592,8 @@ dependencies = [
"futures-core",
"futures-util",
"h2",
"http 1.5.0",
"http-body 1.0.1",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
@@ -4486,18 +4480,18 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.20"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.20"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
@@ -4692,8 +4686,8 @@ dependencies = [
"bitflags",
"bytes",
"futures-util",
"http 1.5.0",
"http-body 1.0.1",
"http",
"http-body",
"pin-project-lite",
"tower",
"tower-layer",
@@ -4711,8 +4705,8 @@ dependencies = [
"bytes",
"futures-core",
"futures-util",
"http 1.5.0",
"http-body 1.0.1",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
@@ -4843,7 +4837,7 @@ dependencies = [
"data-encoding",
"flate2",
"headers",
"http 1.5.0",
"http",
"httparse",
"log",
"rand 0.9.4",
@@ -4862,7 +4856,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http 1.5.0",
"http",
"httparse",
"log",
"rand 0.9.4",
@@ -5361,7 +5355,7 @@ dependencies = [
"base64 0.22.1",
"deadpool",
"futures",
"http 1.5.0",
"http",
"http-body-util",
"hyper",
"hyper-util",
+14
View File
@@ -35,6 +35,20 @@ needless_collect = { level = "deny", priority = 7 }
[profile.dev]
debug = true
# 测试保留真实密码参数和压缩数据量,优化热点依赖以减少 fixture 的计算开销。
# test 继承 dev;workspace 代码仍保留默认调试与溢出检查。
[profile.dev.package.argon2]
opt-level = 2
[profile.dev.package.blake2]
opt-level = 2
[profile.dev.package."sha2@0.11.0"]
opt-level = 2
[profile.dev.package."miniz_oxide@0.9.1"]
opt-level = 2
[profile.release]
debug = false
strip = "symbols"
+63 -1
View File
@@ -187,6 +187,14 @@ async fn launch(
core.snapshot_control(),
);
or_shutdown!(plugin_runtime, plugin_runtime.bind_client_key_ports(&keys));
let plugin_resources = gateway_admin::initialize_plugin_resources(
store.admin_ports().plugin_resources(),
core.snapshot_control(),
);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_resource_ports(&plugin_resources)
);
let core = or_shutdown!(plugin_runtime, core.activate().await);
let nested_models = core.nested_model_execution_port();
let affinity_lookup = core.affinity_lookup_port();
@@ -194,6 +202,23 @@ async fn launch(
plugin_runtime,
plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup)
);
let settings = gateway_admin::initialize_settings(
store.admin_ports().settings(),
core.snapshot_control(),
admin_providers,
std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
);
let mut services = gateway_admin::service::Registry::new({
let snapshots = core.snapshots();
let middleware = plugin_runtime.middleware_registry();
std::sync::Arc::new(move || {
let snapshot = snapshots.snapshot_for_diagnostics()?;
middleware.resolve(snapshot.extensions()?)
})
});
or_shutdown!(plugin_runtime, services.register_settings(&settings));
let services = std::sync::Arc::new(services);
or_shutdown!(plugin_runtime, plugin_runtime.bind_services(&services));
let commands = or_shutdown!(plugin_runtime, plugin_runtime.prepare_command_line().await);
or_shutdown!(plugin_runtime, store.start_command_line_writes());
let result = commands
@@ -231,6 +256,14 @@ async fn launch(
plugin_runtime,
plugin_runtime.bind_client_key_ports(&plugin_keys)
);
let plugin_resources = gateway_admin::initialize_plugin_resources(
store.admin_ports().plugin_resources(),
core.snapshot_control(),
);
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_resource_ports(&plugin_resources)
);
let mut core = or_shutdown!(plugin_runtime, core.activate().await);
let nested_models = core.nested_model_execution_port();
let affinity_lookup = core.affinity_lookup_port();
@@ -261,6 +294,14 @@ async fn launch(
client,
store.admin_ports(),
gateway_admin::AdminRuntimePorts {
service_middleware: {
let snapshots = core.snapshots();
let middleware = plugin_runtime.middleware_registry();
std::sync::Arc::new(move || {
let snapshot = snapshots.snapshot_for_diagnostics()?;
middleware.resolve(snapshot.extensions()?)
})
},
providers: admin_providers,
plugin_preparation: plugin_runtime.clone(),
plugin_management: plugin_runtime.clone(),
@@ -314,14 +355,35 @@ async fn launch(
host.worker_health(),
host.connection_lifecycle(),
)
);
)
.with_middleware({
let snapshots = core.snapshots();
let middleware = plugin_runtime.middleware_registry();
move |snapshot| {
if let Some(snapshot) = snapshot {
return middleware.resolve(snapshot.extensions()?);
}
// 数据面未就绪时,管理与诊断路由仍可使用发布候选中的插件。
let diagnostic = snapshots.snapshot_for_diagnostics()?;
middleware.resolve(diagnostic.extensions()?)
}
});
let http_dispatcher = api.dispatcher();
or_shutdown!(plugin_runtime, plugin_runtime.bind_http(&http_dispatcher));
host.report_startup_ready("API");
or_shutdown!(
plugin_runtime,
plugin_runtime.bind_services(&admin.services().public_services())
);
let mut plan = store.take_worker_contributions();
plan.push(gateway_host::retention::worker(store.retention())?);
plan.extend(core.take_worker_contributions());
plan.extend(openai.take_worker_contributions());
plan.extend(xai.take_worker_contributions());
plan.extend(admin.take_worker_contributions());
plan.push(plugin_runtime.maintenance_worker(core.snapshots())?);
or_shutdown!(
plugin_runtime,
host.start_workers(plan, store.worker_leader_lease())
+54 -16
View File
@@ -76,7 +76,12 @@ fn core_value_owners_do_not_depend_on_execution_or_routing() {
"account/selection.rs" => Some(&["account", "concurrency", "identity", "validation"]),
"concurrency.rs" => Some(&["error"]),
"account/store.rs" => Some(&["account", "error", "identity", "validation"]),
path if path.starts_with("policy/") => Some(&["account", "policy", "validation"]),
path if path.starts_with("policy/") => {
Some(&["account", "identity", "policy", "validation"])
}
"settings/values.rs" | "settings/compiled.rs" => {
Some(&["account", "concurrency", "identity", "metering", "policy"])
}
path if path.starts_with("account/") => Some(&["account", "identity", "validation"]),
_ => None,
};
@@ -205,6 +210,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
"plugin_distribution",
"process",
"proxy_probe",
"retention",
"serve",
"system_update",
"workers",
@@ -378,7 +384,9 @@ fn workspace_modules_follow_conventional_file_layout() {
let targets = test_source_roots();
for member in WORKSPACE_MEMBERS {
let member_root = backend_root().join(member);
assert_module_tree(&member_root.join("src"), &["lib.rs", "main.rs"]);
let src = member_root.join("src");
assert_module_tree(&src, &["lib.rs", "main.rs"]);
assert_directory_modules_have_children(&src, &["lib.rs", "main.rs"]);
let tests = member_root.join("tests");
if tests.is_dir() {
@@ -391,6 +399,29 @@ fn workspace_modules_follow_conventional_file_layout() {
}
}
fn assert_directory_modules_have_children(root: &Path, crate_roots: &[&str]) {
let files = super::rust_files(root);
for relative in &files {
if crate_roots
.iter()
.any(|candidate| relative == Path::new(candidate))
|| relative.file_name().and_then(|value| value.to_str()) != Some("mod.rs")
{
continue;
}
let directory = relative.parent().expect("mod.rs parent");
assert!(
files
.iter()
.any(|candidate| candidate != relative && candidate.starts_with(directory)),
"{} is a leaf module and must use {}",
root.join(relative).display(),
root.join(directory).with_extension("rs").display(),
);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ModuleLayout {
File,
@@ -498,6 +529,8 @@ fn root_test_scenario_allowed(member: &str, module: &Path) -> bool {
fn assert_module_tree(root: &Path, crate_roots: &[&str]) {
let files = super::rust_files(root);
// 同一父模块的所有子文件复用一次语法解析,计数仍保留重复声明检查。
let mut declarations = BTreeMap::<PathBuf, BTreeMap<String, usize>>::new();
for relative in &files {
if crate_roots
.iter()
@@ -560,7 +593,14 @@ fn assert_module_tree(root: &Path, crate_roots: &[&str]) {
};
let declaration_count = declaration_parents
.iter()
.map(|path| external_module_declaration_count(path, module_name))
.map(|path| {
declarations
.entry(path.clone())
.or_insert_with(|| external_module_declarations(path))
.get(module_name)
.copied()
.unwrap_or_default()
})
.sum::<usize>();
assert_eq!(
declaration_count,
@@ -571,23 +611,21 @@ fn assert_module_tree(root: &Path, crate_roots: &[&str]) {
}
}
fn external_module_declaration_count(path: &Path, module_name: &str) -> usize {
fn external_module_declarations(path: &Path) -> BTreeMap<String, usize> {
if !path.is_file() {
return 0;
return BTreeMap::new();
}
let source = fs::read_to_string(path).expect("read parent module source");
let syntax = syn::parse_file(&source).expect("parse parent module source");
syntax
.items
.iter()
.filter(|item| {
matches!(
item,
Item::Mod(module)
if module.content.is_none() && module.ident == module_name
)
})
.count()
let mut declarations = BTreeMap::new();
for item in syntax.items {
if let Item::Mod(module) = item
&& module.content.is_none()
{
*declarations.entry(module.ident.to_string()).or_default() += 1;
}
}
declarations
}
pub(super) fn backend_root() -> PathBuf {
+10 -8
View File
@@ -1,7 +1,7 @@
# Codex Proxy Plugin CLI
`cpr-plugin` 校验并规范化作者清单、收集声明的资源,生成可安装归档及 SHA-256 校验文件。
当前输出使用 `manifestVersion: 1`、`protocolVersion: 1` 和 SDK `0.1` 合同。
输出合同与 [SDK 版本](../../crates/gateway-plugin/sdk/README.md)一致
## 安装
@@ -11,7 +11,7 @@
cargo install --locked --path backend/apps/plugin-cli --root /path/to/local-tools
```
将 `/path/to/local-tools/bin` 加入 `PATH`,或使用可执行文件的完整路径。
将 `/path/to/local-tools/bin` 加入 `PATH`,或使用可执行文件的完整路径
## 打包
@@ -26,24 +26,26 @@ cpr-plugin package \
--output-dir /path/to/output
```
资源映射相对于作者清单所在目录解析。输出包括完整插件 ID、版本和目标 triple 命名的 `.tar.gz` 及 `.sha256`;归档根目录直接包含 `plugin.json`、可执行文件和已声明资源,不包含开发依赖。
资源映射相对于作者清单所在目录解析。输出包括完整插件 ID、版本和目标 triple 命名的 `.tar.gz` 及 `.sha256`;归档根目录直接包含 `plugin.json`、可执行文件和已声明资源,不包含开发依赖
作者清单可省略普通贡献项的 `id`、`version` 和固定阶段;`middleware` 的 `request`/`attempt` 仍须显式选择。
作者清单可省略普通贡献项的 `id`、`version` 和固定阶段;`middleware` 必须显式声明版本 `3`,
并从 `http`、`websocket`、`service`、`request`、`attempt` 中选择挂载阶段。
CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共用规范化入口;归档中的
`plugin.json` 写入补全后的贡献项字段,不保留另一份作者声明。
完整规则见 [SDK 清单](../../crates/gateway-plugin/sdk/docs/manifest.md)。
完整规则见 [SDK 清单](../../crates/gateway-plugin/sdk/docs/manifest.md)
支持 `x86_64-unknown-linux-gnu`、`aarch64-unknown-linux-gnu` 和 `aarch64-apple-darwin`。完整开发示例在同级 `codex-proxy-plugins/examples/workbench` 仓库目录;本工具不负责源码构建、上传或启用插件。
支持 `x86_64-unknown-linux-gnu`、`aarch64-unknown-linux-gnu` 和 `aarch64-apple-darwin`。
完整开发示例在独立仓库 `codex-proxy-plugins` 的 `examples/workbench`,可通过[子模块](../../../docs/development.md#源码联调)检出;本工具不负责源码构建、上传或启用插件
### 图标资源
在作者清单的 `icon` 中指定图标,并在 `resources` 中登记对应路径与 MIME 类型,配置方式见
[SDK 插件图标](../../crates/gateway-plugin/sdk/docs/manifest.md#插件图标)。图标与其他声明资源一样参与打包和摘要计算,
无需额外命令参数;工具保留文件原始内容,宿主安装时验证图标内容与资源限制。
无需额外命令参数;工具保留文件原始内容,宿主安装时验证图标内容与资源限制
## 开发
Rust 包名为 `codex-proxy-plugin-cli`,位于宿主的 `backend` workspace,共享工具链、锁文件和检查规则;内部仅依赖 `gateway-plugin-sdk`。
Rust 包名为 `codex-proxy-plugin-cli`,位于宿主的 `backend` workspace,共享工具链、锁文件和检查规则;内部仅依赖 `gateway-plugin-sdk`
```bash
cargo run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli -- package --help
+3 -3
View File
@@ -237,7 +237,7 @@ fn assert_packaged_resources(icon_path: &str, icon_mime: &str, icon: &[u8]) {
fn source_manifest() -> &'static [u8] {
br#"{
"manifestVersion":1,
"manifestVersion":2,
"name":"request-workbench",
"displayName":"Request Workbench",
"publisher":"codex-proxy",
@@ -248,10 +248,10 @@ fn source_manifest() -> &'static [u8] {
"main":"bin/plugin",
"runtime":"trustedProcess",
"contributes":{
"middleware":{"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
"middleware":{"version":3,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
"management":{}
},
"permissions":["requests","accounts"],
"icon":"assets/icon.png",
"resources":{"LICENSE":"text/plain","assets/icon.png":"image/png","web/index.html":"text/html","web/app.js":"text/javascript","web/app.css":"text/css"}
}"#
+3
View File
@@ -36,6 +36,9 @@ chrono-tz = "0.10"
tokio = { version = "1.52", features = ["rt", "time", "macros", "sync"] }
[dev-dependencies]
prettyplease = "0.2"
quote = "1"
syn = { version = "2", features = ["full", "visit-mut"] }
tempfile = "3"
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "test-util"] }
+20 -11
View File
@@ -16,8 +16,8 @@ use tracing::{info, warn};
use crate::{
model::backup::{
BackupError, BackupObjectMetadata, BackupRecord, BackupStatus, BackupStorageConfig,
BackupTriggerKind, build_backup_seed, code,
BackupError, BackupObjectMetadata, BackupRecord, BackupStatus, BackupStatusTransition,
BackupStorageConfig, BackupTriggerKind, build_backup_seed, code,
},
ports::backup::{
BackupObjectStorePort, BackupRepository, DatabaseDumpPort, DumpRequest,
@@ -224,8 +224,7 @@ impl BackupTask {
.repository
.transition_status(
&record.id,
BackupStatus::Dumping,
BackupStatus::Uploading,
status_transition(BackupStatus::Dumping, BackupStatus::Uploading)?,
update,
now,
)
@@ -274,8 +273,7 @@ impl BackupTask {
self.repository
.transition_status(
&record.id,
BackupStatus::Uploading,
BackupStatus::Completed,
status_transition(BackupStatus::Uploading, BackupStatus::Completed)?,
StatusTransitionUpdate::default(),
now,
)
@@ -402,8 +400,7 @@ impl BackupTask {
.repository
.transition_status(
&record.id,
BackupStatus::Dumping,
BackupStatus::Uploading,
status_transition(BackupStatus::Dumping, BackupStatus::Uploading)?,
update,
now,
)
@@ -471,8 +468,7 @@ impl BackupTask {
.repository
.transition_status(
&record.id,
BackupStatus::Uploading,
BackupStatus::Completed,
status_transition(BackupStatus::Uploading, BackupStatus::Completed)?,
StatusTransitionUpdate::default(),
Utc::now(),
)
@@ -555,7 +551,12 @@ impl BackupTask {
};
let _ = self
.repository
.transition_status(&record.id, record.status, BackupStatus::Failed, update, now)
.transition_status(
&record.id,
status_transition(record.status, BackupStatus::Failed)?,
update,
now,
)
.await
.map_err(repo_error)?;
let _ = self.dump.cleanup_staging(&record.id).await;
@@ -639,6 +640,14 @@ fn classify_dump_error(error: BackupError) -> (&'static str, &'static str) {
}
}
fn status_transition(
from: BackupStatus,
to: BackupStatus,
) -> Result<BackupStatusTransition, WorkerTaskError> {
BackupStatusTransition::try_new(from, to)
.ok_or_else(|| WorkerTaskError::safe("backup status transition is invalid"))
}
fn repo_error(error: crate::ports::store::AdminStoreError) -> WorkerTaskError {
WorkerTaskError::safe(format!("backup store failed: {error}"))
}
+44 -8
View File
@@ -20,6 +20,7 @@ pub mod backup;
pub mod freeze_recovery;
pub mod model;
pub mod ports;
pub mod service;
mod use_case;
pub use use_case::plugins::{PluginDistributionPorts, PluginManagementService, PluginsService};
@@ -186,6 +187,7 @@ pub enum AdminConfigError {
/// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块。
#[derive(Clone)]
pub struct AdminServices {
public_services: Arc<service::Registry>,
plugins: Arc<PluginsService>,
plugin_management: Arc<PluginManagementService>,
proxies: Arc<dyn ProxiesService>,
@@ -205,6 +207,10 @@ pub struct AdminServices {
}
impl AdminServices {
pub fn public_services(&self) -> Arc<service::Registry> {
self.public_services.clone()
}
#[must_use]
pub fn plugin_management(&self) -> &PluginManagementService {
&self.plugin_management
@@ -266,7 +272,6 @@ impl AdminServices {
self.observability.as_ref()
}
#[must_use]
pub fn settings(&self) -> &dyn SettingsService {
self.settings.as_ref()
}
@@ -313,6 +318,7 @@ impl AdminBundle {
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。
pub struct AdminRuntimePorts {
pub service_middleware: service::PlanSource,
pub plugin_preparation: Arc<dyn ports::plugins::PluginPreparation>,
pub plugin_management: Arc<dyn ports::plugin_management::PluginManagement>,
pub published_snapshot: gateway_core::runtime::RuntimeSnapshotHandle,
@@ -361,6 +367,7 @@ async fn initialize_inner(
plugin_accounts: Option<Arc<dyn PluginAccountAccess>>,
) -> Result<AdminBundle, AdminError> {
let AdminRuntimePorts {
service_middleware,
plugin_preparation,
plugin_management,
published_snapshot,
@@ -415,6 +422,7 @@ async fn initialize_inner(
let system_preflight = Arc::new(use_case::plugin_update::PluginSystemUpdatePreflight::new(
store.plugins(),
plugin_inspector.clone(),
snapshot.clone(),
));
let system = Arc::new(DefaultSystemService::new(system, system_preflight));
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
@@ -435,7 +443,16 @@ async fn initialize_inner(
});
let import_tasks = use_case::import_tasks::DefaultImportTasksService::new(credentials.clone());
let import_task = use_case::import_tasks::ImportTaskWorker(import_tasks.clone());
let settings = initialize_settings(
store.settings(),
snapshot.clone(),
registry.clone(),
pricing_source,
);
let mut public_services = service::Registry::new(service_middleware);
public_services.register_settings(&settings)?;
let services = AdminServices {
public_services: Arc::new(public_services),
plugin_management: Arc::new(PluginManagementService::new(
plugin_management,
store.plugins(),
@@ -476,12 +493,7 @@ async fn initialize_inner(
store.settings(),
registry.clone(),
)),
settings: Arc::new(DefaultSettingsService::new(
store.settings(),
snapshot.clone(),
registry,
pricing_source,
)),
settings,
system,
credentials,
plugin_accounts,
@@ -527,7 +539,7 @@ pub fn initialize_plugin_accounts(
))
}
/// 为 Runtime 创建只暴露非秘密 Client Key 目录的窄端口。
/// 为 Runtime 创建非秘密 Client Key 目录与预算重置的窄端口。
#[must_use]
pub fn initialize_plugin_client_keys(
providers: ports::provider::ProviderAdminRegistry,
@@ -539,6 +551,15 @@ pub fn initialize_plugin_client_keys(
Arc::new(use_case::plugin_client_keys::DefaultPluginClientKeyAccess::new(service))
}
/// 为 Runtime 组合实例自有资源写入;权限和归属在同一存储事务复核。
#[must_use]
pub fn initialize_plugin_resources(
store: Arc<dyn ports::plugin_resources::PluginResourceStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Arc<dyn ports::plugin_resources::PluginResourceAccess> {
Arc::new(use_case::plugin_resources::DefaultPluginResourceAccess { store, snapshot })
}
/// Backup Worker 注册:单个可取消 Daemon,owner 固定为 `backup`。
fn backup_worker_contribution(
task: backup::task::BackupTask,
@@ -588,3 +609,18 @@ fn freeze_recovery_worker_contribution(
.map_err(|_| AdminError::internal("冻结恢复 Worker 注册信息不合法"))?;
Ok(vec![WorkerContribution::Registration(registration)])
}
/// 设置服务不依赖 Web 管理会话,CLI 与服务器通过同一用例执行事务和发布。
pub fn initialize_settings(
store: Arc<dyn ports::store::SettingsStore>,
snapshot: Arc<dyn SnapshotControl>,
providers: ProviderAdminRegistry,
pricing_source: Arc<dyn ports::pricing::PricingSource>,
) -> Arc<dyn SettingsService> {
Arc::new(DefaultSettingsService::new(
store,
snapshot,
providers,
pricing_source,
))
}
@@ -223,6 +223,16 @@ pub struct AccountPage {
pub struct AccountPageItem {
pub account: AccountRecord,
pub projection: AccountStatusProjection,
pub capacity: AccountCapacity,
}
/// 网关配置的账号并发上限与查询时的占用,不包含排队请求或上游隐藏限制。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountCapacity {
/// 实时租约读取失败时为 `None`,不能当作空闲。
pub used_slots: Option<u64>,
/// 应用账号覆盖或全局默认值后的上限;`None` 表示不限。
pub total_slots: Option<u64>,
}
/// 统一账号目录的全局状态计数,不受当前筛选和分页影响。
@@ -0,0 +1,153 @@
//! 管理写入的审计分类;适配器只能补充事务实际写入的字段与实体标识。
use super::{
MutationActor, MutationContext,
auth::{AdminAuditEvent, AuditActorKind, admin_session_actor_ref},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MutationAuditOperation {
ModelPricingSync,
ModelPricingUpdate,
RuntimeSettingsReplace,
AdminApiKeyChanged { exists: bool },
AccountGroupCreate,
AccountGroupUpdate,
AccountGroupEnabled { enabled: bool },
AccountGroupDelete,
ClientApiKeyResetBudget,
ProviderAccountUpdate,
BackupStorageUpdate,
BackupScheduleUpdate,
ProviderAccountAdaptConcurrency,
ProviderAccountRecover,
ProviderAccountBatchUpdate,
ProviderAccountDelete,
ProviderAccountExportCredentials,
PluginArtifactInstall,
PluginArtifactAccept,
PluginArtifactDelete,
PluginInstanceConfigure,
PluginInstanceDelete,
PluginSourceCredentialCreate,
PluginSourceCredentialDelete,
ClientApiKeyUpdateBudgetLimits,
ClientApiKeyCreate,
ClientApiKeyUpdate,
ClientApiKeyEnabled { enabled: bool },
ClientApiKeyDelete,
PluginSourceChangeSource,
PluginSourceDelete,
OutboundProxyCreate,
OutboundProxyUpdate,
OutboundProxyDelete,
OutboundProxyTest,
AccountGroupPluginReconcile,
ClientApiKeyPluginReconcile,
ProviderAccountImportDocument,
ProviderAccountRotateCredential,
ProviderAccountRefreshCredential,
ProviderAccountAuthorize,
ProviderAccountReauthorize,
}
impl MutationAuditOperation {
#[must_use]
pub const fn classification(self) -> (&'static str, &'static str) {
match self {
Self::ModelPricingSync => ("pricing.sync", "model_pricing"),
Self::ModelPricingUpdate => ("pricing.update", "model_pricing"),
Self::RuntimeSettingsReplace => ("settings.replace", "runtime_settings"),
Self::AdminApiKeyChanged { exists } => (
if exists {
"admin_api_key.replace"
} else {
"admin_api_key.delete"
},
"runtime_settings",
),
Self::AccountGroupCreate => ("create", "account_group"),
Self::AccountGroupUpdate => ("update", "account_group"),
Self::AccountGroupEnabled { enabled } => {
(if enabled { "enable" } else { "disable" }, "account_group")
}
Self::AccountGroupDelete => ("delete", "account_group"),
Self::ClientApiKeyResetBudget => ("reset_budget", "client_api_key"),
Self::ProviderAccountUpdate => ("update", "provider_account"),
Self::BackupStorageUpdate => ("backup.s3_config_updated", "backup_settings"),
Self::BackupScheduleUpdate => ("backup.schedule_updated", "backup_settings"),
Self::ProviderAccountAdaptConcurrency => ("adapt_concurrency", "provider_account"),
Self::ProviderAccountRecover => ("recover", "provider_account"),
Self::ProviderAccountBatchUpdate => ("batch_update", "provider_account"),
Self::ProviderAccountDelete => ("delete", "provider_account"),
Self::ProviderAccountExportCredentials => ("export_credentials", "provider_account"),
Self::PluginArtifactInstall => ("install", "plugin_artifact"),
Self::PluginArtifactAccept => ("accept", "plugin_artifact"),
Self::PluginArtifactDelete => ("delete", "plugin_artifact"),
Self::PluginInstanceConfigure => ("configure", "plugin_instance"),
Self::PluginInstanceDelete => ("delete", "plugin_instance"),
Self::PluginSourceCredentialCreate => ("create", "plugin_source_credential"),
Self::PluginSourceCredentialDelete => ("delete", "plugin_source_credential"),
Self::ClientApiKeyUpdateBudgetLimits => ("update_budget_limits", "client_api_key"),
Self::ClientApiKeyCreate => ("create", "client_api_key"),
Self::ClientApiKeyUpdate => ("update", "client_api_key"),
Self::ClientApiKeyEnabled { enabled } => {
(if enabled { "enable" } else { "disable" }, "client_api_key")
}
Self::ClientApiKeyDelete => ("delete", "client_api_key"),
Self::PluginSourceChangeSource => ("change_source", "plugin_source"),
Self::PluginSourceDelete => ("delete", "plugin_source"),
Self::OutboundProxyCreate => ("create", "outbound_proxy"),
Self::OutboundProxyUpdate => ("update", "outbound_proxy"),
Self::OutboundProxyDelete => ("delete", "outbound_proxy"),
Self::OutboundProxyTest => ("test", "outbound_proxy"),
Self::AccountGroupPluginReconcile => ("plugin_reconcile", "account_group"),
Self::ClientApiKeyPluginReconcile => ("plugin_reconcile", "client_api_key"),
Self::ProviderAccountImportDocument => ("import_document", "provider_account"),
Self::ProviderAccountRotateCredential => ("rotate_credential", "provider_account"),
Self::ProviderAccountRefreshCredential => ("refresh_credential", "provider_account"),
Self::ProviderAccountAuthorize => ("authorize", "provider_account"),
Self::ProviderAccountReauthorize => ("reauthorize", "provider_account"),
}
}
}
/// 分类与稳定实体标识共同组成审计意图,字段差异由同一提交事务补齐。
#[derive(Debug, Clone, Copy)]
pub struct MutationAuditIntent<'a> {
pub operation: MutationAuditOperation,
pub entity_ref: &'a str,
}
impl MutationAuditIntent<'_> {
#[must_use]
pub fn event(self, context: &MutationContext, changed_fields: Vec<String>) -> AdminAuditEvent {
let (actor_kind, actor_admin_user_id, actor_ref) = match &context.actor {
MutationActor::AdminSession { admin_user_id } => (
AuditActorKind::AdminSession,
Some(admin_user_id.clone()),
admin_session_actor_ref(admin_user_id),
),
MutationActor::AdminApiKey => (
AuditActorKind::AdminApiKey,
None,
"admin_api_key".to_owned(),
),
MutationActor::System => (AuditActorKind::System, None, "system".to_owned()),
};
let (action, entity_kind) = self.operation.classification();
AdminAuditEvent {
id: format!("audit_{}", uuid::Uuid::now_v7().simple()),
actor_kind,
actor_admin_user_id,
actor_ref,
request_id: Some(context.request_id.clone()),
action: action.to_owned(),
entity_kind: entity_kind.to_owned(),
entity_ref: self.entity_ref.to_owned(),
config_revision: None,
changed_fields,
occurred_at: chrono::Utc::now(),
}
}
}
@@ -14,8 +14,14 @@ pub fn admin_session_actor_ref(admin_user_id: &str) -> String {
/// 已认证的管理主体。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdminPrincipal {
Session { admin_user_id: String },
Session {
admin_user_id: String,
},
ApiKey,
/// 安装者完整信任的插件;身份仅由内部 HTTP 分派端口签发。
Plugin {
instance_id: String,
},
}
/// 传给管理用例的安全请求上下文。
@@ -33,6 +39,7 @@ impl AdminRequestContext {
admin_user_id: admin_user_id.clone(),
},
AdminPrincipal::ApiKey => MutationActor::AdminApiKey,
AdminPrincipal::Plugin { .. } => MutationActor::System,
};
MutationContext {
actor,
@@ -124,6 +124,33 @@ impl fmt::Display for BackupStatus {
}
}
/// 已通过领域状态机校验的显式迁移。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BackupStatusTransition {
from: BackupStatus,
to: BackupStatus,
}
impl BackupStatusTransition {
/// 校验并构造显式迁移;非法状态边返回 `None`。
#[must_use]
pub fn try_new(from: BackupStatus, to: BackupStatus) -> Option<Self> {
from.allows_transition_to(to).then_some(Self { from, to })
}
/// 迁移要求的当前状态。
#[must_use]
pub const fn from(self) -> BackupStatus {
self.from
}
/// 迁移完成后的目标状态。
#[must_use]
pub const fn to(self) -> BackupStatus {
self.to
}
}
/// S3 存储配置更新命令;`secret_access_key` 为 `None` 表示保留旧值。
#[derive(Clone)]
pub struct UpdateBackupStorageCommand {
@@ -10,7 +10,10 @@ use gateway_core::{
routing::{AccountGroupId, ProviderKind},
};
use super::{AdminModelError, Revision, account_groups::AccountGroupRef};
use super::{
AdminModelError, Revision, account_groups::AccountGroupRef,
plugin_resources::PluginResourceOwner,
};
pub type ProviderRequestProfileOverrides =
BTreeMap<ProviderKind, gateway_core::account::OpaqueProviderData>;
@@ -219,6 +222,21 @@ pub struct ResetClientKeyBudget {
pub period: ClientKeyBudgetPeriod,
}
/// 仅更新指定金额上限;省略的周期保持不变,不修改已用金额或窗口。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateClientKeyBudgetLimits {
pub id: ClientApiKeyId,
pub daily_limit_usd: Option<gateway_core::metering::Decimal>,
pub weekly_limit_usd: Option<gateway_core::metering::Decimal>,
}
/// 预算变更的调用来源;插件身份由 Runtime 给出,不能从插件请求反序列化。
#[derive(Debug, Clone)]
pub enum ClientKeyBudgetMutationOrigin {
Admin,
Plugin(PluginResourceOwner),
}
/// 删除 Client Key。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteClientKey {
+11 -3
View File
@@ -4,6 +4,7 @@ use std::num::{NonZeroU16, NonZeroU64};
pub mod account_groups;
pub mod accounts;
pub mod audit;
pub mod auth;
pub mod backup;
pub mod client_distribution;
@@ -12,12 +13,14 @@ pub mod import_tasks;
pub mod key_usage;
pub mod observability;
pub mod plugin_client_keys;
pub mod plugin_resources;
pub mod plugins;
pub mod pricing;
pub mod provider_credentials;
pub mod proxies;
pub mod quota_forecast;
pub mod quota_forecast_sampling;
pub mod retention;
pub mod settings;
pub mod system;
@@ -106,7 +109,10 @@ impl AdminError {
}
/// PostgreSQL 中所有正整数 revision 的管理层表示。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
#[serde(transparent)]
pub struct Revision(NonZeroU64);
impl Revision {
@@ -155,7 +161,8 @@ impl PageSize {
}
/// 可审计管理写操作的发起者。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum MutationActor {
AdminSession { admin_user_id: String },
AdminApiKey,
@@ -163,7 +170,8 @@ pub enum MutationActor {
}
/// 管理写操作必须携带的审计上下文。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct MutationContext {
pub actor: MutationActor,
pub request_id: String,
@@ -558,6 +558,9 @@ pub struct UsageListRecord {
pub provider_account_email: Option<String>,
/// 账号当前备注,按内部账号 ID 关联,不属于请求历史快照。
pub provider_account_notes: Option<String>,
/// 账号当前套餐,不属于请求历史快照。
pub provider_account_plan_type: Option<String>,
pub provider_account_plan_type_display: Option<String>,
pub provider_account_authentication_kind: Option<String>,
pub upstream_model_id: Option<String>,
pub upstream_transport: Option<String>,
@@ -757,11 +760,20 @@ pub struct UsageSummary {
pub average_latency_ms: Option<u64>,
}
/// 诊断聚合结果,分母包含截取展示项之前的全部匹配请求。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DiagnosticsObservation {
pub total_request_count: u64,
pub items: Vec<DiagnosticObservation>,
}
/// 单个诊断维度值的聚合结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagnosticObservation {
pub key: String,
pub name: String,
pub account_provider_kind: Option<String>,
pub account_plan_type: Option<String>,
pub request_count: u64,
pub success_count: u64,
pub failure_count: u64,
@@ -772,6 +784,7 @@ pub struct DiagnosticObservation {
pub first_token_p95_ms: Option<u64>,
pub non_completion_count: u64,
pub retry_count: u64,
pub retried_request_count: u64,
pub cost_coverage: CostCoverage,
pub costs: Vec<CurrencyCost>,
}
@@ -1142,6 +1155,8 @@ pub struct UsageInsights {
pub struct DiagnosticsItem {
pub key: String,
pub name: String,
pub account_plan_type: Option<String>,
pub account_plan_type_display: Option<String>,
pub request_count: u64,
pub success_count: u64,
pub error_count: u64,
@@ -1154,7 +1169,6 @@ pub struct DiagnosticsItem {
pub non_completion_rate: f64,
pub retry_count: u64,
pub retry_rate: f64,
pub impact_score: f64,
pub estimated_cost: Option<DecimalAmount>,
pub attempt_count: u64,
pub total_tokens: u64,
@@ -1,9 +1,18 @@
//! 插件可见的 Client Key 非秘密资源投影。
use gateway_core::policy::ClientApiKeyId;
use gateway_core::routing::AccountGroupId;
use super::PageSize;
/// 插件可读取的当前 Key 范围,不含密钥或配置秘密。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginClientKeyFacts {
pub id: ClientApiKeyId,
pub enabled: bool,
pub group_ids: Vec<AccountGroupId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginClientKeyCursor {
pub name: String,
@@ -0,0 +1,42 @@
//! 插件资源归属由宿主调用上下文给出,不能由插件参数选择实例或代次。
use super::Revision;
#[derive(Debug, Clone)]
pub struct PluginResourceOwner {
pub instance_id: String,
pub artifact_sha256: String,
pub revision: Revision,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManagedResource {
pub id: String,
pub name: String,
pub enabled: bool,
}
pub struct ResourceMutation<T> {
/// 没有事实变化时不递增配置 revision,也不触发发布。
pub revision: Option<Revision>,
pub value: T,
}
pub struct GroupMembersChange {
pub resource_key: String,
pub add: Vec<String>,
pub remove: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GroupMembersChanged {
pub added: u64,
pub removed: u64,
}
/// 插件 Key 的首次创建设置;分组始终从本实例资源键解析。
pub struct ManagedKeyConfig {
pub name: String,
pub limits: gateway_core::policy::RateLimits,
pub budget: gateway_core::engine::budget::ClientBudgetLimits,
}
@@ -5,12 +5,6 @@ use serde::{Deserialize, Serialize};
use crate::model::Revision;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PluginPermissionGrant {
pub permission: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginFailurePolicy {
@@ -26,6 +20,9 @@ pub struct PluginCapabilityBinding {
pub stage: String,
pub order: i32,
pub failure_policy: PluginFailurePolicy,
/// 观察绑定选择事件类型;其他能力不设置事件。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub event: Option<String>,
#[serde(default)]
pub client_key_ids: Vec<String>,
#[serde(default)]
@@ -66,7 +63,6 @@ pub struct PluginInstance {
pub trusted_process: bool,
pub configuration: serde_json::Value,
pub secrets: BTreeMap<String, SecretString>,
pub grants: Vec<PluginPermissionGrant>,
pub bindings: Vec<PluginCapabilityBinding>,
pub revision: Revision,
}
@@ -115,6 +111,7 @@ pub struct PluginInstanceRuntime {
pub struct PluginInstanceView {
pub instance: PluginInstance,
pub configuration_required: bool,
pub compatibility_warning: Option<String>,
pub running: bool,
pub published_revision: Option<u64>,
pub runtime: PluginInstanceRuntime,
@@ -78,6 +78,7 @@ pub struct PluginManagementRequest {
pub path: String,
pub query: String,
pub content_type: Option<String>,
pub headers: Vec<gateway_core::engine::middleware::MiddlewareHeader>,
pub body: Vec<u8>,
pub request_id: String,
}
@@ -85,5 +86,6 @@ pub struct PluginManagementRequest {
pub struct PluginManagementResponse {
pub status: u16,
pub content_type: String,
pub headers: Vec<gateway_core::engine::middleware::MiddlewareHeader>,
pub body: Arc<[u8]>,
}
@@ -29,19 +29,17 @@ pub struct PluginHostCompatibility {
pub manifest_schema_versions: Vec<u32>,
pub protocol_versions: Vec<u32>,
pub capabilities: Vec<PluginHostCapability>,
pub permissions: Vec<String>,
}
impl PluginHostCompatibility {
/// 拒绝空集合、重复项和不受控标识,避免发行清单把“未知”解释为通配。
#[must_use]
pub fn is_valid(&self) -> bool {
if self.schema_version != 1
if self.schema_version != 2
|| !valid_versions(&self.manifest_schema_versions)
|| !valid_versions(&self.protocol_versions)
|| self.capabilities.is_empty()
|| self.capabilities.len() > 64
|| self.permissions.len() > 64
{
return false;
}
@@ -53,10 +51,7 @@ impl PluginHostCompatibility {
}) {
return false;
}
let mut permissions = BTreeSet::new();
!self.permissions.iter().any(|permission| {
!valid_identifier(permission) || !permissions.insert(permission.as_str())
})
true
}
#[must_use]
@@ -65,11 +60,6 @@ impl PluginHostCompatibility {
.iter()
.any(|entry| entry.capability == capability && entry.versions.contains(&version))
}
#[must_use]
pub fn supports_permission(&self, permission: &str) -> bool {
self.permissions.iter().any(|entry| entry == permission)
}
}
fn valid_versions(versions: &[u32]) -> bool {
@@ -118,7 +108,6 @@ pub struct PluginCompatibilityRequirements {
pub manifest_schema_version: u32,
pub protocol_version: u32,
pub capabilities: Vec<(String, u32)>,
pub permissions: Vec<String>,
}
/// 一次下载使用的非敏感出站代理身份;地址与认证由受管代理记录持有。
@@ -177,12 +166,15 @@ impl PluginSource {
/// 管理端保留的单项贡献声明;map key 是能力标识,`id` 是实例绑定引用的扩展项身份。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[serde(rename_all = "camelCase")]
pub struct PluginContribution {
pub id: String,
pub version: u32,
#[serde(default)]
pub stages: Vec<String>,
#[serde(default)]
pub input_formats: Vec<String>,
#[serde(default)]
pub output_formats: Vec<String>,
}
@@ -195,7 +187,6 @@ pub enum PluginArtifactIcon {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginArtifactIconVariants {
pub light: String,
pub dark: String,
@@ -214,17 +205,10 @@ pub struct PluginArtifactIconResource {
pub body: Vec<u8>,
}
/// 管理端使用的静态描述,不承载 SDK 的运行时消息或宿主实现类型。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PluginPermissionDescription {
pub permission: String,
pub label: String,
pub description: String,
}
/// 已校验制品的持久化投影;读取时忽略未知字段,可选字段缺省沿用清单语义。
/// 身份与执行所需事实仍为必填,包清单及协议版本由 Runtime 独立校验。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[serde(rename_all = "camelCase")]
pub struct PluginArtifactMetadata {
pub plugin_id: String,
pub version: String,
@@ -239,13 +223,18 @@ pub struct PluginArtifactMetadata {
#[serde(default)]
pub icon: Option<PluginArtifactIcon>,
pub contributes: BTreeMap<String, PluginContribution>,
pub requested_permissions: Vec<String>,
#[serde(default = "empty_configuration_schema")]
pub configuration_schema: serde_json::Value,
#[serde(default)]
pub secret_fields: Vec<String>,
#[serde(default)]
pub state_namespaces: Vec<state::PluginStateSchema>,
}
fn empty_configuration_schema() -> serde_json::Value {
serde_json::json!({})
}
/// 由包检查端口产生的不可变制品;不派生 Debug,包内可能含敏感用户数据。
#[derive(Clone)]
pub struct InspectedPluginArtifact {
@@ -6,7 +6,7 @@ use crate::model::Revision;
/// 已由 Runtime 校验的命名空间 schema 与配额事实。
#[derive(Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[serde(rename_all = "camelCase")]
pub struct PluginStateSchema {
pub namespace: String,
pub schema_version: u32,
@@ -3,7 +3,7 @@
use gateway_core::metering::{ModelPriceOverride, PricingOverrides};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PricingCatalog {
pub defaults: PricingOverrides,
pub overrides: PricingOverrides,
@@ -27,6 +27,7 @@ pub struct PricingSyncPreview {
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[derive(serde::Serialize)]
pub struct SyncPricing {
pub preview: PricingSyncPreview,
pub models: BTreeMap<String, BTreeSet<String>>,
@@ -34,7 +35,7 @@ pub struct SyncPricing {
pub type PricingSyncChanges = BTreeMap<String, BTreeMap<String, Option<ModelPriceOverride>>>;
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum PricingChange {
Replace(ModelPriceOverride),
Multiplier(u32),
@@ -42,7 +43,7 @@ pub enum PricingChange {
Delete,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct UpdatePricing {
pub provider: String,
pub models: Vec<String>,
@@ -1227,6 +1227,7 @@ impl fmt::Debug for ProviderExport {
#[derive(Debug, Clone, PartialEq)]
pub struct AccountDirectoryItem {
pub account: AccountRecord,
pub capacity: super::accounts::AccountCapacity,
pub capabilities: super::accounts::ProviderAccountCapabilities,
/// Provider 提供的套餐展示名称;未识别到套餐时为空。
pub plan_type_display: Option<String>,
@@ -28,9 +28,6 @@ pub struct AccountQuotaForecast {
pub incomplete_tokens: bool,
pub estimated_tokens: Option<u64>,
pub estimated_usd: Option<f64>,
/// 剩余估算始终属于源窗口,不随目标周期折算。
pub remaining_tokens: Option<u64>,
pub remaining_usd: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
@@ -81,8 +78,6 @@ pub fn account_quota_forecasts(
incomplete_tokens: false,
estimated_tokens: None,
estimated_usd: None,
remaining_tokens: None,
remaining_usd: None,
};
if let Some((window, source_period)) = selected {
forecast.project(
@@ -191,14 +186,13 @@ impl AccountQuotaForecast {
let remaining_tokens = Some(sample.usage.tokens)
.filter(|tokens| *tokens > 0)
.and_then(|value| estimate(value as f64, remaining_factor));
self.remaining_tokens = remaining_tokens.and_then(|value| estimate_tokens(value, 1.0));
self.estimated_tokens = remaining_tokens
.and_then(|remaining| estimate_tokens(usage.tokens as f64 + remaining, factor));
self.remaining_usd = Some(&sample.usage)
let remaining_usd = Some(&sample.usage)
.filter(|usage| usage.known_cost_count > 0 && usage.usd.is_finite() && usage.usd >= 0.0)
.and_then(|usage| estimate(usage.usd, remaining_factor));
self.estimated_usd = usd
.zip(self.remaining_usd)
.zip(remaining_usd)
.and_then(|(used, remaining)| estimate(used + remaining, factor));
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
{
@@ -0,0 +1,41 @@
//! 历史数据保留规则;存储实现只接受已校验的窗口。
use super::AdminError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RetentionTarget {
ModelRequests,
OpsEvents,
AdminAuditEvents,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetentionPolicy {
usage_days: u32,
ops_days: u32,
audit_days: u32,
}
impl RetentionPolicy {
pub fn try_new(usage_days: u32, ops_days: u32, audit_days: u32) -> Result<Self, AdminError> {
if usage_days < 31 || ops_days == 0 || audit_days == 0 {
return Err(AdminError::invalid(
"请求保留期至少 31 天,事件与审计保留期必须为正数",
));
}
Ok(Self {
usage_days,
ops_days,
audit_days,
})
}
#[must_use]
pub const fn days(self, target: RetentionTarget) -> u32 {
match target {
RetentionTarget::ModelRequests => self.usage_days,
RetentionTarget::OpsEvents => self.ops_days,
RetentionTarget::AdminAuditEvents => self.audit_days,
}
}
}
@@ -23,7 +23,8 @@ pub type ProviderRequestProfileUpdates =
pub use gateway_core::account::RotationStrategy;
/// 完整运行设置事实。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeSettings {
pub request_profiles: ProviderRequestProfiles,
pub config_revision: Revision,
@@ -38,6 +39,7 @@ pub struct RuntimeSettings {
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
@@ -58,8 +60,11 @@ pub struct RuntimeSettings {
}
/// 原子替换运行设置的命令。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReplaceRuntimeSettings {
/// 读取设置时的版本;与写入在同一事务内比较,防止覆盖并发更新。
pub expected_revision: Revision,
/// 只覆盖提交的 Provider;未提交项保留当前持久值。
pub request_profile_updates: ProviderRequestProfileUpdates,
pub request_location_enabled: bool,
@@ -73,6 +78,7 @@ pub struct ReplaceRuntimeSettings {
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
@@ -92,7 +98,8 @@ pub struct ReplaceRuntimeSettings {
}
/// 明文管理员 API Key;按产品约束明文落库,但禁止 Debug 泄漏。
#[derive(Clone, PartialEq, Eq)]
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
pub struct AdminApiKey(String);
impl AdminApiKey {
@@ -120,13 +127,16 @@ impl fmt::Debug for AdminApiKey {
}
/// 管理员 API Key 更新结果。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AdminApiKeyMutation {
pub config_revision: Revision,
pub exists: bool,
}
/// 新管理员 API Key 的一次性返回结果。
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RegeneratedAdminApiKey {
pub mutation: AdminApiKeyMutation,
pub key: AdminApiKey,
@@ -152,3 +162,45 @@ impl RuntimeSettings {
.and_then(|provider| self.request_profiles.get(&provider))
}
}
impl From<RuntimeSettings> for ReplaceRuntimeSettings {
fn from(settings: RuntimeSettings) -> Self {
Self {
expected_revision: settings.config_revision,
request_profile_updates: settings
.request_profiles
.into_iter()
.map(|(provider, value)| (provider, Some(value)))
.collect(),
request_location_enabled: settings.request_location_enabled,
request_location: settings.request_location,
model_mappings: settings.model_mappings,
refresh_margin_seconds: settings.refresh_margin_seconds,
refresh_concurrency: settings.refresh_concurrency,
max_concurrent_per_account: settings.max_concurrent_per_account,
request_interval_ms: settings.request_interval_ms,
max_waiting_per_key: settings.max_waiting_per_key,
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
smart_scheduling: settings.smart_scheduling,
rotation_strategy: settings.rotation_strategy,
min_codex_desktop_version: settings.min_codex_desktop_version,
min_codex_cli_version: settings.min_codex_cli_version,
usage_retention_days: settings.usage_retention_days,
ops_event_retention_days: settings.ops_event_retention_days,
audit_retention_days: settings.audit_retention_days,
account_auto_freeze_enabled: settings.account_auto_freeze_enabled,
account_auto_freeze_threshold: settings.account_auto_freeze_threshold,
account_auto_freeze_window_seconds: settings.account_auto_freeze_window_seconds,
account_auto_freeze_duration_seconds: settings.account_auto_freeze_duration_seconds,
account_auto_freeze_probe_enabled: settings.account_auto_freeze_probe_enabled,
account_auto_freeze_probe_model: settings.account_auto_freeze_probe_model,
account_auto_freeze_adaptive_concurrency: settings
.account_auto_freeze_adaptive_concurrency,
account_warmup_enabled: settings.account_warmup_enabled,
account_warmup_schedule_time: settings.account_warmup_schedule_time,
account_warmup_model: settings.account_warmup_model,
}
}
}
@@ -162,3 +162,21 @@ impl SystemOperationAccepted {
}
}
}
/// 重启前的只读确认快照;目标发行与全局配置版本一起绑定用户确认。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SystemRestartPlan {
pub target_version: Option<String>,
pub release_manifest_sha256: Option<String>,
pub config_revision: u64,
pub incompatible_plugins: Vec<SystemIncompatiblePlugin>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SystemIncompatiblePlugin {
pub instance_id: String,
pub name: String,
pub reason: String,
}
@@ -17,8 +17,8 @@ use crate::model::{
MutationContext,
backup::{
BackupError, BackupObjectMetadata, BackupRecord, BackupRecordListQuery, BackupRecordPage,
BackupRecordSeed, BackupSettings, BackupStatus, BackupStorageConfig, ConnectionTestResult,
UpdateBackupScheduleCommand, UpdateBackupStorageCommand,
BackupRecordSeed, BackupSettings, BackupStatusTransition, BackupStorageConfig,
ConnectionTestResult, UpdateBackupScheduleCommand, UpdateBackupStorageCommand,
},
};
use crate::ports::store::AdminStoreResult;
@@ -96,8 +96,7 @@ pub trait BackupRepository: Send + Sync {
async fn transition_status(
&self,
id: &str,
from: BackupStatus,
to: BackupStatus,
transition: BackupStatusTransition,
update: StatusTransitionUpdate,
now: DateTime<Utc>,
) -> AdminStoreResult<Option<BackupRecord>>;
@@ -333,8 +332,7 @@ impl BackupRepository for UnavailableBackupRepository {
async fn transition_status(
&self,
_id: &str,
_from: BackupStatus,
_to: BackupStatus,
_transition: BackupStatusTransition,
_update: StatusTransitionUpdate,
_now: DateTime<Utc>,
) -> AdminStoreResult<Option<BackupRecord>> {
@@ -6,9 +6,11 @@ pub mod plugin_accounts;
pub mod plugin_client_keys;
pub mod plugin_management;
pub mod plugin_release;
pub mod plugin_resources;
pub mod plugins;
pub mod pricing;
pub mod provider;
pub mod proxy;
pub mod retention;
pub mod store;
pub mod system;
@@ -34,6 +34,14 @@ pub trait PluginAccountAccess: Send + Sync {
Err(AdminError::unavailable("插件额度事实查询暂不可用"))
}
/// 通过 Provider 原生管理路径刷新额度;不向插件暴露凭据或执行额度重置。
async fn refresh_quota(
&self,
_account_id: &ProviderAccountId,
) -> Result<crate::model::provider_credentials::ProviderQuota, AdminError> {
Err(AdminError::unavailable("插件额度观测刷新暂不可用"))
}
async fn save(
&self,
command: PreparedPluginAccountSave,
@@ -1,14 +1,35 @@
//! 插件发现 Client Key 公开身份的窄端口。
//! 插件 Client Key 非秘密目录与受控预算管理端口。
use async_trait::async_trait;
use gateway_core::{engine::budget::ClientBudgetStatus, policy::ClientApiKeyId};
use crate::model::{
AdminError,
plugin_client_keys::{PluginClientKeyListQuery, PluginClientKeyPage},
AdminError, MutationContext,
client_keys::{ResetClientKeyBudget, UpdateClientKeyBudgetLimits},
plugin_client_keys::{PluginClientKeyFacts, PluginClientKeyListQuery, PluginClientKeyPage},
plugin_resources::PluginResourceOwner,
};
#[async_trait]
pub trait PluginClientKeyAccess: Send + Sync {
async fn facts(&self, id: &ClientApiKeyId) -> Result<PluginClientKeyFacts, AdminError>;
async fn budget(&self, id: &ClientApiKeyId) -> Result<ClientBudgetStatus, AdminError>;
async fn update_budget_limits(
&self,
owner: &PluginResourceOwner,
command: UpdateClientKeyBudgetLimits,
context: &MutationContext,
) -> Result<ClientApiKeyId, AdminError>;
async fn reset_budget(
&self,
owner: &PluginResourceOwner,
command: ResetClientKeyBudget,
context: &MutationContext,
) -> Result<ClientApiKeyId, AdminError>;
async fn list(
&self,
query: PluginClientKeyListQuery,
@@ -12,8 +12,8 @@ use crate::model::{
/// 仅按 Admin 固定的发布引用分派;Runtime 不自行选择当前代次,也不拥有管理员身份。
#[async_trait]
pub trait PluginManagement: Send + Sync {
/// 复核固定发布代次中的模型访问域;调用方已校验持久实例仍为当前目标。
async fn authorize_models(
/// 复核固定发布代次中的可用目标;调用方已校验持久实例仍为当前版本。
async fn validate_target(
&self,
published: &ExtensionSetReference,
target: &PluginManagementTarget,
@@ -0,0 +1,63 @@
//! 插件自有资源的管理入口与原子持久化端口。
use super::store::AdminStoreResult;
use crate::model::{
AdminError, MutationContext,
account_groups::{CreateAccountGroup, NewAccountGroup},
client_keys::NewClientKey,
plugin_resources::{
GroupMembersChange, GroupMembersChanged, ManagedKeyConfig, ManagedResource,
PluginResourceOwner, ResourceMutation,
},
};
use async_trait::async_trait;
#[async_trait]
pub trait PluginResourceAccess: Send + Sync {
async fn ensure_group(
&self,
owner: &PluginResourceOwner,
resource_key: String,
command: CreateAccountGroup,
context: &MutationContext,
) -> Result<ManagedResource, AdminError>;
async fn ensure_key(
&self,
owner: &PluginResourceOwner,
resource_key: String,
groups: Vec<String>,
command: ManagedKeyConfig,
context: &MutationContext,
) -> Result<ManagedResource, AdminError>;
async fn change_members(
&self,
owner: &PluginResourceOwner,
command: GroupMembersChange,
context: &MutationContext,
) -> Result<GroupMembersChanged, AdminError>;
}
#[async_trait]
pub trait PluginResourceStore: Send + Sync {
async fn ensure_group(
&self,
owner: &PluginResourceOwner,
resource_key: String,
command: NewAccountGroup,
context: &MutationContext,
) -> AdminStoreResult<ResourceMutation<ManagedResource>>;
async fn ensure_key(
&self,
owner: &PluginResourceOwner,
resource_key: String,
groups: Vec<String>,
command: NewClientKey,
context: &MutationContext,
) -> AdminStoreResult<ResourceMutation<ManagedResource>>;
async fn change_members(
&self,
owner: &PluginResourceOwner,
command: GroupMembersChange,
context: &MutationContext,
) -> AdminStoreResult<ResourceMutation<GroupMembersChanged>>;
}
@@ -28,7 +28,7 @@ use gateway_core::runtime::extensions::ExtensionSetReference;
/// Admin 准备完整候选并保活到提交后的发布结束;进程状态不能代替持久启用状态。
#[async_trait]
pub trait PluginPreparation: Send + Sync {
pub trait PluginPreparation: PluginRuntimeDiagnostics + PluginStateLifecycle {
/// 静态判断业务配置是否完整,不启动进程;不完整的安装保留为待配置。
async fn configuration_ready(
&self,
@@ -44,36 +44,33 @@ pub trait PluginPreparation: Send + Sync {
&self,
snapshot: PluginInstanceSnapshot,
) -> Result<ExtensionSetReference, AdminError>;
/// 返回当前进程的只读运行事实;缺省实现让不托管插件进程的组合保持兼容。
}
/// 运行诊断独立于发布准备;没有进程事实时显式返回 None。
#[async_trait]
pub trait PluginRuntimeDiagnostics: Send + Sync {
async fn runtime_diagnostics(
&self,
_snapshot: &PluginInstanceSnapshot,
_published_revision: Option<u64>,
_published: Option<&ExtensionSetReference>,
) -> Option<BTreeMap<String, PluginInstanceRuntime>> {
None
}
snapshot: &PluginInstanceSnapshot,
published_revision: Option<u64>,
published: Option<&ExtensionSetReference>,
) -> Option<BTreeMap<String, PluginInstanceRuntime>>;
}
/// 发布实现必须显式承担状态激活、排空和迁移,不能以缺省成功跳过生命周期。
#[async_trait]
pub trait PluginStateLifecycle: Send + Sync {
async fn activate_state(
&self,
_prepared: &ExtensionSetReference,
_instance: &PluginInstance,
) -> Result<(), AdminError> {
Ok(())
}
async fn quiesce_instance(
&self,
_instance_id: &str,
_artifact_sha256: &str,
_revision: Revision,
) {
}
prepared: &ExtensionSetReference,
instance: &PluginInstance,
) -> Result<(), AdminError>;
async fn quiesce_instance(&self, instance_id: &str, artifact_sha256: &str, revision: Revision);
async fn migrate_state(
&self,
_prepared: &ExtensionSetReference,
_transition: PluginStateTransition,
) -> Result<(), AdminError> {
Err(AdminError::unavailable("插件状态迁移运行时不可用"))
}
prepared: &ExtensionSetReference,
transition: PluginStateTransition,
) -> Result<(), AdminError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -176,23 +173,6 @@ pub trait PluginDistribution: Send + Sync {
/// Runtime 只解释插件包格式;安装事务与来源选择归 Admin。
#[async_trait]
pub trait PluginPackageInspector: Send + Sync {
/// 当前宿主对权限标识的说明,供展示投影使用,不属于不可变制品元数据。
fn permission_descriptions(
&self,
permissions: &[String],
) -> Vec<crate::model::plugins::PluginPermissionDescription> {
permissions
.iter()
.map(
|permission| crate::model::plugins::PluginPermissionDescription {
permission: permission.clone(),
label: permission.clone(),
description: String::new(),
},
)
.collect()
}
async fn inspect(
&self,
archive: Arc<[u8]>,
@@ -239,6 +219,19 @@ pub trait PluginStore: Send + Sync {
async fn configuration_versions(&self, _id: &str) -> AdminStoreResult<Vec<String>> {
Ok(Vec::new())
}
/// 在单个事务中停用确认快照中的实例,保留配置、密钥及私有状态。
async fn disable_instances(
&self,
_ids: &[String],
_expected_revision: Revision,
_context: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(super::store::AdminStoreError::new(
super::store::AdminStoreErrorKind::Unavailable,
"plugin",
"atomic plugin disable is unavailable",
))
}
async fn save_instance(
&self,
instance: PluginInstance,
@@ -314,7 +307,7 @@ pub trait PluginStore: Send + Sync {
source: PluginSource,
context: &MutationContext,
) -> AdminStoreResult<PluginArtifactMutation>;
/// 接受不可变制品声明的全部能力域;接受事实不随实例设置变化。
/// 确认信任并安装精确摘要的制品;安装事实不随实例设置变化。
async fn accept_artifact(
&self,
digest: &str,
@@ -0,0 +1,23 @@
//! 有界保留期删除,不承载周期、暂停或重试策略。
use std::num::NonZeroU32;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use super::store::AdminStoreResult;
use crate::model::retention::{RetentionPolicy, RetentionTarget};
#[async_trait]
pub trait RetentionStore: Send + Sync {
async fn load_policy(&self) -> AdminStoreResult<RetentionPolicy>;
/// 每次最多删除 limit 行;请求关联事件随请求事务级联删除。
async fn purge_batch(
&self,
target: RetentionTarget,
now: DateTime<Utc>,
policy: RetentionPolicy,
limit: NonZeroU32,
) -> AdminStoreResult<u64>;
}
@@ -22,11 +22,12 @@ use crate::model::{
},
auth::{AdminAuditEvent, AuthSession},
client_keys::{
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled, UpdateClientKey,
ClientKeyBudgetMutationOrigin, ClientKeyListQuery, ClientKeyPage, ClientKeyRecord,
ClientKeySecret, DeleteClientKey, NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled,
UpdateClientKey, UpdateClientKeyBudgetLimits,
},
observability::{
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticsObservation,
OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageCalculatedBillingFact,
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
},
@@ -332,10 +333,19 @@ pub trait ClientKeyStore: Send + Sync {
context: &MutationContext,
) -> AdminStoreResult<Revision>;
/// 局部更新预算上限,保留其他策略和账本;无变化时不产生配置版本或审计。
async fn update_client_key_budget_limits(
&self,
command: UpdateClientKeyBudgetLimits,
origin: ClientKeyBudgetMutationOrigin,
context: &MutationContext,
) -> AdminStoreResult<Option<Revision>>;
/// 仅修改运行时账本并原子记录审计,不推进配置版本。
async fn reset_client_key_budget(
&self,
command: ResetClientKeyBudget,
origin: ClientKeyBudgetMutationOrigin,
context: &MutationContext,
) -> AdminStoreResult<()>;
}
@@ -434,7 +444,7 @@ pub trait ObservabilityStore: Send + Sync {
range: TimeRange,
filter: UsageFilter,
dimension: DiagnosticDimension,
) -> AdminStoreResult<Vec<DiagnosticObservation>>;
) -> AdminStoreResult<DiagnosticsObservation>;
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage>;
}
@@ -515,9 +525,15 @@ pub struct AdminStorePorts {
backup: BackupStorePorts,
plugins: Arc<dyn super::plugins::PluginStore>,
plugin_state: Arc<dyn super::plugins::PluginStateStore>,
plugin_resources: Arc<dyn super::plugin_resources::PluginResourceStore>,
}
impl AdminStorePorts {
#[must_use]
pub fn plugin_resources(&self) -> Arc<dyn super::plugin_resources::PluginResourceStore> {
self.plugin_resources.clone()
}
#[must_use]
pub fn plugins(&self) -> Arc<dyn super::plugins::PluginStore> {
Arc::clone(&self.plugins)
@@ -542,6 +558,7 @@ impl AdminStorePorts {
backup: BackupStorePorts,
plugins: Arc<dyn super::plugins::PluginStore>,
plugin_state: Arc<dyn super::plugins::PluginStateStore>,
plugin_resources: Arc<dyn super::plugin_resources::PluginResourceStore>,
) -> Self {
Self {
accounts,
@@ -552,6 +569,7 @@ impl AdminStorePorts {
backup,
plugins,
plugin_state,
plugin_resources,
}
}
@@ -77,10 +77,27 @@ pub trait SystemUpdatePreflight: Send + Sync {
candidate: SystemUpdateCandidate,
) -> Result<Revision, SystemOperationError>;
/// 回滚保持严格预检,不能把旧目标当成具有当前重启确认能力的版本。
async fn validate_rollback(
&self,
candidate: SystemUpdateCandidate,
) -> Result<Revision, SystemOperationError> {
self.validate(candidate).await
}
/// 在文件交换临界点复核全局配置 CAS,不能用较早快照掩盖实例变更。
async fn confirm_revision(&self, expected: Revision) -> Result<(), SystemOperationError>;
}
/// 重启临界区内复查候选,确认后停用不兼容插件,再允许进程退出。
#[async_trait]
pub trait SystemRestartPreflight: Send + Sync {
async fn prepare(
&self,
candidate: Option<SystemUpdateCandidate>,
) -> Result<(), SystemOperationError>;
}
/// 版本、自更新、回滚和重启能力;实现唯一归 gateway-host。
#[async_trait]
pub trait SystemOperations: Send + Sync {
@@ -108,5 +125,15 @@ pub trait SystemOperations: Send + Sync {
preflight: Arc<dyn SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError>;
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError>;
/// 返回下一次启动实际使用的发行;源码运行没有封存发行清单。
async fn restart_candidate(
&self,
) -> Result<Option<SystemUpdateCandidate>, SystemOperationError> {
Ok(None)
}
async fn restart(
&self,
preflight: Arc<dyn SystemRestartPreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError>;
}
@@ -0,0 +1,129 @@
//! 主动服务调用的统一组合入口;普通业务方法由所属 HTTP 或执行边界包裹。
mod registry;
mod settings;
pub use registry::Registry;
use std::{future::Future, sync::Arc};
use futures::future::BoxFuture;
use gateway_core::{
engine::middleware::FrozenMiddlewarePlan,
middleware::{
compose,
service::{Context, Error, Value},
},
};
use crate::model::{AdminError, AdminErrorKind};
pub type PlanSource = Arc<dyn Fn() -> Option<FrozenMiddlewarePlan> + Send + Sync>;
/// 空计划也是快照,只有未解析的外部入口才读取当前发布集合。
#[derive(Clone, Default)]
pub enum Plan {
#[default]
Current,
Frozen(Option<FrozenMiddlewarePlan>),
}
/// 非服务入口也有父调用身份和取消信号;发布计划在首次进入公开服务时解析。
#[derive(Clone)]
pub struct Origin {
pub request_id: String,
pub call_id: String,
pub cancellation: gateway_core::lifecycle::CancellationToken,
pub extensions: gateway_core::engine::extensions::ExtensionCallScope,
pub plan: Plan,
}
impl From<Context> for Origin {
fn from(context: Context) -> Self {
Self {
request_id: context.request_id,
call_id: context.call_id,
cancellation: context.cancellation,
extensions: context.extensions,
plan: Plan::Frozen(Some(context.plan)),
}
}
}
tokio::task_local! { static CURRENT: Origin; }
/// task local 随 future poll 进入/退出,不泄漏给并行任务。
pub async fn scope<T>(context: impl Into<Origin>, future: impl Future<Output = T>) -> T {
CURRENT.scope(context.into(), future).await
}
pub(crate) async fn invoke(
source: &PlanSource,
operation: &'static str,
input: Value,
terminal: impl FnOnce(Value) -> BoxFuture<'static, Result<Value, Error>> + Send + 'static,
) -> Result<Value, Error> {
let parent = CURRENT.try_with(Clone::clone).ok();
let plan = match parent.as_ref().map(|parent| &parent.plan) {
Some(Plan::Frozen(plan)) => plan.clone(),
_ => source(),
};
let call_id = uuid::Uuid::now_v7().to_string();
let parent_call_id = parent.as_ref().map(|parent| parent.call_id.clone());
let origin = Origin {
request_id: parent
.as_ref()
.map_or_else(|| call_id.clone(), |parent| parent.request_id.clone()),
cancellation: parent
.as_ref()
.map(|parent| parent.cancellation.clone())
.unwrap_or_default(),
extensions: parent.map(|parent| parent.extensions).unwrap_or_default(),
call_id,
plan: Plan::Frozen(plan.clone()),
};
let Some(plan) = plan.filter(FrozenMiddlewarePlan::has_service) else {
return tokio::select! {
biased;
() = origin.cancellation.cancelled() => Err(Error::unavailable("服务调用已取消")),
result = scope(origin.clone(), terminal(input)) => result,
};
};
let context = Context {
operation,
request_id: origin.request_id,
call_id: origin.call_id,
parent_call_id,
cancellation: origin.cancellation,
extensions: origin.extensions,
plan: plan.clone(),
};
let inner = context.clone();
let next = compose(Vec::new(), move |input| {
Box::pin(scope(inner, terminal(input)))
});
tokio::select! {
biased;
() = context.cancellation.cancelled() => Err(Error::unavailable("服务调用已取消")),
result = plan.handle_service(context.clone(), input, next) => result,
}
}
pub(crate) fn encode_error(error: AdminError) -> Error {
Error {
kind: match error.kind() {
AdminErrorKind::Invalid => "invalid",
AdminErrorKind::Unauthorized => "unauthorized",
AdminErrorKind::Forbidden => "forbidden",
AdminErrorKind::NotFound => "not_found",
AdminErrorKind::Conflict => "conflict",
AdminErrorKind::RateLimited => "rate_limited",
AdminErrorKind::BadGateway => "bad_gateway",
AdminErrorKind::UpstreamResultUnknown => "upstream_result_unknown",
AdminErrorKind::Unavailable => "unavailable",
AdminErrorKind::Internal => "internal",
}
.into(),
message: error.message().to_owned(),
details: None,
}
}
@@ -0,0 +1,76 @@
//! 公开操作登记在所属服务;注册表只校验类型和分派,不实现业务。
use crate::model::AdminError;
use futures::future::BoxFuture;
use gateway_core::middleware::service::{Error, Value};
use serde::{Serialize, de::DeserializeOwned};
use std::{collections::BTreeMap, sync::Arc};
type Handler = Arc<dyn Fn(Value) -> BoxFuture<'static, Result<Value, Error>> + Send + Sync>;
pub struct Registry {
middleware: super::PlanSource,
operations: BTreeMap<&'static str, Handler>,
}
impl Registry {
pub fn new(middleware: super::PlanSource) -> Self {
Self {
middleware,
operations: BTreeMap::new(),
}
}
/// 网关与命令行复用同一组设置服务;注册不会执行读取或写入。
pub fn register_settings(
&mut self,
service: &Arc<dyn crate::SettingsService>,
) -> Result<(), AdminError> {
super::settings::register(self, service)
}
pub(crate) fn register<I, O>(
&mut self,
name: &'static str,
handler: impl Fn(I) -> BoxFuture<'static, Result<O, AdminError>> + Send + Sync + 'static,
) -> Result<(), AdminError>
where
I: DeserializeOwned + Send + 'static,
O: Serialize + Send + 'static,
{
let std::collections::btree_map::Entry::Vacant(entry) = self.operations.entry(name) else {
return Err(AdminError::internal(format!("公开服务重复登记:{name}")));
};
let handler = Arc::new(handler);
entry.insert(Arc::new(move |input| {
let handler = handler.clone();
Box::pin(async move {
let input = serde_json::from_value(input)
.map_err(|_| Error::invalid("服务输入类型无效"))?;
let output = handler(input).await.map_err(super::encode_error)?;
serde_json::to_value(output).map_err(|_| Error::unavailable("服务结果编码失败"))
})
}));
Ok(())
}
pub async fn call(&self, operation: &str, input: Value) -> Result<Value, Error> {
let (operation, handler) =
self.operations
.get_key_value(operation)
.ok_or_else(|| Error {
details: None,
kind: "not_found".into(),
message: format!("公开服务不存在:{operation}"),
})?;
let handler = handler.clone();
super::invoke(&self.middleware, operation, input, move |input| {
handler(input)
})
.await
}
pub fn operations(&self) -> impl Iterator<Item = &'static str> + '_ {
self.operations.keys().copied()
}
}
@@ -0,0 +1,85 @@
//! 设置服务的唯一公开操作声明;SDK 操作和数据合同由对应合同测试生成。
use crate::{
SettingsService,
model::{
AdminError, MutationContext,
pricing::{SyncPricing, UpdatePricing},
settings::ReplaceRuntimeSettings,
},
};
use std::sync::Arc;
pub(super) fn register(
registry: &mut super::Registry,
service: &Arc<dyn SettingsService>,
) -> Result<(), AdminError> {
// 声明保留 SDK 类型名和原生参数转换,分派名称由业务方法名直接派生。
macro_rules! register {
($sdk:ident, $method:ident, $input:ty, $pattern:pat $(, $argument:expr)* $(,)?) => {{
let service = service.clone();
registry.register(concat!("settings.", stringify!($method)), move |$pattern: $input| {
let service = service.clone();
Box::pin(async move { service.$method($($argument),*).await })
})?;
}};
}
register!(Load, load, (), ());
register!(
Replace,
replace,
(MutationContext, ReplaceRuntimeSettings),
(context, command),
&context,
command
);
register!(ApiKeyExists, admin_api_key_exists, (), ());
register!(
RegenerateApiKey,
regenerate_admin_api_key,
MutationContext,
context,
&context
);
register!(
DeleteApiKey,
delete_admin_api_key,
MutationContext,
context,
&context
);
register!(Pricing, pricing, (), ());
register!(PreviewPricingSync, preview_pricing_sync, (), ());
register!(
Sync,
sync_pricing,
(MutationContext, SyncPricing),
(context, command),
&context,
command
);
register!(
Update,
update_pricing,
(MutationContext, UpdatePricing),
(context, command),
&context,
command
);
register!(
ClientProfileOptions,
client_profile_options,
String,
provider,
&provider
);
register!(
PreviewClientProfile,
preview_client_profile,
(String, Option<gateway_core::account::OpaqueProviderData>),
(provider, configuration),
&provider,
configuration.as_ref()
);
Ok(())
}
@@ -363,6 +363,7 @@ impl DefaultAccountsService {
.and_then(|(window, _)| window.local_usage.clone())
});
Ok(AccountDirectoryItem {
capacity: stored.capacity,
capabilities: provider
.account_capabilities(account_id, &stored.account.authentication_kind),
plan_type_display: self.providers.resolve_account_plan(
@@ -402,6 +403,18 @@ impl AccountsService for DefaultAccountsService {
.iter()
.map(|item| item.account.id.clone())
.collect::<Vec<_>>();
// 只读取当前页的租约占用;观测失败不能把账号误报为空闲或拖垮目录。
let in_flight = if ids.is_empty() {
None
} else {
match self.account_runtime.account_runtime(&ids).await {
Ok(runtime) => runtime.in_flight,
Err(error) => {
tracing::warn!(error = %error, "account capacity projection is unavailable");
None
}
}
};
let rolling_usage = self
.accounts
.load_account_usage(rolling_range, &ids)
@@ -472,6 +485,12 @@ impl AccountsService for DefaultAccountsService {
.and_then(|(window, _)| window.local_usage.clone())
});
Ok(AccountDirectoryItem {
capacity: crate::model::accounts::AccountCapacity {
used_slots: in_flight
.as_ref()
.map(|counts| counts.get(&item.account.id).copied().unwrap_or(0)),
..item.capacity
},
capabilities,
plan_type_display: providers.resolve_account_plan(
item.account.provider_kind.as_str(),
@@ -4,8 +4,9 @@ use std::sync::Arc;
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use gateway_core::policy::ClientApiKeyId;
use gateway_core::runtime::SnapshotControl;
use gateway_core::{
engine::budget::ClientBudgetStatus, policy::ClientApiKeyId, runtime::SnapshotControl,
};
use rand_core::{OsRng, RngCore as _};
use uuid::Uuid;
@@ -13,10 +14,10 @@ use crate::{
model::{
AdminError, MutationContext,
client_keys::{
ClientKeyCursorValue, ClientKeyListQuery, ClientKeyMutation, ClientKeyPage,
ClientKeySecret, ClientKeySortField, CreateClientKey, CreatedClientKey,
DeleteClientKey, NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled,
UpdateClientKey,
ClientKeyBudgetMutationOrigin, ClientKeyCursorValue, ClientKeyListQuery,
ClientKeyMutation, ClientKeyPage, ClientKeyRecord, ClientKeySecret, ClientKeySortField,
CreateClientKey, CreatedClientKey, DeleteClientKey, NewClientKey, ResetClientKeyBudget,
SetClientKeyEnabled, UpdateClientKey, UpdateClientKeyBudgetLimits,
},
},
ports::store::{AdminStoreError, AdminStoreErrorKind, ClientKeyStore},
@@ -27,6 +28,7 @@ use super::{map_store_error, publish_committed};
/// API 消费的 Client Key 管理服务。
#[async_trait]
pub trait ClientKeyService: Send + Sync {
async fn get(&self, id: &ClientApiKeyId) -> Result<ClientKeyRecord, AdminError>;
async fn list(&self, query: ClientKeyListQuery) -> Result<ClientKeyPage, AdminError>;
async fn reveal(&self, id: &ClientApiKeyId) -> Result<ClientKeySecret, AdminError>;
async fn create(
@@ -49,10 +51,18 @@ pub trait ClientKeyService: Send + Sync {
context: &MutationContext,
command: DeleteClientKey,
) -> Result<ClientKeyMutation, AdminError>;
async fn budget(&self, id: &ClientApiKeyId) -> Result<ClientBudgetStatus, AdminError>;
async fn update_budget_limits(
&self,
context: &MutationContext,
command: UpdateClientKeyBudgetLimits,
origin: ClientKeyBudgetMutationOrigin,
) -> Result<ClientApiKeyId, AdminError>;
async fn reset_budget(
&self,
context: &MutationContext,
command: ResetClientKeyBudget,
origin: ClientKeyBudgetMutationOrigin,
) -> Result<ClientApiKeyId, AdminError>;
}
@@ -79,14 +89,48 @@ impl DefaultClientKeyService {
#[async_trait]
impl ClientKeyService for DefaultClientKeyService {
async fn get(&self, id: &ClientApiKeyId) -> Result<ClientKeyRecord, AdminError> {
self.store
.get_client_key(id)
.await
.map_err(|error| map_store_error(error, "client API key"))?
.ok_or_else(|| AdminError::not_found("Client API Key 不存在"))
}
async fn budget(&self, id: &ClientApiKeyId) -> Result<ClientBudgetStatus, AdminError> {
self.get(id).await.map(|key| key.budget)
}
async fn update_budget_limits(
&self,
context: &MutationContext,
command: UpdateClientKeyBudgetLimits,
origin: ClientKeyBudgetMutationOrigin,
) -> Result<ClientApiKeyId, AdminError> {
if command.daily_limit_usd.is_none() && command.weekly_limit_usd.is_none() {
return Err(AdminError::invalid("至少指定一个预算上限"));
}
let id = command.id.clone();
if let Some(revision) = self
.store
.update_client_key_budget_limits(command, origin, context)
.await
.map_err(|error| map_store_error(error, "client API key"))?
{
publish_committed(self.snapshot.as_ref(), revision).await?;
}
Ok(id)
}
async fn reset_budget(
&self,
context: &MutationContext,
command: ResetClientKeyBudget,
origin: ClientKeyBudgetMutationOrigin,
) -> Result<ClientApiKeyId, AdminError> {
let id = command.id.clone();
self.store
.reset_client_key_budget(command, context)
.reset_client_key_budget(command, origin, context)
.await
.map_err(|error| map_store_error(error, "client API key"))?;
Ok(id)
@@ -124,9 +168,7 @@ impl ClientKeyService for DefaultClientKeyService {
let plaintext = if let Some(key) = command.custom_key {
key.expose_for_auth().to_owned()
} else {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes))
generate_key()
};
let (config_revision, record) = self
.store
@@ -257,3 +299,10 @@ fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
Err(AdminError::invalid("Client API Key 游标不合法"))
}
}
// 原生与插件创建共用相同的密钥生成规则。
pub(super) fn generate_key() -> String {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes))
}
@@ -12,6 +12,7 @@ pub mod key_usage;
pub mod observability;
pub mod plugin_accounts;
pub mod plugin_client_keys;
pub mod plugin_resources;
pub(crate) mod plugin_update;
pub mod plugins;
pub mod proxies;
@@ -161,7 +161,7 @@ impl DefaultObservabilityService {
self.store.dashboard_runtime_slots(observed_at),
)
.map_err(|error| map_store_error(error, "dashboard"))?;
self.enrich_list_billing(&mut observation.recent_requests);
self.enrich_list_records(&mut observation.recent_requests);
self.enrich_dashboard_quotas(&mut observation.account_usage)
.await;
for account in &mut observation.account_usage {
@@ -268,7 +268,7 @@ impl ObservabilityService for DefaultObservabilityService {
.list_usage_records(query)
.await
.map_err(|error| map_store_error(error, "usage records"))?;
self.enrich_list_billing(&mut page.items);
self.enrich_list_records(&mut page.items);
Ok(page)
}
@@ -334,37 +334,37 @@ impl ObservabilityService for DefaultObservabilityService {
filter: UsageFilter,
dimension: DiagnosticDimension,
) -> Result<DiagnosticsResult, AdminError> {
let items = self
let observation = self
.store
.usage_diagnostics(range, filter, dimension)
.await
.map_err(|error| map_store_error(error, "usage diagnostics"))?;
let total_requests = items.iter().fold(0_u64, |total, item| {
total.saturating_add(item.request_count)
});
let mut items = items
let items = observation
.items
.into_iter()
.map(|item| {
let error_rate = rate_or_zero(item.failure_count, item.request_count);
let non_completion_rate =
rate_or_zero(item.non_completion_count, item.request_count);
let retry_rate = rate_or_zero(item.retry_count, item.request_count);
let impact_score = diagnostic_impact_score(
item.request_count,
total_requests,
error_rate,
non_completion_rate,
retry_rate,
item.first_token_p95_ms,
);
let retry_rate = rate_or_zero(item.retried_request_count, item.request_count);
DiagnosticsItem {
key: item.key,
name: item.name,
account_plan_type_display: item.account_provider_kind.as_deref().and_then(
|provider| {
self.providers
.plan_type_display(provider, item.account_plan_type.as_deref())
},
),
account_plan_type: item.account_plan_type,
request_count: item.request_count,
success_count: item.success_count,
error_count: item.failure_count,
error_rate,
request_share: rate_or_zero(item.request_count, total_requests),
request_share: rate_or_zero(
item.request_count,
observation.total_request_count,
),
average_latency_ms: item.average_latency_ms,
latency_p95_ms: item.latency_p95_ms,
first_token_p95_ms: item.first_token_p95_ms,
@@ -372,19 +372,12 @@ impl ObservabilityService for DefaultObservabilityService {
non_completion_rate,
retry_count: item.retry_count,
retry_rate,
impact_score,
estimated_cost: usd_cost(&item.costs),
attempt_count: item.attempt_count,
total_tokens: item.total_tokens,
}
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.impact_score
.total_cmp(&left.impact_score)
.then_with(|| right.request_count.cmp(&left.request_count))
});
Ok(DiagnosticsResult { dimension, items })
}
@@ -724,25 +717,6 @@ fn decimal(value: &DecimalAmount) -> Option<gateway_core::metering::Decimal> {
value.as_str().parse().ok()
}
fn diagnostic_impact_score(
request_count: u64,
total_requests: u64,
error_rate: f64,
non_completion_rate: f64,
retry_rate: f64,
first_token_p95_ms: Option<u64>,
) -> f64 {
let request_share = rate_or_zero(request_count, total_requests);
let slow_score = first_token_p95_ms
.map_or(0.0, |value| value as f64 / 30_000.0)
.min(1.0);
error_rate * 0.35
+ non_completion_rate * 0.25
+ retry_rate.min(1.0) * 0.20
+ request_share * 0.10
+ slow_score * 0.10
}
fn usd_cost(costs: &[CurrencyCost]) -> Option<DecimalAmount> {
costs
.iter()
@@ -806,10 +780,15 @@ impl DefaultObservabilityService {
}
}
/// 逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
/// 补充当前套餐展示名称,逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
/// 不支持的来源或费用规则失败)只保留该条已存的总额,不影响整页返回。
fn enrich_list_billing(&self, records: &mut [crate::model::observability::UsageListRecord]) {
fn enrich_list_records(&self, records: &mut [crate::model::observability::UsageListRecord]) {
for record in records {
record.provider_account_plan_type_display =
record.provider_kind.as_deref().and_then(|provider| {
self.providers
.plan_type_display(provider, record.provider_account_plan_type.as_deref())
});
let Some(UsageBilling::Total { source, total }) = record.billing.as_ref() else {
continue;
};
@@ -45,6 +45,22 @@ impl DefaultPluginAccountAccess {
}
}
async fn quota(
&self,
account_id: &ProviderAccountId,
refresh: bool,
) -> Result<crate::model::provider_credentials::ProviderQuota, AdminError> {
let account = self.get_runtime(account_id).await?;
self.provider(&account.provider_kind)?
.quota(crate::model::provider_credentials::ProviderQuotaRequest {
account_id: account_id.clone(),
refresh,
rolling_usage: None,
})
.await
.map_err(|error| map_provider_error(error, "plugin quota facts"))
}
fn provider(
&self,
provider_kind: &ProviderKind,
@@ -99,16 +115,16 @@ impl PluginAccountAccess for DefaultPluginAccountAccess {
&self,
account_id: &ProviderAccountId,
) -> Result<crate::model::provider_credentials::ProviderQuota, AdminError> {
let account = self.get_runtime(account_id).await?;
self.provider(&account.provider_kind)?
.quota(crate::model::provider_credentials::ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: false,
rolling_usage: None,
})
.await
.map_err(|error| map_provider_error(error, "plugin quota facts"))
self.quota(account_id, false).await
}
async fn refresh_quota(
&self,
account_id: &ProviderAccountId,
) -> Result<crate::model::provider_credentials::ProviderQuota, AdminError> {
self.quota(account_id, true).await
}
async fn save(
&self,
command: PreparedPluginAccountSave,
@@ -1,19 +1,23 @@
//! 插件 Client Key 目录;复用管理服务并收窄为非秘密投影。
//! 插件 Client Key 管理;复用管理服务,只开放非秘密目录与预算操作。
use std::sync::Arc;
use async_trait::async_trait;
use gateway_core::{engine::budget::ClientBudgetStatus, policy::ClientApiKeyId};
use crate::{
model::{
AdminError,
AdminError, MutationContext,
client_keys::{
ClientKeyCursor, ClientKeyCursorValue, ClientKeyListQuery, ClientKeyPageSize,
ClientKeySort, ClientKeySortField, SortDirection,
ClientKeyBudgetMutationOrigin, ClientKeyCursor, ClientKeyCursorValue,
ClientKeyListQuery, ClientKeyPageSize, ClientKeySort, ClientKeySortField,
ResetClientKeyBudget, SortDirection, UpdateClientKeyBudgetLimits,
},
plugin_client_keys::{
PluginClientKey, PluginClientKeyCursor, PluginClientKeyListQuery, PluginClientKeyPage,
PluginClientKey, PluginClientKeyCursor, PluginClientKeyFacts, PluginClientKeyListQuery,
PluginClientKeyPage,
},
plugin_resources::PluginResourceOwner,
},
ports::plugin_client_keys::PluginClientKeyAccess,
use_case::client_keys::ClientKeyService,
@@ -31,6 +35,49 @@ impl DefaultPluginClientKeyAccess {
#[async_trait]
impl PluginClientKeyAccess for DefaultPluginClientKeyAccess {
async fn facts(&self, id: &ClientApiKeyId) -> Result<PluginClientKeyFacts, AdminError> {
let key = self.service.get(id).await?;
Ok(PluginClientKeyFacts {
id: key.id,
enabled: key.enabled,
group_ids: key.groups.into_iter().map(|group| group.id).collect(),
})
}
async fn budget(&self, id: &ClientApiKeyId) -> Result<ClientBudgetStatus, AdminError> {
self.service.budget(id).await
}
async fn update_budget_limits(
&self,
owner: &PluginResourceOwner,
command: UpdateClientKeyBudgetLimits,
context: &MutationContext,
) -> Result<ClientApiKeyId, AdminError> {
self.service
.update_budget_limits(
context,
command,
ClientKeyBudgetMutationOrigin::Plugin(owner.clone()),
)
.await
}
async fn reset_budget(
&self,
owner: &PluginResourceOwner,
command: ResetClientKeyBudget,
context: &MutationContext,
) -> Result<ClientApiKeyId, AdminError> {
self.service
.reset_budget(
context,
command,
ClientKeyBudgetMutationOrigin::Plugin(owner.clone()),
)
.await
}
async fn list(
&self,
query: PluginClientKeyListQuery,
@@ -0,0 +1,108 @@
use crate::{
model::{
AdminError, MutationContext,
account_groups::{CreateAccountGroup, NewAccountGroup},
client_keys::NewClientKey,
plugin_resources::{
GroupMembersChange, GroupMembersChanged, ManagedKeyConfig, ManagedResource,
PluginResourceOwner, ResourceMutation,
},
},
ports::plugin_resources::{PluginResourceAccess, PluginResourceStore},
};
use async_trait::async_trait;
use gateway_core::{policy::ClientApiKeyId, routing::AccountGroupId, runtime::SnapshotControl};
use std::sync::Arc;
use uuid::Uuid;
pub(crate) struct DefaultPluginResourceAccess {
pub store: Arc<dyn PluginResourceStore>,
pub snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultPluginResourceAccess {
async fn publish<T>(
&self,
result: crate::ports::store::AdminStoreResult<ResourceMutation<T>>,
) -> Result<T, AdminError> {
let result = result.map_err(|error| super::map_store_error(error, "plugin resource"))?;
if let Some(revision) = result.revision {
super::publish_committed(self.snapshot.as_ref(), revision).await?;
}
Ok(result.value)
}
}
#[async_trait]
impl PluginResourceAccess for DefaultPluginResourceAccess {
async fn ensure_group(
&self,
owner: &PluginResourceOwner,
resource_key: String,
command: CreateAccountGroup,
context: &MutationContext,
) -> Result<ManagedResource, AdminError> {
let id = AccountGroupId::new(format!("grp_{}", Uuid::now_v7().simple()))
.map_err(|_| AdminError::internal("创建账号组 ID 失败"))?;
self.publish(
self.store
.ensure_group(
owner,
resource_key,
NewAccountGroup {
id,
name: command.name,
description: command.description,
color: command.color,
disable_fast: command.disable_fast,
},
context,
)
.await,
)
.await
}
async fn ensure_key(
&self,
owner: &PluginResourceOwner,
resource_key: String,
groups: Vec<String>,
command: ManagedKeyConfig,
context: &MutationContext,
) -> Result<ManagedResource, AdminError> {
let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple()))
.map_err(|_| AdminError::internal("创建 Key ID 失败"))?;
self.publish(
self.store
.ensure_key(
owner,
resource_key,
groups,
NewClientKey {
id,
name: command.name,
label: None,
group_ids: Vec::new(),
limits: command.limits,
budget: command.budget,
request_profile_overrides: Default::default(),
plaintext: super::client_keys::generate_key(),
},
context,
)
.await,
)
.await
}
async fn change_members(
&self,
owner: &PluginResourceOwner,
command: GroupMembersChange,
context: &MutationContext,
) -> Result<GroupMembersChanged, AdminError> {
self.publish(self.store.change_members(owner, command, context).await)
.await
}
}
@@ -1,6 +1,14 @@
use std::{collections::BTreeMap, sync::Arc};
use super::publish_committed;
use crate::model::{
MutationContext,
system::{SystemIncompatiblePlugin, SystemRestartPlan},
};
use crate::ports::system::SystemRestartPreflight;
use async_trait::async_trait;
use gateway_core::runtime::SnapshotControl;
use sha2::{Digest as _, Sha256};
use crate::{
model::{AdminErrorKind, Revision},
@@ -14,20 +22,26 @@ use crate::{
},
};
use super::plugins::official::update_compatibility;
use super::plugins::official::{update_compatibility, validate_update_release};
/// 只读取启用实例及其固定包体,检查目标宿主合同;不准备或执行插件。
pub(crate) struct PluginSystemUpdatePreflight {
store: Arc<dyn PluginStore>,
inspector: Arc<dyn PluginPackageInspector>,
snapshots: Arc<dyn SnapshotControl>,
}
impl PluginSystemUpdatePreflight {
pub(crate) fn new(
store: Arc<dyn PluginStore>,
inspector: Arc<dyn PluginPackageInspector>,
snapshots: Arc<dyn SnapshotControl>,
) -> Self {
Self { store, inspector }
Self {
store,
inspector,
snapshots,
}
}
}
@@ -37,63 +51,28 @@ impl SystemUpdatePreflight for PluginSystemUpdatePreflight {
&self,
candidate: SystemUpdateCandidate,
) -> Result<Revision, SystemOperationError> {
let target = semver::Version::parse(candidate.target_version.trim_start_matches('v'))
.map_err(|_| invalid("目标网关版本不合法"))?;
let compatibility = update_compatibility(
candidate.release_manifest.as_ref(),
candidate.target_version.trim_start_matches('v'),
)
.map_err(|_| invalid("目标发行物缺少有效的插件兼容声明"))?;
let snapshot = self.store.load_instances().await.map_err(map_store_error)?;
let mut inspected = BTreeMap::new();
for instance in snapshot
.instances
.iter()
.filter(|instance| instance.enabled)
{
let requirements = if let Some(requirements) = inspected.get(&instance.artifact_sha256)
{
requirements
} else {
let artifact = self
.store
.load_artifact(&instance.artifact_sha256)
.await
.map_err(map_store_error)?;
let requirements = self
.inspector
.compatibility(artifact.archive, instance.artifact_sha256.clone())
.await
.map_err(map_inspection_error)?;
inspected.insert(instance.artifact_sha256.clone(), requirements);
inspected
.get(&instance.artifact_sha256)
.ok_or_else(|| internal("插件兼容性检查结果不可用"))?
};
let host_versions = semver::VersionReq::parse(&requirements.host_version)
.map_err(|_| incompatible(&instance.id))?;
if !host_versions.matches(&target)
|| !compatibility
.manifest_schema_versions
.contains(&requirements.manifest_schema_version)
|| !compatibility
.protocol_versions
.contains(&requirements.protocol_version)
|| requirements
.capabilities
.iter()
.any(|(capability, version)| {
!compatibility.supports_capability(capability, *version)
})
|| requirements
.permissions
.iter()
.any(|permission| !compatibility.supports_permission(permission))
{
return Err(incompatible(&instance.id));
}
let target_version = candidate.target_version.trim_start_matches('v');
semver::Version::parse(target_version).map_err(|_| invalid("目标网关版本不合法"))?;
// 下载阶段只校验发行身份;插件兼容性在用户点击重启时检查并确认。
validate_update_release(candidate.release_manifest.as_ref(), target_version)
.map_err(|_| invalid("目标发行清单不合法或版本不匹配"))?;
Ok(self
.store
.load_instances()
.await
.map_err(map_store_error)?
.config_revision)
}
async fn validate_rollback(
&self,
candidate: SystemUpdateCandidate,
) -> Result<Revision, SystemOperationError> {
let plan = self.plan(Some(candidate)).await?;
if !plan.incompatible_plugins.is_empty() {
return Err(conflict("启用插件与回滚目标不兼容,请先停用对应插件"));
}
Ok(snapshot.config_revision)
Revision::new(plan.config_revision).map_err(|_| internal("插件配置版本不合法"))
}
async fn confirm_revision(&self, expected: Revision) -> Result<(), SystemOperationError> {
@@ -131,10 +110,6 @@ fn map_inspection_error(error: crate::model::AdminError) -> SystemOperationError
}
}
fn incompatible(instance_id: &str) -> SystemOperationError {
conflict(format!("启用插件实例 {instance_id} 与目标网关版本不兼容"))
}
fn invalid(message: impl Into<String>) -> SystemOperationError {
SystemOperationError::new(SystemOperationErrorKind::Invalid, message)
}
@@ -146,3 +121,158 @@ fn conflict(message: impl Into<String>) -> SystemOperationError {
fn internal(message: impl Into<String>) -> SystemOperationError {
SystemOperationError::new(SystemOperationErrorKind::Internal, message)
}
impl PluginSystemUpdatePreflight {
pub(crate) async fn plan(
&self,
candidate: Option<SystemUpdateCandidate>,
) -> Result<SystemRestartPlan, SystemOperationError> {
let snapshot = self.store.load_instances().await.map_err(map_store_error)?;
let mut plan = SystemRestartPlan {
target_version: candidate.as_ref().map(|item| item.target_version.clone()),
release_manifest_sha256: candidate
.as_ref()
.map(|item| hex::encode(Sha256::digest(&item.release_manifest))),
config_revision: snapshot.config_revision.get(),
incompatible_plugins: Vec::new(),
};
let target = candidate
.as_ref()
.map(|item| semver::Version::parse(item.target_version.trim_start_matches('v')))
.transpose()
.map_err(|_| invalid("目标网关版本不合法"))?;
if let Some(candidate) = &candidate {
validate_update_release(
&candidate.release_manifest,
candidate.target_version.trim_start_matches('v'),
)
.map_err(|_| invalid("目标发行清单不合法或版本不匹配"))?;
}
// 未知目标合同不能证明兼容,列入待确认停用;不伪造目标支持范围。
let host = candidate.as_ref().and_then(|item| {
update_compatibility(
&item.release_manifest,
item.target_version.trim_start_matches('v'),
)
.ok()
});
let mut inspected: BTreeMap<String, Option<String>> = BTreeMap::new();
for instance in snapshot.instances.iter().filter(|item| item.enabled) {
let reason = if let Some(reason) = inspected.get(&instance.artifact_sha256) {
reason.clone()
} else {
let artifact = self
.store
.load_artifact(&instance.artifact_sha256)
.await
.map_err(map_store_error)?;
let reason = if let Some(target) = &target {
match (
&host,
self.inspector
.compatibility(artifact.archive, instance.artifact_sha256.clone())
.await,
) {
(_, Err(error)) if error.kind() == AdminErrorKind::Invalid => {
Some("插件包或协议与目标版本不兼容".to_owned())
}
(_, Err(error)) => return Err(map_inspection_error(error)),
(None, _) => Some("无法确认插件与目标版本兼容".to_owned()),
(Some(host), Ok(requirements)) => {
if !semver::VersionReq::parse(&requirements.host_version)
.is_ok_and(|versions| versions.matches(target))
{
Some(format!("要求网关版本 {}", requirements.host_version))
} else if !host
.manifest_schema_versions
.contains(&requirements.manifest_schema_version)
|| !host
.protocol_versions
.contains(&requirements.protocol_version)
|| requirements
.capabilities
.iter()
.any(|(capability, version)| {
!host.supports_capability(capability, *version)
})
{
Some("插件协议或能力与目标版本不兼容".to_owned())
} else {
None
}
}
}
} else {
match self
.inspector
.inspect(artifact.archive, Some(instance.artifact_sha256.clone()))
.await
{
Ok(_) => None,
Err(error) if error.kind() == AdminErrorKind::Invalid => {
Some("插件与当前宿主不兼容".to_owned())
}
Err(error) => return Err(map_inspection_error(error)),
}
};
inspected.insert(instance.artifact_sha256.clone(), reason.clone());
reason
};
if let Some(reason) = reason {
plan.incompatible_plugins.push(SystemIncompatiblePlugin {
instance_id: instance.id.clone(),
name: instance.name.clone(),
reason,
});
}
}
plan.incompatible_plugins
.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
Ok(plan)
}
}
pub(crate) struct ConfirmedPluginRestart {
pub preflight: Arc<PluginSystemUpdatePreflight>,
pub confirmation: Option<SystemRestartPlan>,
pub context: MutationContext,
}
#[async_trait]
impl SystemRestartPreflight for ConfirmedPluginRestart {
async fn prepare(
&self,
candidate: Option<SystemUpdateCandidate>,
) -> Result<(), SystemOperationError> {
let plan = self.preflight.plan(candidate).await?;
if self
.confirmation
.as_ref()
.is_some_and(|confirmed| confirmed != &plan)
{
return Err(conflict("插件配置或目标版本已变化,请重新检查并确认重启"));
}
if !plan.incompatible_plugins.is_empty() && self.confirmation.is_none() {
return Err(conflict("请先确认停用不兼容插件,再重启服务"));
}
let mut revision =
Revision::new(plan.config_revision).map_err(|_| internal("插件配置版本不合法"))?;
if !plan.incompatible_plugins.is_empty() {
let ids = plan
.incompatible_plugins
.iter()
.map(|item| item.instance_id.clone())
.collect::<Vec<_>>();
revision = self
.preflight
.store
.disable_instances(&ids, revision, &self.context)
.await
.map_err(map_store_error)?;
publish_committed(self.preflight.snapshots.as_ref(), revision)
.await
.map_err(|_| internal("插件已停用,但运行配置尚未发布,请重试重启"))?;
}
self.preflight.confirm_revision(revision).await
}
}
@@ -1,4 +1,7 @@
use std::{collections::BTreeSet, sync::Arc};
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
};
use gateway_core::runtime::SnapshotControl;
use sha2::{Digest as _, Sha256};
@@ -14,7 +17,7 @@ use crate::{
distribution::VerifiedPluginArtifact,
instances::{
ConfigurePluginInstance, PluginCapabilityBinding, PluginFailurePolicy,
PluginInstance, PluginPermissionGrant,
PluginInstance,
},
},
},
@@ -49,16 +52,10 @@ pub struct PluginsService {
pub(super) preparation: Arc<dyn PluginPreparation>,
pub(super) published: gateway_core::runtime::RuntimeSnapshotHandle,
pub(super) state: PluginStateService,
pub(super) compatibility: tokio::sync::Mutex<BTreeMap<String, Option<String>>>,
}
impl PluginsService {
pub fn permission_descriptions(
&self,
permissions: &[String],
) -> Vec<crate::model::plugins::PluginPermissionDescription> {
self.inspector.permission_descriptions(permissions)
}
#[must_use]
pub fn new(
store: Arc<dyn PluginStore>,
@@ -77,6 +74,7 @@ impl PluginsService {
preparation,
published,
state: PluginStateService::new(state),
compatibility: tokio::sync::Mutex::new(BTreeMap::new()),
}
}
@@ -377,14 +375,21 @@ pub(super) fn default_bindings(metadata: &PluginArtifactMetadata) -> Vec<PluginC
.filter(|(capability, _)| {
!matches!(
capability.as_str(),
"frontend_authentication" | "management" | "command_line" | "model_catalog"
"frontend_authentication"
| "management"
| "command_line"
| "model_catalog"
| "maintenance"
)
})
.flat_map(|(_, contribution)| {
contribution
.stages
.iter()
.map(|stage| PluginCapabilityBinding {
.flat_map(|(capability, contribution)| {
contribution.stages.iter().flat_map(move |stage| {
let events: &[Option<&str>] = if capability == "observer" {
&[Some("request_completed"), Some("websocket_response")]
} else {
&[None]
};
events.iter().map(move |event| PluginCapabilityBinding {
contribution: contribution.id.clone(),
stage: stage.clone(),
order: 0,
@@ -399,8 +404,10 @@ pub(super) fn default_bindings(metadata: &PluginArtifactMetadata) -> Vec<PluginC
account_group_ids: Vec::new(),
provider_ids: Vec::new(),
models: Vec::new(),
event: event.map(str::to_owned),
identity_bindings: Vec::new(),
})
})
})
.collect()
}
@@ -421,12 +428,6 @@ fn default_instance(
trusted_process: true,
configuration,
secrets: Default::default(),
grants: metadata
.requested_permissions
.iter()
.cloned()
.map(|permission| PluginPermissionGrant { permission })
.collect(),
bindings,
revision,
}
@@ -6,11 +6,11 @@ use secrecy::ExposeSecret;
use super::PluginsService;
use crate::{
model::{
AdminError, MutationContext, Revision,
AdminError, AdminErrorKind, MutationContext, Revision,
plugins::instances::{
ConfigurePluginInstance, PluginInstance, PluginInstanceMutation,
PluginInstanceReplacement, PluginInstanceRuntime, PluginInstanceRuntimeStatus,
PluginInstanceSnapshot, PluginInstanceView, PluginPermissionGrant,
PluginInstanceSnapshot, PluginInstanceView,
},
plugins::state::{PluginStateCommit, PluginStateConfiguration},
},
@@ -18,6 +18,36 @@ use crate::{
};
impl PluginsService {
/// 只校验固定包体,不启动插件;停用的旧版本仍需向管理端说明不能启用的原因。
async fn compatibility_warning(&self, digest: &str) -> Result<Option<String>, AdminError> {
// 运行宿主和摘要固定,缓存纯静态结论,避免管理页轮询反复解包。
// 只缓存有限数量的确定结果,暂时性错误仍可重试。
let mut cache = self.compatibility.lock().await;
if let Some(warning) = cache.get(digest) {
return Ok(warning.clone());
}
let artifact = self
.store
.load_artifact(digest)
.await
.map_err(|error| map_store_error(error, "plugin"))?;
let warning = match self
.inspector
.inspect(artifact.archive, Some(digest.to_owned()))
.await
{
Ok(_) => None,
Err(error) if error.kind() == AdminErrorKind::Invalid => {
Some(error.message().to_owned())
}
Err(error) => return Err(error),
};
if cache.len() < 128 {
cache.insert(digest.to_owned(), warning.clone());
}
Ok(warning)
}
pub async fn instances(&self) -> Result<Vec<PluginInstanceView>, AdminError> {
let snapshot = self
.store
@@ -52,10 +82,14 @@ impl PluginsService {
let metadata = artifacts
.get(&instance.artifact_sha256)
.ok_or_else(|| AdminError::not_found("插件制品不存在"))?;
let configuration_required = !self
.preparation
.configuration_ready(instance.clone(), metadata)
let compatibility_warning = self
.compatibility_warning(&instance.artifact_sha256)
.await?;
let configuration_required = compatibility_warning.is_none()
&& !self
.preparation
.configuration_ready(instance.clone(), metadata)
.await?;
let runtime = diagnostics.remove(&instance.id).unwrap_or_else(|| {
let running = instance.enabled
&& published_ready
@@ -76,6 +110,7 @@ impl PluginsService {
});
views.push(PluginInstanceView {
configuration_required,
compatibility_warning,
running: runtime.status == PluginInstanceRuntimeStatus::Running,
published_revision,
runtime,
@@ -162,6 +197,13 @@ impl PluginsService {
if artifact.accepted_at.is_none() {
return Err(AdminError::invalid("请先安装并接受该版本声明的权限"));
}
if input.enabled
&& let Some(warning) = self.compatibility_warning(&input.artifact_sha256).await?
{
return Err(AdminError::invalid(format!(
"{warning},无法启动,请安装兼容版本"
)));
}
let same_plugin = |digest: &str| {
artifacts.iter().any(|item| {
item.metadata.sha256 == digest
@@ -225,13 +267,6 @@ impl PluginsService {
.as_ref()
.map_or_else(Default::default, |instance| instance.secrets.clone())
}),
grants: artifact
.metadata
.requested_permissions
.iter()
.cloned()
.map(|permission| PluginPermissionGrant { permission })
.collect(),
bindings: input.bindings,
revision: candidate_revision,
};
@@ -690,7 +725,6 @@ fn validate_input(instance: &PluginInstance) -> Result<(), AdminError> {
|| !instance.configuration.is_object()
|| serde_json::to_vec(&instance.configuration).map_or(true, |bytes| bytes.len() > 48 * 1024)
|| instance.secrets.len() > 64
|| instance.grants.len() > 32
|| instance.bindings.len() > 64
{
return Err(AdminError::invalid("插件实例配置不合法或超过大小限制"));
@@ -16,7 +16,7 @@ use crate::{
ports::{plugin_management::PluginManagement, plugins::PluginStore},
};
/// 菜单是发布视图的投影;每次业务动作仍复核持久实例,旧页面不能保留已撤销权限。
/// 菜单来自发布视图;每次业务动作复核持久实例,旧页面不能继续使用已停用或换版的目标。
pub struct PluginManagementService {
runtime: Arc<dyn PluginManagement>,
store: Arc<dyn PluginStore>,
@@ -25,12 +25,9 @@ pub struct PluginManagementService {
impl PluginManagementService {
/// 页面发起模型请求前复核持久目标与当前发布代次;长响应可重复调用以收敛撤销。
pub async fn authorize_models(
&self,
target: &PluginManagementTarget,
) -> Result<(), AdminError> {
pub async fn validate_target(&self, target: &PluginManagementTarget) -> Result<(), AdminError> {
let reference = self.authorize(target).await?;
self.runtime.authorize_models(&reference, target).await
self.runtime.validate_target(&reference, target).await
}
pub async fn start_callback(
@@ -34,7 +34,7 @@ struct SealedReleaseManifest {
sealed: bool,
gateway_version: String,
gateway_git_sha: String,
plugin_host: PluginHostCompatibility,
plugin_host: serde_json::Value,
plugins: Vec<OfficialPlugin>,
}
@@ -150,6 +150,7 @@ fn validate_manifest(
identity: &OfficialPluginReleaseIdentity,
) -> Result<ValidatedManifest, AdminError> {
let manifest = parse_manifest(bytes)?;
parse_host_compatibility(manifest.plugin_host)?;
if manifest.gateway_version != identity.gateway_version
|| manifest.gateway_git_sha != identity.gateway_git_sha
{
@@ -168,8 +169,39 @@ pub(crate) fn update_compatibility(
target_version: &str,
) -> Result<PluginHostCompatibility, AdminError> {
let manifest = parse_manifest(bytes)?;
(manifest.gateway_version == target_version)
.then_some(manifest.plugin_host)
if manifest.gateway_version != target_version {
return Err(invalid_manifest());
}
parse_host_compatibility(manifest.plugin_host)
}
pub(crate) fn validate_update_release(
bytes: &[u8],
target_version: &str,
) -> Result<(), AdminError> {
let manifest = parse_manifest(bytes)?;
if manifest.gateway_version != target_version {
return Err(invalid_manifest());
}
Ok(())
}
fn parse_host_compatibility(
mut value: serde_json::Value,
) -> Result<PluginHostCompatibility, AdminError> {
// 旧更新器要求 v1 与空权限字段,运行兼容性仍由清单、协议和能力版本表达。
if value.get("schema_version") == Some(&serde_json::json!(1))
&& value.get("permissions") == Some(&serde_json::json!([]))
{
let fields = value.as_object_mut().ok_or_else(invalid_manifest)?;
fields.remove("permissions");
fields.insert("schema_version".into(), serde_json::json!(2));
}
let compatibility: PluginHostCompatibility =
serde_json::from_value(value).map_err(|_| invalid_manifest())?;
compatibility
.is_valid()
.then_some(compatibility)
.ok_or_else(invalid_manifest)
}
@@ -183,7 +215,6 @@ fn parse_manifest(bytes: &[u8]) -> Result<SealedReleaseManifest, AdminError> {
|| !manifest.sealed
|| !valid_text(&manifest.gateway_version, MAXIMUM_VERSION_BYTES)
|| !valid_git_sha(&manifest.gateway_git_sha)
|| !manifest.plugin_host.is_valid()
|| manifest.plugins.len() > MAXIMUM_PLUGINS
{
return Err(invalid_manifest());
@@ -252,10 +252,11 @@ fn version_bindings(
let previous = capability.and_then(|capability| current.contributes.get(capability));
previous.is_none_or(|contribution| {
!contribution.stages.contains(&binding.stage)
|| instance
.bindings
.iter()
.any(|old| old.contribution == contribution.id && old.stage == binding.stage)
|| instance.bindings.iter().any(|old| {
old.contribution == contribution.id
&& old.stage == binding.stage
&& old.event == binding.event
})
})
});
for previous in &instance.bindings {
@@ -276,7 +277,9 @@ fn version_bindings(
let mut binding = previous.clone();
binding.contribution.clone_from(&contribution.id);
if let Some(default) = bindings.iter_mut().find(|default| {
default.contribution == binding.contribution && default.stage == binding.stage
default.contribution == binding.contribution
&& default.stage == binding.stage
&& default.event == binding.event
}) {
*default = binding;
} else {
@@ -102,6 +102,53 @@ impl DefaultSettingsService {
}
}
fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> {
let valid = command.request_location.validate().is_ok()
&& command.responses_max_decompressed_body_bytes > 0
&& isize::try_from(command.responses_max_decompressed_body_bytes).is_ok()
&& command.refresh_margin_seconds > 0
&& command.refresh_concurrency > 0
&& command.max_waiting_per_key <= 1_000
&& command.max_waiting_per_account <= 1_000
&& (1..=120).contains(&command.concurrency_wait_timeout_seconds)
&& crate::model::retention::RetentionPolicy::try_new(
command.usage_retention_days,
command.ops_event_retention_days,
command.audit_retention_days,
)
.is_ok()
&& valid_client_version(command.min_codex_desktop_version.as_deref())
&& valid_client_version(command.min_codex_cli_version.as_deref())
&& valid_probe_model(command.account_auto_freeze_probe_model.as_deref())
&& gateway_core::provider_ports::valid_warmup_schedule_time(
&command.account_warmup_schedule_time,
)
&& valid_probe_model(command.account_warmup_model.as_deref())
&& (!command.account_warmup_enabled || command.account_warmup_model.is_some())
&& i64::try_from(command.request_interval_ms).is_ok()
&& (2..=1_000).contains(&command.account_auto_freeze_threshold)
&& (60..=3_600).contains(&command.account_auto_freeze_window_seconds)
&& (300..=604_800).contains(&command.account_auto_freeze_duration_seconds);
if valid {
Ok(())
} else {
Err(AdminError::invalid("运行时设置不满足约束"))
}
}
fn valid_client_version(value: Option<&str>) -> bool {
value.is_none_or(|value| CodexClientVersion::parse(value).is_ok())
}
fn valid_probe_model(value: Option<&str>) -> bool {
value.is_none_or(|value| {
!value.is_empty()
&& value.len() <= 128
&& value == value.trim()
&& !value.bytes().any(|byte| byte.is_ascii_control())
})
}
#[async_trait]
impl SettingsService for DefaultSettingsService {
async fn preview_pricing_sync(
@@ -365,7 +412,17 @@ impl SettingsService for DefaultSettingsService {
.store
.replace_runtime_settings(command, context)
.await
.map_err(|error| map_store_error(error, "runtime settings"))?;
.map_err(|error| {
if matches!(
error.kind(),
crate::ports::store::AdminStoreErrorKind::Conflict
| crate::ports::store::AdminStoreErrorKind::StaleRevision
) {
AdminError::conflict("运行设置已被其他调用修改,请重新读取并确认后提交")
} else {
map_store_error(error, "runtime settings")
}
})?;
publish_committed(self.snapshot.as_ref(), settings.config_revision).await?;
Ok(settings)
}
@@ -406,47 +463,3 @@ impl SettingsService for DefaultSettingsService {
Ok(mutation)
}
}
fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> {
let valid = command.request_location.validate().is_ok()
&& command.responses_max_decompressed_body_bytes > 0
&& isize::try_from(command.responses_max_decompressed_body_bytes).is_ok()
&& command.refresh_margin_seconds > 0
&& command.refresh_concurrency > 0
&& command.max_waiting_per_key <= 1_000
&& command.max_waiting_per_account <= 1_000
&& (1..=120).contains(&command.concurrency_wait_timeout_seconds)
&& command.usage_retention_days >= 31
&& command.ops_event_retention_days > 0
&& command.audit_retention_days > 0
&& valid_client_version(command.min_codex_desktop_version.as_deref())
&& valid_client_version(command.min_codex_cli_version.as_deref())
&& valid_probe_model(command.account_auto_freeze_probe_model.as_deref())
&& gateway_core::provider_ports::valid_warmup_schedule_time(
&command.account_warmup_schedule_time,
)
&& valid_probe_model(command.account_warmup_model.as_deref())
&& (!command.account_warmup_enabled || command.account_warmup_model.is_some())
&& i64::try_from(command.request_interval_ms).is_ok()
&& (2..=1_000).contains(&command.account_auto_freeze_threshold)
&& (60..=3_600).contains(&command.account_auto_freeze_window_seconds)
&& (300..=604_800).contains(&command.account_auto_freeze_duration_seconds);
if valid {
Ok(())
} else {
Err(AdminError::invalid("运行时设置不满足约束"))
}
}
fn valid_client_version(value: Option<&str>) -> bool {
value.is_none_or(|value| CodexClientVersion::parse(value).is_ok())
}
fn valid_probe_model(value: Option<&str>) -> bool {
value.is_none_or(|value| {
!value.is_empty()
&& value.len() <= 128
&& value == value.trim()
&& !value.bytes().any(|byte| byte.is_ascii_control())
})
}
@@ -2,6 +2,8 @@
use std::sync::Arc;
use super::plugin_update::{ConfirmedPluginRestart, PluginSystemUpdatePreflight};
use crate::model::{MutationContext, system::SystemRestartPlan};
use async_trait::async_trait;
use crate::{
@@ -14,7 +16,6 @@ use crate::{
},
ports::system::{
SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemUpdateEventStream,
SystemUpdatePreflight,
},
};
@@ -35,20 +36,25 @@ pub trait SystemService: Send + Sync {
) -> Result<SystemOperationAccepted, AdminError>;
async fn update_status(&self) -> Result<SystemUpdateStatus, AdminError>;
async fn rollback(&self) -> Result<SystemOperationAccepted, AdminError>;
async fn restart(&self) -> Result<SystemOperationAccepted, AdminError>;
async fn restart_plan(&self) -> Result<SystemRestartPlan, AdminError>;
async fn restart(
&self,
confirmation: Option<SystemRestartPlan>,
context: &MutationContext,
) -> Result<SystemOperationAccepted, AdminError>;
}
/// 保持 Host 能力窄边界的默认系统用例。
pub(crate) struct DefaultSystemService {
operations: Arc<dyn SystemOperations>,
preflight: Arc<dyn SystemUpdatePreflight>,
preflight: Arc<PluginSystemUpdatePreflight>,
}
impl DefaultSystemService {
#[must_use]
pub(crate) fn new(
operations: Arc<dyn SystemOperations>,
preflight: Arc<dyn SystemUpdatePreflight>,
preflight: Arc<PluginSystemUpdatePreflight>,
) -> Self {
Self {
operations,
@@ -87,7 +93,7 @@ impl SystemService for DefaultSystemService {
.map(|version| version.trim().to_owned())
.filter(|version| !version.is_empty());
self.operations
.perform_update(target_version, channel, Arc::clone(&self.preflight))
.perform_update(target_version, channel, self.preflight.clone())
.await
.map_err(map_system_error)
}
@@ -101,13 +107,36 @@ impl SystemService for DefaultSystemService {
async fn rollback(&self) -> Result<SystemOperationAccepted, AdminError> {
self.operations
.rollback(Arc::clone(&self.preflight))
.rollback(self.preflight.clone())
.await
.map_err(map_system_error)
}
async fn restart(&self) -> Result<SystemOperationAccepted, AdminError> {
self.operations.restart().await.map_err(map_system_error)
async fn restart_plan(&self) -> Result<SystemRestartPlan, AdminError> {
let candidate = self
.operations
.restart_candidate()
.await
.map_err(map_system_error)?;
self.preflight
.plan(candidate)
.await
.map_err(map_system_error)
}
async fn restart(
&self,
confirmation: Option<SystemRestartPlan>,
context: &MutationContext,
) -> Result<SystemOperationAccepted, AdminError> {
self.operations
.restart(Arc::new(ConfirmedPluginRestart {
preflight: self.preflight.clone(),
confirmation,
context: context.clone(),
}))
.await
.map_err(map_system_error)
}
}
@@ -120,7 +149,7 @@ fn map_system_error(error: SystemOperationError) -> AdminError {
};
let message = match kind {
AdminErrorKind::Invalid => "系统操作请求不合法",
AdminErrorKind::Conflict => "系统当前状态不允许执行该操作",
AdminErrorKind::Conflict => error.message(),
AdminErrorKind::BadGateway => "系统更新服务请求失败",
AdminErrorKind::Internal => "系统操作失败",
_ => "系统操作失败",
@@ -14,9 +14,9 @@ use gateway_admin::{
auth::AdminAuditEvent,
backup::{
BackupError, BackupObjectMetadata, BackupRecord, BackupRecordListQuery,
BackupRecordPage, BackupRecordSeed, BackupSettings, BackupStatus, BackupStorageConfig,
BackupTriggerKind, ConnectionTestResult, UpdateBackupScheduleCommand,
UpdateBackupStorageCommand,
BackupRecordPage, BackupRecordSeed, BackupSettings, BackupStatus,
BackupStatusTransition, BackupStorageConfig, BackupTriggerKind, ConnectionTestResult,
UpdateBackupScheduleCommand, UpdateBackupStorageCommand,
},
},
ports::{
@@ -330,19 +330,18 @@ impl BackupRepository for FakeBackupRepository {
async fn transition_status(
&self,
id: &str,
from: BackupStatus,
to: BackupStatus,
transition: BackupStatusTransition,
update: StatusTransitionUpdate,
now: DateTime<Utc>,
) -> AdminStoreResult<Option<BackupRecord>> {
let mut records = self.records.lock().expect("records");
let Some(record) = records
.iter_mut()
.find(|record| record.id == id && record.status == from)
.find(|record| record.id == id && record.status == transition.from())
else {
return Ok(None);
};
record.status = to;
record.status = transition.to();
if let Some(size) = update.size_bytes {
record.size_bytes = Some(size);
}
@@ -356,7 +355,11 @@ impl BackupRepository for FakeBackupRepository {
record.error_message = Some(message);
}
record.completed_at = update.completed_at.or_else(|| {
matches!(to, BackupStatus::Completed | BackupStatus::Failed).then_some(now)
matches!(
transition.to(),
BackupStatus::Completed | BackupStatus::Failed
)
.then_some(now)
});
record.updated_at = now;
Ok(Some(record.clone()))
@@ -0,0 +1,69 @@
use gateway_admin::model::{
MutationActor, MutationContext,
audit::{MutationAuditIntent, MutationAuditOperation},
auth::AuditActorKind,
};
#[test]
fn mutation_intent_preserves_actor_request_and_committed_field_names() {
let intent = MutationAuditIntent {
operation: MutationAuditOperation::PluginInstanceConfigure,
entity_ref: "instance-1",
};
for (actor, expected_kind, expected_user) in [
(
MutationActor::AdminSession {
admin_user_id: "admin-1".to_owned(),
},
AuditActorKind::AdminSession,
Some("admin-1"),
),
(
MutationActor::AdminApiKey,
AuditActorKind::AdminApiKey,
None,
),
(MutationActor::System, AuditActorKind::System, None),
] {
let event = intent.event(
&MutationContext {
actor,
request_id: "request-1".to_owned(),
},
vec!["configuration_json".to_owned()],
);
assert_eq!(event.actor_kind, expected_kind);
assert_eq!(event.actor_admin_user_id.as_deref(), expected_user);
assert_eq!(event.request_id.as_deref(), Some("request-1"));
assert_eq!(
(event.action.as_str(), event.entity_kind.as_str()),
("configure", "plugin_instance")
);
assert_eq!(event.entity_ref, "instance-1");
assert_eq!(event.changed_fields, ["configuration_json"]);
assert_eq!(event.config_revision, None);
}
}
#[test]
fn resulting_state_selects_the_existing_audit_classification() {
for enabled in [true, false] {
let action = if enabled { "enable" } else { "disable" };
assert_eq!(
MutationAuditOperation::AccountGroupEnabled { enabled }.classification(),
(action, "account_group"),
);
assert_eq!(
MutationAuditOperation::ClientApiKeyEnabled { enabled }.classification(),
(action, "client_api_key"),
);
}
assert_eq!(
MutationAuditOperation::AdminApiKeyChanged { exists: false }.classification(),
("admin_api_key.delete", "runtime_settings"),
);
assert_eq!(
MutationAuditOperation::AdminApiKeyChanged { exists: true }.classification(),
("admin_api_key.replace", "runtime_settings"),
);
}
@@ -4,19 +4,42 @@ use chrono::{TimeZone as _, Utc};
use secrecy::SecretString;
use gateway_admin::model::backup::{
BackupSettings, BackupStatus, BackupStorageConfig, BackupTriggerKind, build_object_key,
BackupSettings, BackupStatus, BackupStatusTransition, BackupStorageConfig, BackupTriggerKind,
build_object_key,
};
#[test]
fn status_machine_rejects_invalid_transitions() {
assert!(BackupStatus::Queued.allows_transition_to(BackupStatus::Dumping));
assert!(BackupStatus::Dumping.allows_transition_to(BackupStatus::Uploading));
assert!(BackupStatus::Uploading.allows_transition_to(BackupStatus::Completed));
assert!(BackupStatus::Completed.allows_transition_to(BackupStatus::Deleting));
assert!(!BackupStatus::Queued.allows_transition_to(BackupStatus::Completed));
assert!(!BackupStatus::Completed.allows_transition_to(BackupStatus::Dumping));
assert!(!BackupStatus::Deleting.allows_transition_to(BackupStatus::Failed));
assert!(!BackupStatus::Deleting.allows_transition_to(BackupStatus::Completed));
fn status_transition_accepts_every_legal_edge() {
let transitions = [
(BackupStatus::Queued, BackupStatus::Dumping),
(BackupStatus::Queued, BackupStatus::Failed),
(BackupStatus::Dumping, BackupStatus::Uploading),
(BackupStatus::Dumping, BackupStatus::Failed),
(BackupStatus::Uploading, BackupStatus::Completed),
(BackupStatus::Uploading, BackupStatus::Failed),
(BackupStatus::Completed, BackupStatus::Deleting),
(BackupStatus::Failed, BackupStatus::Deleting),
];
assert!(
transitions
.into_iter()
.all(|(from, to)| { BackupStatusTransition::try_new(from, to).is_some() })
);
}
#[test]
fn status_transition_rejects_invalid_edges() {
let transitions = [
(BackupStatus::Queued, BackupStatus::Completed),
(BackupStatus::Completed, BackupStatus::Dumping),
(BackupStatus::Deleting, BackupStatus::Failed),
(BackupStatus::Deleting, BackupStatus::Completed),
];
assert!(
transitions
.into_iter()
.all(|(from, to)| { BackupStatusTransition::try_new(from, to).is_none() })
);
}
#[test]
@@ -1,6 +1,9 @@
//! 模型模块测试:镜像 src/model/ 中带逻辑的模块。
mod audit;
mod backup;
mod plugins;
mod provider_credentials;
mod quota_forecast;
mod quota_forecast_sampling;
mod retention;
@@ -0,0 +1,105 @@
use gateway_admin::model::plugins::PluginArtifactMetadata;
use serde_json::{Value, json};
fn metadata() -> Value {
json!({
"pluginId": "test.example", "version": "1.0.0", "name": "example",
"displayName": "Example", "publisher": "test", "description": "Example plugin",
"license": "MIT", "sha256": "a".repeat(64), "platforms": ["linux-x86_64"],
"contributes": {"middleware": {
"id": "test.example.middleware", "version": 3,
"stages": [], "inputFormats": [], "outputFormats": []
}},
"configurationSchema": {}, "secretFields": [], "stateNamespaces": []
})
}
#[test]
fn metadata_ignores_unknown_fields_at_each_object_boundary() {
let mut original = metadata();
original["icon"] = json!({"light": "light.svg", "dark": "dark.svg"});
original["stateNamespaces"] = json!([{
"namespace": "cache", "schemaVersion": 1, "schemaSha256": "b".repeat(64),
"schema": {}, "maximumRecords": 10, "maximumBytes": 1024,
"maximumValueBytes": 128, "migratesFrom": []
}]);
let expected: PluginArtifactMetadata = serde_json::from_value(original.clone()).unwrap();
for path in ["", "/contributes/middleware", "/icon", "/stateNamespaces/0"] {
let mut extended = original.clone();
extended.pointer_mut(path).unwrap()["futureField"] = json!({"enabled": true});
assert_eq!(
serde_json::from_value::<PluginArtifactMetadata>(extended).unwrap(),
expected,
"unknown field at {path}"
);
}
}
#[test]
fn metadata_defaults_only_optional_fields() {
let original = metadata();
let expected: PluginArtifactMetadata = serde_json::from_value(original.clone()).unwrap();
let mut older = original;
for field in ["configurationSchema", "secretFields", "stateNamespaces"] {
older.as_object_mut().unwrap().remove(field);
}
for field in ["stages", "inputFormats", "outputFormats"] {
older["contributes"]["middleware"]
.as_object_mut()
.unwrap()
.remove(field);
}
assert_eq!(
serde_json::from_value::<PluginArtifactMetadata>(older).unwrap(),
expected
);
}
#[test]
fn metadata_still_requires_identity_and_execution_facts() {
for field in [
"pluginId",
"version",
"name",
"publisher",
"sha256",
"platforms",
"contributes",
] {
let mut incomplete = metadata();
incomplete.as_object_mut().unwrap().remove(field);
assert!(
serde_json::from_value::<PluginArtifactMetadata>(incomplete).is_err(),
"missing {field}"
);
}
for field in ["id", "version"] {
let mut incomplete = metadata();
incomplete["contributes"]["middleware"]
.as_object_mut()
.unwrap()
.remove(field);
assert!(
serde_json::from_value::<PluginArtifactMetadata>(incomplete).is_err(),
"missing contribution {field}"
);
}
}
#[test]
fn metadata_still_rejects_wrong_types_in_known_fields() {
for (path, value) in [
("/version", json!(1)),
("/secretFields", json!(false)),
("/stateNamespaces", json!({})),
("/contributes/middleware/version", json!("3")),
("/contributes/middleware/stages", json!("http")),
] {
let mut invalid = metadata();
*invalid.pointer_mut(path).unwrap() = value;
assert!(
serde_json::from_value::<PluginArtifactMetadata>(invalid).is_err(),
"wrong type at {path}"
);
}
}
@@ -114,18 +114,14 @@ fn samples(quota: &ProviderQuota) -> Vec<QuotaForecastSample> {
}
#[test]
fn forecasts_use_actual_periods_and_do_not_extrapolate_remaining_capacity() {
fn forecasts_use_actual_periods_and_extrapolate_total_capacity() {
let [week, month] = forecast(&quota(vec![window("week", 7)]));
assert_eq!(week.estimated_tokens, Some(5_000));
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.remaining_tokens, Some(4_000));
assert_eq!(week.remaining_usd, Some(8.0));
assert!(!week.extrapolated);
assert!(month.extrapolated);
assert_eq!(month.estimated_tokens, Some(21_429));
assert!((month.estimated_usd.unwrap() - 10.0 * 30.0 / 7.0).abs() < 1e-10);
assert_eq!(month.remaining_tokens, week.remaining_tokens);
assert_eq!(month.remaining_usd, week.remaining_usd);
}
#[test]
@@ -203,8 +199,8 @@ fn low_samples_warn_and_exhaustion_uses_raw_not_display_percentages() {
source.windows[0].used_percent = Some(100.0);
let [week, _] = forecast(&source);
assert!(!week.low_sample);
assert_eq!(week.remaining_tokens, Some(0));
assert_eq!(week.remaining_usd, Some(0.0));
assert_eq!(week.estimated_tokens, Some(1_000));
assert_eq!(week.estimated_usd, Some(2.0));
}
#[test]
@@ -257,8 +253,7 @@ fn incremental_sample_supports_mid_cycle_accounts_and_remaining_uses_current_per
let [week, month] =
account_quota_forecasts(&source, now() - Duration::days(1), now(), &[sample]);
assert_eq!(week.estimated_tokens, Some(9_000));
assert_eq!(week.remaining_tokens, Some(8_000));
assert_eq!(month.remaining_tokens, Some(8_000));
assert_eq!(month.estimated_tokens, Some(38_571));
assert!(week.unavailable_reason.is_none());
assert!(!week.low_sample);
}
@@ -273,7 +268,6 @@ fn missing_tokens_keep_estimates_from_recorded_usage() {
assert!(week.incomplete_tokens);
assert!(week.unavailable_reason.is_none());
assert_eq!(week.estimated_tokens, Some(5_000));
assert_eq!(week.remaining_tokens, Some(4_000));
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.source.unwrap().tokens, Some(1_000));
}
@@ -308,13 +302,12 @@ fn partial_costs_keep_estimates_from_known_amounts() {
assert!(week.incomplete_cost);
assert!(week.unavailable_reason.is_none());
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.remaining_usd, Some(8.0));
assert_eq!(week.estimated_tokens, Some(5_000));
}
}
#[test]
fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimates() {
fn missing_tokens_and_costs_do_not_block_recorded_capacity_estimates() {
let source = quota(vec![window("week", 7)]);
let mut sample = samples(&source).remove(0);
// 完整请求之外混有缺少计量的成功请求,不把已有用量和费用一并作废。
@@ -331,11 +324,7 @@ fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimate
assert!(week.unavailable_reason.is_none());
assert_eq!(week.estimated_tokens, Some(5_000));
assert_eq!(week.estimated_usd, Some(10.0));
assert_eq!(week.remaining_tokens, Some(4_000));
assert_eq!(week.remaining_usd, Some(8.0));
assert_eq!(month.estimated_tokens, Some(21_429));
assert_eq!(month.remaining_tokens, week.remaining_tokens);
assert_eq!(month.remaining_usd, week.remaining_usd);
}
#[test]
@@ -352,7 +341,6 @@ fn entirely_unknown_costs_leave_only_money_estimates_unavailable() {
assert!(week.unavailable_reason.is_none());
assert_eq!(week.estimated_tokens, Some(5_000));
assert_eq!(week.estimated_usd, None);
assert_eq!(week.remaining_usd, None);
}
#[test]
@@ -370,8 +358,6 @@ fn entirely_unknown_tokens_and_costs_do_not_invent_zero_estimates() {
assert!(week.unavailable_reason.is_some());
assert_eq!(week.estimated_tokens, None);
assert_eq!(week.estimated_usd, None);
assert_eq!(week.remaining_tokens, None);
assert_eq!(week.remaining_usd, None);
}
#[test]
@@ -420,11 +406,8 @@ fn slower_recent_consumption_preserves_recorded_cycle_usage_in_total_forecast()
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(1_084_786_565));
assert_eq!(cycle.source.as_ref().unwrap().usd, Some(1_412.57957384));
assert_eq!(cycle.remaining_tokens, Some(66_459_719));
assert_eq!(cycle.estimated_tokens, Some(1_151_246_284));
assert!((cycle.remaining_usd.unwrap() - 111.628272395294).abs() < 1e-8);
assert!((cycle.estimated_usd.unwrap() - 1_524.207846235294).abs() < 1e-8);
assert_eq!(month.remaining_tokens, cycle.remaining_tokens);
assert!(
(month.estimated_usd.unwrap() - cycle.estimated_usd.unwrap() * 30.0 / 7.0).abs() < 1e-8
);
@@ -445,6 +428,4 @@ fn exhausted_cycle_total_is_recorded_usage_even_when_recent_sample_is_small() {
let [cycle, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
assert_eq!(cycle.estimated_tokens, Some(1_000));
assert_eq!(cycle.estimated_usd, Some(2.0));
assert_eq!(cycle.remaining_tokens, Some(0));
assert_eq!(cycle.remaining_usd, Some(0.0));
}
@@ -0,0 +1,12 @@
use gateway_admin::model::retention::{RetentionPolicy, RetentionTarget};
#[test]
fn retention_policy_rejects_invalid_windows_and_preserves_independent_windows() {
for (usage, ops, audit) in [(30, 1, 1), (31, 0, 1), (31, 1, 0)] {
assert!(RetentionPolicy::try_new(usage, ops, audit).is_err());
}
let policy = RetentionPolicy::try_new(31, 7, 90).unwrap();
assert_eq!(policy.days(RetentionTarget::ModelRequests), 31);
assert_eq!(policy.days(RetentionTarget::OpsEvents), 7);
assert_eq!(policy.days(RetentionTarget::AdminAuditEvents), 90);
}
@@ -1,4 +1,7 @@
use std::sync::{Arc, Mutex};
use std::{
collections::BTreeMap,
sync::{Arc, Mutex},
};
use async_trait::async_trait;
use chrono::{TimeDelta, Utc};
@@ -55,7 +58,8 @@ use gateway_admin::{
ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind, ProviderAdminRegistry,
},
store::{
AccountStore, AdminStoreError, AdminStoreErrorKind, AdminStoreResult, SettingsStore,
AccountRuntimeStore, AccountStore, AdminStoreError, AdminStoreErrorKind,
AdminStoreResult, SettingsStore,
},
},
};
@@ -668,6 +672,12 @@ impl FakeAccountStore {
last_error_message: account.last_error_message.clone(),
};
AccountPageItem {
capacity: gateway_admin::model::accounts::AccountCapacity {
used_slots: None,
total_slots: account
.concurrency_limit
.map(|limit| u64::from(limit.get())),
},
account,
projection: resolve_account_status(&facts, std::time::SystemTime::now()),
}
@@ -1105,6 +1115,7 @@ impl SettingsStore for StaticSettingsStore {
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -2419,7 +2430,6 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
.unwrap();
assert!(result.forecasts[0].unavailable_reason.is_none());
assert_eq!(result.forecasts[0].estimated_tokens, Some(5_000));
assert_eq!(result.forecasts[0].remaining_tokens, Some(3_000));
assert_eq!(store.quota_window_queries()[0].range.start, added);
store
.quota_forecast_history
@@ -2460,7 +2470,6 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
let cycle = &result.forecasts[0];
assert!(cycle.unavailable_reason.is_none());
assert_eq!(cycle.source.as_ref().unwrap().tokens, Some(250));
assert_eq!(cycle.remaining_tokens, Some(429));
assert_eq!(cycle.estimated_tokens, Some(679));
}
@@ -3404,3 +3413,136 @@ async fn unregistered_provider_accounts_have_no_live_management_capabilities() {
);
assert_eq!(page.items[0].capabilities, Default::default());
}
#[tokio::test]
async fn plugin_quota_refresh_uses_native_provider_without_changing_configuration() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let store = FakeAccountStore::new("openai", events.clone());
let access = gateway_admin::initialize_plugin_accounts(
ProviderAdminRegistry::new([provider.clone() as Arc<dyn ProviderAdmin>]).unwrap(),
store,
Arc::new(RecordingPluginAccountPublication(events.clone())),
);
let id = ProviderAccountId::new("acct_test").unwrap();
access.get_quota(&id).await.unwrap();
access.refresh_quota(&id).await.unwrap();
assert_eq!(
provider.quota_requests(),
vec![
ProviderQuotaRequest {
account_id: id.clone(),
refresh: false,
rolling_usage: None
},
ProviderQuotaRequest {
account_id: id,
refresh: true,
rolling_usage: None
},
]
);
assert!(!recorded(&events).contains(&"snapshot.publish_committed"));
}
struct CapacityRuntime {
counts: Option<BTreeMap<String, u64>>,
fail: bool,
reads: Mutex<Vec<Vec<String>>>,
}
#[async_trait]
impl AccountRuntimeStore for CapacityRuntime {
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot::default())
}
async fn account_runtime(&self, ids: &[String]) -> AdminStoreResult<AccountRuntimeSnapshot> {
self.reads.lock().unwrap().push(ids.to_vec());
if self.fail {
return Err(store_unavailable());
}
Ok(AccountRuntimeSnapshot {
in_flight: self.counts.clone(),
..Default::default()
})
}
async fn active_freezes(
&self,
) -> AdminStoreResult<BTreeMap<String, gateway_admin::model::accounts::AccountFreeze>> {
Ok(BTreeMap::new())
}
async fn capacity_peaks(&self, _: &[String]) -> AdminStoreResult<BTreeMap<String, u32>> {
Ok(BTreeMap::new())
}
async fn finish_freeze(
&self,
_: &str,
_: &gateway_admin::model::accounts::AccountFreeze,
_: Option<chrono::DateTime<Utc>>,
) -> AdminStoreResult<bool> {
Ok(false)
}
}
#[tokio::test]
async fn account_capacity_should_batch_page_ids_and_distinguish_idle_from_unavailable() {
for (counts, fail, expected) in [
(
Some(BTreeMap::from([("acct_test".to_owned(), 3)])),
false,
Some(3),
),
(Some(BTreeMap::new()), false, Some(0)),
(None, false, None),
(None, true, None),
] {
for empty in [false, true] {
let runtime = Arc::new(CapacityRuntime {
counts: counts.clone(),
fail,
reads: Mutex::default(),
});
let mut account = account_record("openai");
account.concurrency_limit = gateway_core::account::AccountConcurrencyLimit::new(5);
let store = FakeAccountStore::with_account(account, events());
if empty {
store.set_accounts(Vec::new());
}
let services = super::AdminHarness::new()
.accounts(store)
.account_runtime(runtime.clone())
.settings(Arc::new(StaticSettingsStore))
.provider(FakeProviderAdmin::new("openai", events()))
.build()
.await;
let page = services
.accounts()
.list(AccountListQuery {
page: 1,
page_size: gateway_admin::model::PageSize::new(20).unwrap(),
provider_kind: None,
group_filter: None,
search: None,
status: None,
sort: None,
})
.await
.unwrap();
if empty {
assert!(page.items.is_empty());
assert!(runtime.reads.lock().unwrap().is_empty());
} else {
assert_eq!(
*runtime.reads.lock().unwrap(),
[vec!["acct_test".to_owned()]]
);
assert_eq!(page.items[0].capacity.used_slots, expected);
assert_eq!(page.items[0].capacity.total_slots, Some(5));
}
}
}
}
@@ -33,9 +33,19 @@ struct TestClientKeyStore {
#[async_trait]
impl ClientKeyStore for TestClientKeyStore {
async fn update_client_key_budget_limits(
&self,
_: gateway_admin::model::client_keys::UpdateClientKeyBudgetLimits,
_: gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin,
_: &MutationContext,
) -> AdminStoreResult<Option<Revision>> {
Err(unused())
}
async fn reset_client_key_budget(
&self,
command: gateway_admin::model::client_keys::ResetClientKeyBudget,
_: gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin,
_: &MutationContext,
) -> AdminStoreResult<()> {
self.resets.lock().unwrap().push(command);
@@ -147,7 +157,11 @@ async fn reset_budget_forwards_scope_and_returns_only_key_identity() {
};
let result = services
.client_keys()
.reset_budget(&mutation_context(), command.clone())
.reset_budget(
&mutation_context(),
command.clone(),
gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin::Admin,
)
.await
.unwrap();
assert_eq!(result, command.id);
@@ -38,6 +38,7 @@ fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> Runti
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: gateway_admin::model::settings::RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -44,7 +44,7 @@ use gateway_admin::{
observability::{
DashboardDesktopRelease, DashboardObservation, DashboardWireAttribute,
DashboardWireProfile, DashboardWireTarget, DesktopReleaseStatus, DiagnosticDimension,
DiagnosticObservation, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
DiagnosticsObservation, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
},
provider_credentials::{
@@ -105,6 +105,7 @@ pub(super) struct AdminHarness {
plugin_store: Arc<dyn gateway_admin::ports::plugins::PluginStore>,
plugin_inspector: Arc<dyn gateway_admin::ports::plugins::PluginPackageInspector>,
client_key_verifier: Arc<dyn ClientKeyVerifier>,
service_middleware: gateway_admin::service::PlanSource,
}
impl AdminHarness {
@@ -132,6 +133,7 @@ impl AdminHarness {
plugin_store: Arc::new(plugins::TestPluginPorts),
plugin_inspector: Arc::new(plugins::TestPluginPorts),
client_key_verifier: Arc::new(UnavailableClientKeyVerifier),
service_middleware: Arc::new(|| None),
}
}
@@ -190,6 +192,11 @@ impl AdminHarness {
self
}
pub(super) fn service_middleware(mut self, source: gateway_admin::service::PlanSource) -> Self {
self.service_middleware = source;
self
}
pub(super) fn backup(mut self, backup: BackupStorePorts) -> Self {
self.backup = backup;
self
@@ -258,8 +265,10 @@ impl AdminHarness {
self.backup,
self.plugin_store,
Arc::new(plugins::TestPluginPorts),
Arc::new(plugins::TestPluginPorts),
),
gateway_admin::AdminRuntimePorts {
service_middleware: self.service_middleware,
plugin_preparation: Arc::new(plugins::TestPluginPorts),
plugin_management: Arc::new(plugins::TestPluginPorts),
published_snapshot: gateway_core::runtime::RuntimeSnapshotHandle::default(),
@@ -643,9 +652,19 @@ impl AccountRuntimeStore for UnavailableStore {
#[async_trait]
impl ClientKeyStore for UnavailableStore {
async fn update_client_key_budget_limits(
&self,
_: gateway_admin::model::client_keys::UpdateClientKeyBudgetLimits,
_: gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin,
_: &MutationContext,
) -> AdminStoreResult<Option<Revision>> {
Err(unavailable("client key budget limits"))
}
async fn reset_client_key_budget(
&self,
_: gateway_admin::model::client_keys::ResetClientKeyBudget,
_: gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin,
_: &MutationContext,
) -> AdminStoreResult<()> {
Err(unavailable("client key budget reset"))
@@ -751,7 +770,7 @@ impl ObservabilityStore for UnavailableStore {
_: TimeRange,
_: UsageFilter,
_: DiagnosticDimension,
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
) -> AdminStoreResult<DiagnosticsObservation> {
Err(unavailable("usage diagnostics"))
}
@@ -1067,7 +1086,10 @@ impl SystemOperations for UnavailableSystem {
Err(unavailable_system())
}
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
async fn restart(
&self,
_preflight: Arc<dyn gateway_admin::ports::system::SystemRestartPreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
}
}
@@ -1090,3 +1112,4 @@ fn unavailable_system() -> SystemOperationError {
"unavailable in this test",
)
}
mod service_contract;
@@ -16,11 +16,11 @@ use gateway_admin::{
MutationContext, PageSize, Revision,
observability::{
AccountPoolMetrics, AttemptMetrics, CostCoverage, CurrencyCost, DashboardObservation,
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation, Granularity,
HealthStatus, LatencyPercentiles, OpsErrorPage, OpsErrorQuery, PercentileMilliseconds,
RequestMetricPoint, RequestMetrics, TimeRange, TrendKind, UsageBilling,
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageListRecord, UsageOverview,
UsagePage, UsageQuery, china_day_start,
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
DiagnosticsObservation, Granularity, HealthStatus, LatencyPercentiles, OpsErrorPage,
OpsErrorQuery, PercentileMilliseconds, RequestMetricPoint, RequestMetrics, TimeRange,
TrendKind, UsageBilling, UsageCalculatedBillingFact, UsageDetail, UsageFilter,
UsageListRecord, UsageOverview, UsagePage, UsageQuery, china_day_start,
},
settings::{
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
@@ -421,7 +421,10 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
.expect("calculated total"),
},
}]);
store.replace_diagnostics(vec![diagnostic("openai", 3), diagnostic("xai", 1)]);
store.replace_diagnostics(DiagnosticsObservation {
total_request_count: 4,
items: vec![diagnostic("openai", 3), diagnostic("xai", 1)],
});
let services = observability_services_with_calculated_billing(store).await;
let insights = services
@@ -502,7 +505,96 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
);
assert_eq!(diagnostics.items[0].request_share, 0.75);
assert_eq!(diagnostics.items[1].request_share, 0.25);
assert!((diagnostics.items[0].impact_score - 0.075).abs() < 1e-9);
}
#[tokio::test]
async fn diagnostics_should_preserve_request_order_when_a_small_sample_fails() {
let now = Utc::now();
let range = observation_range(now);
let store = Arc::new(FixtureObservabilityStore::new(range));
let success = diagnostic("popular", 100);
let mut failure = diagnostic("single_failure", 1);
failure.success_count = 0;
failure.failure_count = 1;
store.replace_diagnostics(DiagnosticsObservation {
total_request_count: 101,
items: vec![success, failure],
});
let services = observability_services_with_calculated_billing(store).await;
let result = services
.observability()
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Model)
.await
.expect("diagnostics");
assert_eq!(result.items[0].key, "popular");
assert_eq!(result.items[1].key, "single_failure");
assert_eq!(result.items[0].error_count, 0);
assert_eq!(result.items[1].error_count, 1);
assert_eq!(result.items[1].error_rate, 1.0);
}
#[tokio::test]
async fn diagnostics_should_use_the_total_before_truncating_groups() {
let range = observation_range(Utc::now());
let store = Arc::new(FixtureObservabilityStore::new(range));
store.replace_diagnostics(DiagnosticsObservation {
total_request_count: 200,
items: (0..100)
.map(|index| diagnostic(&format!("model-{index}"), 1))
.collect(),
});
let services = observability_services_with_calculated_billing(store).await;
let result = services
.observability()
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Model)
.await
.expect("limited diagnostics");
assert!(result.items.iter().all(|item| item.request_share == 0.005));
}
#[tokio::test]
async fn diagnostics_should_count_each_retried_request_once() {
let range = observation_range(Utc::now());
let store = Arc::new(FixtureObservabilityStore::new(range));
let mut item = diagnostic("model", 8);
item.attempt_count = 14;
item.retry_count = 6;
item.retried_request_count = 2;
store.replace_diagnostics(DiagnosticsObservation {
total_request_count: 8,
items: vec![item],
});
let services = observability_services_with_calculated_billing(store).await;
let result = services
.observability()
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Model)
.await
.expect("retry diagnostics");
assert_eq!(
(result.items[0].retry_count, result.items[0].retry_rate),
(6, 0.25)
);
}
#[tokio::test]
async fn diagnostics_should_preserve_empty_results() {
let range = observation_range(Utc::now());
let store = Arc::new(FixtureObservabilityStore::new(range));
let services = observability_services_with_calculated_billing(store).await;
let result = services
.observability()
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Account)
.await
.expect("empty diagnostics");
assert!(result.items.is_empty());
}
#[tokio::test]
@@ -608,7 +700,7 @@ struct FixtureObservabilityStore {
overview: Mutex<UsageOverview>,
calculated_billing_facts: Mutex<Vec<UsageCalculatedBillingFact>>,
billing_stream_fails: AtomicBool,
diagnostics: Mutex<Vec<DiagnosticObservation>>,
diagnostics: Mutex<DiagnosticsObservation>,
runtime_slots: Mutex<Option<DashboardRuntimeSlots>>,
summary_observed_at: Mutex<Option<DateTime<Utc>>>,
slots_observed_at: Mutex<Option<DateTime<Utc>>>,
@@ -629,7 +721,7 @@ impl FixtureObservabilityStore {
}),
calculated_billing_facts: Mutex::new(Vec::new()),
billing_stream_fails: AtomicBool::new(false),
diagnostics: Mutex::new(Vec::new()),
diagnostics: Mutex::new(DiagnosticsObservation::default()),
runtime_slots: Mutex::new(None),
summary_observed_at: Mutex::new(None),
slots_observed_at: Mutex::new(None),
@@ -662,7 +754,7 @@ impl FixtureObservabilityStore {
.expect("calculated billing facts") = facts;
}
fn replace_diagnostics(&self, diagnostics: Vec<DiagnosticObservation>) {
fn replace_diagnostics(&self, diagnostics: DiagnosticsObservation) {
*self.diagnostics.lock().expect("diagnostics") = diagnostics;
}
@@ -774,7 +866,7 @@ impl ObservabilityStore for FixtureObservabilityStore {
_: TimeRange,
_: UsageFilter,
_: DiagnosticDimension,
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
) -> AdminStoreResult<DiagnosticsObservation> {
Ok(self.diagnostics.lock().expect("diagnostics").clone())
}
@@ -821,6 +913,7 @@ impl SettingsStore for FixtureSettingsStore {
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -923,6 +1016,8 @@ fn total_record(
provider_account_name: None,
provider_account_email: None,
provider_account_notes: None,
provider_account_plan_type: None,
provider_account_plan_type_display: None,
provider_account_authentication_kind: None,
upstream_model_id: Some("gpt-5.5".to_owned()),
upstream_transport: None,
@@ -985,6 +1080,9 @@ fn diagnostic(name: &str, request_count: u64) -> DiagnosticObservation {
first_token_p95_ms: None,
non_completion_count: 0,
retry_count: 0,
retried_request_count: 0,
account_provider_kind: None,
account_plan_type: None,
key: name.to_owned(),
name: name.to_owned(),
request_count,
@@ -28,6 +28,8 @@ use gateway_admin::{
struct Fixture {
revisions: std::sync::Mutex<Vec<Revision>>,
requirements: PluginCompatibilityRequirements,
instances: Vec<PluginInstance>,
disabled: std::sync::Mutex<Vec<String>>,
}
impl Fixture {
@@ -40,6 +42,8 @@ impl Fixture {
.collect(),
),
requirements,
instances: vec![instance(true)],
disabled: std::sync::Mutex::new(Vec::new()),
})
}
@@ -95,18 +99,17 @@ impl PluginStore for Fixture {
async fn load_instances(&self) -> AdminStoreResult<PluginInstanceSnapshot> {
Ok(PluginInstanceSnapshot {
config_revision: self.next_revision(),
instances: vec![PluginInstance {
id: "enabled-fixture".into(),
name: "Enabled fixture".into(),
artifact_sha256: "a".repeat(64),
enabled: true,
trusted_process: true,
configuration: serde_json::json!({}),
secrets: BTreeMap::new(),
grants: Vec::new(),
bindings: Vec::new(),
revision: Revision::new(1).expect("revision"),
}],
instances: self
.instances
.iter()
.cloned()
.map(|mut instance| {
if self.disabled.lock().unwrap().contains(&instance.id) {
instance.enabled = false;
}
instance
})
.collect(),
})
}
@@ -126,7 +129,7 @@ impl PluginStore for Fixture {
platforms: Vec::new(),
icon: None,
contributes: BTreeMap::new(),
requested_permissions: Vec::new(),
configuration_schema: serde_json::json!({}),
secret_fields: Vec::new(),
state_namespaces: Vec::new(),
@@ -135,6 +138,24 @@ impl PluginStore for Fixture {
})
}
async fn disable_instances(
&self,
ids: &[String],
expected: Revision,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
let mut revisions = self.revisions.lock().unwrap();
if revisions[0] != expected {
return Err(gateway_admin::ports::store::AdminStoreError::new(
gateway_admin::ports::store::AdminStoreErrorKind::StaleRevision,
"plugin",
"stale",
));
}
revisions[0] = Revision::new(expected.get() + 1).unwrap();
self.disabled.lock().unwrap().extend_from_slice(ids);
Ok(revisions[0])
}
async fn save_instance(
&self,
_: PluginInstance,
@@ -273,23 +294,41 @@ impl SystemOperations for PreflightingSystem {
async fn rollback(
&self,
_: Arc<dyn SystemUpdatePreflight>,
preflight: Arc<dyn SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
unreachable!()
let revision = preflight.validate_rollback(self.candidate.clone()).await?;
preflight.confirm_revision(revision).await?;
Ok(SystemOperationAccepted::Rollback {
operation_id: "fixture".into(),
message: "accepted".into(),
need_restart: true,
})
}
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
unreachable!()
async fn restart_candidate(
&self,
) -> Result<Option<SystemUpdateCandidate>, SystemOperationError> {
Ok(Some(self.candidate.clone()))
}
async fn restart(
&self,
preflight: Arc<dyn gateway_admin::ports::system::SystemRestartPreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
preflight.prepare(Some(self.candidate.clone())).await?;
Ok(SystemOperationAccepted::Restart {
operation_id: "fixture".into(),
message: "accepted".into(),
})
}
}
#[tokio::test]
async fn system_update_accepts_compatible_enabled_plugins_at_the_same_revision() {
let fixture = Fixture::new(requirements("^1.0", "executor", "log"), &[7]);
let fixture = Fixture::new(requirements("^1.0", "executor"), &[7]);
let services = super::AdminHarness::new()
.plugins(fixture.clone(), fixture)
.system(Arc::new(PreflightingSystem {
candidate: candidate("executor", "log"),
candidate: candidate("executor"),
}))
.build()
.await;
@@ -302,28 +341,25 @@ async fn system_update_accepts_compatible_enabled_plugins_at_the_same_revision()
}
#[tokio::test]
async fn system_update_rejects_missing_target_capability_and_revision_changes() {
let incompatible = Fixture::new(requirements("^1.0", "executor", "log"), &[7]);
async fn rollback_rejects_missing_target_capability() {
let incompatible = Fixture::new(requirements("^1.0", "executor"), &[7]);
let services = super::AdminHarness::new()
.plugins(incompatible.clone(), incompatible)
.system(Arc::new(PreflightingSystem {
candidate: candidate("models", "log"),
candidate: candidate("models"),
}))
.build()
.await;
assert!(
services
.system()
.perform_update(Some("1.2.0".into()), None)
.await
.is_err()
);
assert!(services.system().rollback().await.is_err());
}
let stale = Fixture::new(requirements("^1.0", "executor", "log"), &[7, 8]);
#[tokio::test]
async fn system_update_rejects_revision_changes() {
let stale = Fixture::new(requirements("^1.0", "executor"), &[7, 8]);
let services = super::AdminHarness::new()
.plugins(stale.clone(), stale)
.system(Arc::new(PreflightingSystem {
candidate: candidate("executor", "log"),
candidate: candidate("executor"),
}))
.build()
.await;
@@ -336,32 +372,117 @@ async fn system_update_rejects_missing_target_capability_and_revision_changes()
);
}
fn requirements(
host_version: &str,
capability: &str,
permission: &str,
) -> PluginCompatibilityRequirements {
#[tokio::test]
async fn system_update_accepts_new_plugin_contracts_for_empty_disabled_and_enabled_plugins() {
for instances in [vec![], vec![instance(false)], vec![instance(true)]] {
let mut fixture = Fixture::new(requirements("^1.0", "executor"), &[7]);
Arc::get_mut(&mut fixture)
.expect("exclusive fixture")
.instances = instances;
let mut candidate = candidate("models");
let mut manifest: serde_json::Value =
serde_json::from_slice(&candidate.release_manifest).unwrap();
manifest["plugin_host"] =
serde_json::json!({"schema_version": 99, "future_contract": true});
candidate.release_manifest = serde_json::to_vec(&manifest).unwrap().into();
let services = super::AdminHarness::new()
.plugins(fixture.clone(), fixture)
.system(Arc::new(PreflightingSystem { candidate }))
.build()
.await;
services
.system()
.perform_update(Some("1.2.0".into()), None)
.await
.expect("plugin confirmation is deferred until restart");
}
}
#[tokio::test]
async fn system_update_keeps_release_identity_validation_without_plugins() {
for (field, value) in [
("gateway_version", serde_json::json!("1.3.0")),
("gateway_git_sha", serde_json::json!("invalid")),
("sealed", serde_json::json!(false)),
("schema_version", serde_json::json!(99)),
] {
let mut fixture = Fixture::new(requirements("^1.0", "executor"), &[7]);
Arc::get_mut(&mut fixture).unwrap().instances.clear();
let mut candidate = candidate("executor");
let mut manifest: serde_json::Value =
serde_json::from_slice(&candidate.release_manifest).unwrap();
manifest[field] = value;
candidate.release_manifest = serde_json::to_vec(&manifest).unwrap().into();
let services = super::AdminHarness::new()
.plugins(fixture.clone(), fixture)
.system(Arc::new(PreflightingSystem { candidate }))
.build()
.await;
assert!(
services
.system()
.perform_update(Some("1.2.0".into()), None)
.await
.is_err(),
"{field}"
);
}
}
#[tokio::test]
async fn system_update_still_rechecks_revision_without_plugins() {
let mut fixture = Fixture::new(requirements("^1.0", "executor"), &[7, 8]);
Arc::get_mut(&mut fixture).unwrap().instances.clear();
let services = super::AdminHarness::new()
.plugins(fixture.clone(), fixture)
.system(Arc::new(PreflightingSystem {
candidate: candidate("executor"),
}))
.build()
.await;
assert!(
services
.system()
.perform_update(Some("1.2.0".into()), None)
.await
.is_err()
);
}
fn instance(enabled: bool) -> PluginInstance {
PluginInstance {
id: "enabled-fixture".into(),
name: "Enabled fixture".into(),
artifact_sha256: "a".repeat(64),
enabled,
trusted_process: true,
configuration: serde_json::json!({}),
secrets: BTreeMap::new(),
bindings: Vec::new(),
revision: Revision::new(1).expect("revision"),
}
}
fn requirements(host_version: &str, capability: &str) -> PluginCompatibilityRequirements {
PluginCompatibilityRequirements {
host_version: host_version.into(),
manifest_schema_version: 2,
protocol_version: 3,
capabilities: vec![(capability.into(), 1)],
permissions: vec![permission.into()],
}
}
fn candidate(capability: &str, permission: &str) -> SystemUpdateCandidate {
fn candidate(capability: &str) -> SystemUpdateCandidate {
let manifest = serde_json::to_vec(&serde_json::json!({
"schema_version": 1,
"sealed": true,
"gateway_version": "1.2.0",
"gateway_git_sha": "a".repeat(40),
"plugin_host": {
"schema_version": 1,
"schema_version": 2,
"manifest_schema_versions": [2],
"protocol_versions": [3],
"capabilities": [{ "capability": capability, "versions": [1] }],
"permissions": [permission],
},
"plugins": [],
}))
@@ -371,3 +492,106 @@ fn candidate(capability: &str, permission: &str) -> SystemUpdateCandidate {
release_manifest: manifest.into(),
}
}
fn context() -> MutationContext {
MutationContext {
actor: gateway_admin::model::MutationActor::AdminApiKey,
request_id: "restart-test".into(),
}
}
#[tokio::test]
async fn incompatible_restart_requires_exact_confirmation_before_disabling() {
let fixture = Fixture::new(requirements("^1.0", "executor"), &[7]);
let services = super::AdminHarness::new()
.plugins(fixture.clone(), fixture.clone())
.system(Arc::new(PreflightingSystem {
candidate: candidate("models"),
}))
.build()
.await;
let plan = services.system().restart_plan().await.unwrap();
assert_eq!(plan.incompatible_plugins.len(), 1);
assert_eq!(plan.incompatible_plugins[0].name, "Enabled fixture");
assert!(
fixture.disabled.lock().unwrap().is_empty(),
"检查及取消不能停用插件"
);
assert!(services.system().restart(None, &context()).await.is_err());
for altered in 0..3 {
let mut changed = plan.clone();
match altered {
0 => changed.config_revision += 1,
1 => changed.release_manifest_sha256 = Some("b".repeat(64)),
_ => changed.incompatible_plugins.clear(),
}
assert!(
services
.system()
.restart(Some(changed), &context())
.await
.is_err()
);
assert!(fixture.disabled.lock().unwrap().is_empty());
}
services
.system()
.restart(Some(plan), &context())
.await
.unwrap();
assert_eq!(*fixture.disabled.lock().unwrap(), ["enabled-fixture"]);
assert!(
services
.system()
.restart_plan()
.await
.unwrap()
.incompatible_plugins
.is_empty()
);
}
#[tokio::test]
async fn compatible_restart_does_not_change_plugin_configuration() {
let fixture = Fixture::new(requirements("^1.0", "executor"), &[7]);
let services = super::AdminHarness::new()
.plugins(fixture.clone(), fixture.clone())
.system(Arc::new(PreflightingSystem {
candidate: candidate("executor"),
}))
.build()
.await;
assert!(
services
.system()
.restart_plan()
.await
.unwrap()
.incompatible_plugins
.is_empty()
);
services.system().restart(None, &context()).await.unwrap();
assert!(fixture.disabled.lock().unwrap().is_empty());
assert_eq!(fixture.next_revision().get(), 7);
}
#[tokio::test]
async fn changed_configuration_after_restart_check_requires_a_new_confirmation() {
let fixture = Fixture::new(requirements("^1.0", "executor"), &[7, 8]);
let services = super::AdminHarness::new()
.plugins(fixture.clone(), fixture.clone())
.system(Arc::new(PreflightingSystem {
candidate: candidate("models"),
}))
.build()
.await;
let plan = services.system().restart_plan().await.unwrap();
assert!(
services
.system()
.restart(Some(plan), &context())
.await
.is_err()
);
assert!(fixture.disabled.lock().unwrap().is_empty());
}
@@ -70,7 +70,7 @@ impl Fixture {
let unused = Arc::new(TestPluginPorts);
PluginsService::new(
self.clone(),
unused.clone(),
self.clone(),
PluginDistributionPorts::new(unused.clone(), unused.clone()),
self.clone(),
self.clone(),
@@ -87,6 +87,20 @@ impl SnapshotControl for Fixture {
}
}
#[async_trait]
impl gateway_admin::ports::plugins::PluginPackageInspector for Fixture {
async fn inspect(
&self,
archive: Arc<[u8]>,
_: Option<String>,
) -> Result<InspectedPluginArtifact, AdminError> {
Ok(InspectedPluginArtifact {
metadata: self.data.lock().unwrap().artifact.metadata.clone(),
archive,
})
}
}
struct Lease;
impl ExtensionSetLease for Lease {
@@ -95,6 +109,44 @@ impl ExtensionSetLease for Lease {
}
}
#[async_trait]
impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for Fixture {
async fn runtime_diagnostics(
&self,
_: &gateway_admin::model::plugins::instances::PluginInstanceSnapshot,
_: Option<u64>,
_: Option<&gateway_core::runtime::extensions::ExtensionSetReference>,
) -> Option<
std::collections::BTreeMap<
String,
gateway_admin::model::plugins::instances::PluginInstanceRuntime,
>,
> {
None
}
}
#[async_trait]
impl gateway_admin::ports::plugins::PluginStateLifecycle for Fixture {
async fn activate_state(
&self,
_: &gateway_core::runtime::extensions::ExtensionSetReference,
_: &gateway_admin::model::plugins::instances::PluginInstance,
) -> Result<(), AdminError> {
Ok(())
}
async fn quiesce_instance(&self, _: &str, _: &str, _: gateway_admin::model::Revision) {
panic!("unexpected instance drain")
}
async fn migrate_state(
&self,
_: &gateway_core::runtime::extensions::ExtensionSetReference,
_: gateway_admin::model::plugins::state::PluginStateTransition,
) -> Result<(), AdminError> {
panic!("unexpected state migration")
}
}
#[async_trait]
impl PluginPreparation for Fixture {
async fn configuration_ready(
@@ -222,7 +274,10 @@ impl PluginStore for Fixture {
}
async fn load_artifact(&self, _: &str) -> AdminStoreResult<InspectedPluginArtifact> {
Err(store_error(AdminStoreErrorKind::Unavailable))
Ok(InspectedPluginArtifact {
metadata: self.data.lock().unwrap().artifact.metadata.clone(),
archive: Arc::from([1_u8]),
})
}
async fn install_artifact(
@@ -337,7 +392,7 @@ async fn existing_plugin_instance_prevents_an_extra_default_instance() {
trusted_process: false,
configuration: json!({}),
secrets: BTreeMap::new(),
grants: Vec::new(),
bindings: Vec::new(),
revision: revision(1),
};
@@ -389,7 +444,7 @@ fn metadata(with_required_default: bool) -> PluginArtifactMetadata {
contribution("test.example.middleware", &["observation", "request"]),
),
]),
requested_permissions: vec!["network".into()],
configuration_schema: json!({
"type":"object",
"properties": {

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